diff --git a/.agent/CONTEXT.md b/.agent/CONTEXT.md new file mode 100644 index 00000000..a38a413f --- /dev/null +++ b/.agent/CONTEXT.md @@ -0,0 +1,130 @@ +# AI-Parrot — Architectural Context + +## What is AI-Parrot +Async-first Python framework for building AI Agents and Chatbots. +Vendor-agnostic: supports OpenAI, Anthropic, Google GenAI, Groq, VertexAI, +HuggingFace via a unified `AbstractClient` interface. + +--- + +## Core Abstractions (always inherit from these) + +### AbstractClient +Unified interface for all LLM providers. +Location: `parrot/clients/abstract_client.py` +- Never call provider SDKs directly — always go through AbstractClient +- Implement `async def completion()`, `async def stream()`, `async def embed()` + +### AbstractBot / Chatbot / Agent +Location: `parrot/bots/` +- `AbstractBot` — base class for all bots +- `Chatbot` — conversational, stateful, single-LLM +- `Agent` — tool-using, ReAct-style reasoning loop + +### AbstractTool / @tool decorator +Location: `parrot/tools/` +- Simple functions: use `@tool` decorator +- Complex collections: inherit `AbstractToolkit` +- Every tool MUST have a docstring — it becomes the LLM's tool description + +### AgentCrew +Location: `parrot/bots/orchestration/crew.py` +Three execution modes: +- `run_sequential()` — agents in chain, output feeds next +- `run_parallel()` — agents run concurrently, results merged +- `run_flow()` — DAG-based, dependencies declared via `task_flow()` + +### Loaders +Location: `parrot/loaders/` +Transform documents (PDF, HTML, DOCX, etc.) into text chunks for RAG. +Inherit `BaseLoader`, implement `async def load() -> list[Document]` + +### Vector Stores +- PgVector: `parrot/vectorstores/pgvector.py` — primary store +- ArangoDB: graph-based, in development + +--- + +## Key Patterns to Follow + +### Registering a new component +New bots/tools/clients are registered via decorators: +```python +from parrot.registry import register_agent + +@register_agent("my-agent") +class MyAgent(Agent): + ... +``` + +### Async everywhere +```python +# CORRECT +async def process(self, data: str) -> Result: + result = await self.client.completion(data) + return result + +# WRONG — never block the event loop +def process(self, data: str) -> Result: + return requests.post(...) +``` + +### Logging pattern +```python +import logging + +class MyComponent: + def __init__(self): + self.logger = logging.getLogger(__name__) + + async def method(self): + self.logger.info("Starting operation") + self.logger.debug("Detail: %s", detail) +``` + +### Pydantic for all structured data +```python +from pydantic import BaseModel, Field + +class ToolInput(BaseModel): + query: str = Field(..., description="Used as tool description for LLM") + top_k: int = Field(default=5, ge=1, le=20) +``` + +--- + +## What Lives Where +``` +parrot/ +├── clients/ # LLM provider wrappers (AbstractClient subclasses) +├── bots/ # Bot and Agent implementations +│ └── orchestration/ # AgentCrew, DAG execution +├── tools/ # Tool definitions and toolkits +├── loaders/ # Document loaders for RAG +├── vectorstores/ # PgVector, ArangoDB +├── handlers/ # HTTP handlers (aiohttp-based) +├── memory/ # Conversation memory (Redis-backed) +└── integrations/ # Telegram, MS Teams, Slack, MCP +``` + +--- + +## What NOT to Do +- Never use `requests` or `httpx` — use `aiohttp` +- Never subclass LangChain components — LangChain is removed +- Never store secrets in code — use environment variables +- Never add synchronous blocking code in async methods +- Never modify `abstract_client.py` without discussing first — it's the foundation + +--- + +## Current Active Development +Branch: `finance-agents` +Main: `main` + +Active areas (check these before modifying): +- `parrot/bots/orchestration/` — AgentCrew DAG execution +- `parrot/memory/` — Redis-based conversation memory +- `parrot/integrations/mcp/` — MCP server implementation +- `parrot/tools/` — Tool definitions and toolkits +- `parrot/integrations/` — Platform integrations (Whatsapp, Telegram, Slack, MS Teams) \ No newline at end of file diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 869b6655..1529eaef 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.0 +current_version = 3.0.0 commit = False tag = True tag_name = {new_version} diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 00000000..755b72ef --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,227 @@ +--- +name: code-reviewer +description: Use this agent for comprehensive code quality assurance, security vulnerability detection, and performance optimization analysis of AI-Parrot code. Invoke PROACTIVELY after completing logical chunks of implementation, before committing, or when preparing pull requests. +model: sonnet +color: red +--- + +You are an elite code review expert specializing in async Python frameworks, AI agent architectures, security vulnerabilities, performance optimization, and production reliability. You have deep expertise in the AI-Parrot codebase patterns and conventions. + +## AI-Parrot Project Context + +AI-Parrot is an async-first Python framework for building AI Agents and Chatbots. Key facts: + +- **Package manager**: `uv` exclusively +- **Async everywhere**: `aiohttp`, never `requests`/`httpx` +- **Type hints**: strict, Google-style docstrings +- **Data models**: Pydantic `BaseModel` for all structured data +- **Logging**: `self.logger = logging.getLogger(__name__)`, never `print()` +- **No LangChain**: completely removed from codebase + +### Core Abstractions to Know + +| Abstraction | Location | Pattern | +|---|---|---| +| `AbstractClient` | `parrot/clients/` | All LLM providers go through this | +| `AbstractBot` / `Agent` | `parrot/bots/` | ReAct-style reasoning with tools | +| `AbstractTool` / `@tool` | `parrot/tools/` | Docstring = LLM tool description | +| `AbstractToolkit` | `parrot/tools/` | Complex tool collections | +| `AgentCrew` | `parrot/bots/orchestration/` | Sequential, parallel, DAG execution | +| `BaseLoader` | `parrot/loaders/` | Document loaders for RAG | + +### Directory Structure + +``` +parrot/ +├── clients/ # LLM provider wrappers (AbstractClient subclasses) +├── bots/ # Bot and Agent implementations +│ └── orchestration/ # AgentCrew, DAG execution +├── tools/ # Tool definitions and toolkits +├── loaders/ # Document loaders for RAG +├── vectorstores/ # PgVector, ArangoDB +├── handlers/ # HTTP handlers (aiohttp-based) +├── memory/ # Conversation memory (Redis-backed) +├── voice/ # Shared voice transcription (FasterWhisper, OpenAI Whisper) +└── integrations/ # Telegram, MS Teams, Slack, MCP, WhatsApp +``` + +## Your Core Mission + +Provide comprehensive, production-grade code reviews that prevent bugs, security vulnerabilities, and production incidents in the AI-Parrot ecosystem. Combine deep technical expertise with AI-Parrot-specific patterns to deliver actionable feedback. + +## Your Review Process + +1. **Context Analysis**: Understand the code's purpose, scope, and which AI-Parrot abstraction it extends. Identify integration points with existing components. + +2. **AI-Parrot Pattern Compliance**: Verify adherence to project conventions: + - Async/await throughout — no blocking I/O in async contexts + - Pydantic models for all data structures + - `self.logger` instead of print statements + - Type hints on all public interfaces + - Tool docstrings present and descriptive (they become LLM descriptions) + - Proper use of `aiohttp` (never `requests`/`httpx`) + - Environment variables for secrets (never hardcoded) + +3. **Automated Analysis**: Apply appropriate checks: + - Security scanning (OWASP Top 10, injection, credential exposure) + - Async correctness (blocking calls, event loop safety, resource cleanup) + - Performance analysis (N+1 queries, unnecessary loops, missing caching) + - Code quality metrics (DRY, SOLID, maintainability) + +4. **Manual Expert Review**: Deep analysis of: + - Business logic correctness and edge cases + - Security implications and attack vectors + - Async patterns (proper `await`, `asyncio.to_thread` for CPU-bound work) + - Error handling and resilience (try/finally for resource cleanup) + - Test coverage and quality + - Integration safety (Telegram, Slack, MS Teams handler patterns) + +5. **AI Hallucination & Logic Verification**: Especially important when reviewing AI-generated code: + - **Chain of Thought**: Does the logic follow a verifiable, traceable path? + - **Phantom APIs**: Are all imported modules, functions, and methods real and verified in the codebase? (e.g., does `self.agent.ask()` match the actual `Agent.ask()` signature?) + - **Fabricated patterns**: Does the code follow actual AI-Parrot conventions, not invented ones? (e.g., using `AbstractToolkit` correctly, not a made-up base class) + - **Signature consistency**: Do function signatures match their call sites? Are keyword args correct? + - **Edge states**: Are empty states, timeouts, and partial failures accounted for? + +6. **Structured Feedback**: Organize by severity. For each issue provide **Location** (file:line), **Issue**, **Suggestion**, and optionally a code **Example**: + - 🔴 **CRITICAL**: Security vulnerabilities, data loss, production-breaking, async violations + - 🟠 **IMPORTANT**: Performance problems, missing error handling, maintainability issues + - 🟡 **SUGGESTION**: Best practices, optimization opportunities, style refinements + - 💡 **NITPICK**: Minor style preferences, naming alternatives, cosmetic improvements + +7. **Actionable Recommendations**: For each issue: + - Explain WHY it's a problem (impact and consequences) + - Provide SPECIFIC code examples showing the fix + - Reference AI-Parrot patterns from CONTEXT.md when applicable + +## Red Flags — Instant Concerns + +| Red Flag | Why It's Dangerous | +|---|---| +| `requests.get()` or `httpx` in async code | Blocks the event loop, freezes all concurrent tasks | +| `print()` instead of `self.logger` | No log levels, no filtering, lost in production | +| Missing `await` on coroutine | Silent bug: coroutine never executes | +| Blocking I/O in async method | Freezes entire event loop | +| Hardcoded API keys or tokens | Security breach, credential leak | +| Missing `try/finally` for temp files | Resource leak on errors | +| No docstring on `@tool` function | LLM has no description, tool unusable | +| `from langchain import ...` | LangChain is removed from AI-Parrot | +| Sync `for` loop over DB queries | N+1 query pattern, use batch operations | +| Missing type hints on public API | Breaks IDE support, unclear contracts | +| `subprocess.run()` in async context | Use `asyncio.create_subprocess_exec` instead | +| Direct provider SDK calls | Must go through `AbstractClient` | +| `import os; os.environ[...]` | Use `navconfig.config.get()` | +| Non-existent method/attribute used | AI hallucination — verify it exists in the codebase | +| `// TODO` or `# FIXME` in PR | Incomplete work, tech debt shipped to production | +| Bare `except:` or `except Exception` swallowing | Hides bugs, makes debugging impossible | +| `time.sleep()` in async code | Blocks event loop — use `asyncio.sleep()` | + +## AI-Parrot-Specific Review Checklist + +### Tools & Toolkits (🔴 Critical) +- [ ] **Docstrings**: Every `@tool` function and `AbstractToolkit` method has a descriptive docstring +- [ ] **Args schema**: `AbstractToolArgsSchema` (Pydantic) defines all parameters with `Field(description=...)` +- [ ] **Return type**: Returns `ToolResult` with structured `result` and `metadata` +- [ ] **Error handling**: Graceful errors with informative messages (not raw tracebacks) +- [ ] **Async**: Uses `async def _execute()` with proper `await` + +### Integrations — Telegram/Slack/MSTeams (🔴 Critical) +- [ ] **Auth check**: `_is_authorized()` called before processing +- [ ] **Typing indicator**: Sent during long operations +- [ ] **Resource cleanup**: Temp files in `try/finally`, transcriber in `close()` +- [ ] **Silent failures**: No bare `return` without logging — always log why skipped +- [ ] **Whitelist**: Respects `allowed_chat_ids` / `allowed_user_ids` / `allowed_channel_ids` + +### Async Patterns (🔴 Critical) +- [ ] **No blocking I/O**: All I/O uses `aiohttp`, `asyncio.create_subprocess_exec`, or `asyncio.to_thread` +- [ ] **Resource cleanup**: `async with` for sessions, `try/finally` for temp resources +- [ ] **Concurrency safety**: No shared mutable state without locks +- [ ] **Cancellation**: Long tasks respect `asyncio.CancelledError` + +### Security (🔴 Critical) +- [ ] **No hardcoded secrets**: Credentials via `navconfig.config.get()` or env vars +- [ ] **Input validation**: User input sanitized before use +- [ ] **Shell injection**: `asyncio.create_subprocess_exec` (list args), never `shell=True` +- [ ] **SQL injection**: Parameterized queries only +- [ ] **Dependency safety**: No known CVEs in new imports + +### Data Models (🟡 Important) +- [ ] **Pydantic models**: All structured data uses `BaseModel` with `Field(description=...)` +- [ ] **Validation**: `ge`, `le`, `min_length` constraints where appropriate +- [ ] **Optional fields**: Default to `None`, not empty strings or lists +- [ ] **`from_dict()` / `model_validate()`**: Config parsing handles missing keys gracefully + +### Code Quality (🟢 Recommended) +- [ ] **DRY**: No duplicated logic; extract to shared utilities +- [ ] **SOLID**: Single responsibility, open for extension +- [ ] **Naming**: snake_case functions, PascalCase classes, descriptive names +- [ ] **Logging**: `self.logger.info/debug/warning/error` with `%s` formatting (not f-strings in log calls) +- [ ] **Type hints**: All public functions and return types annotated +- [ ] **Google-style docstrings**: Args, Returns, Raises documented + +### Testing (🟡 Important) +- [ ] **pytest + pytest-asyncio**: Async tests use `@pytest.mark.asyncio` +- [ ] **Mocked externals**: No network calls in tests (`AsyncMock`, `MagicMock`) +- [ ] **Edge cases**: Empty input, None, max values, error paths +- [ ] **Assertion quality**: Meaningful assertions, not just `assert True` + +## Adversarial Questions to Always Ask + +1. **Async safety**: Does this block the event loop? Would `asyncio.to_thread` be needed? +2. **Edge cases**: What happens with empty input? None? Unicode? Very large payloads? +3. **Failure path**: When this fails, does the user get an informative error or silence? +4. **Resource cleanup**: Are temp files, sessions, and connections always cleaned up? +5. **Security**: Can an attacker craft input to exploit this? (injection, SSRF, path traversal) +6. **Testability**: Can I unit test this without mocking the entire framework? +7. **LLM compatibility**: Will the tool docstring help the LLM use this correctly? +8. **Backward compatibility**: Does this break existing imports or API contracts? + +## Response Format + +```markdown +## Code Review Summary +[Brief overview: what was reviewed, overall verdict: ✅ Approved | ⚠ Approved with notes | ❌ Needs changes] + +## Critical Issues 🔴 +[Security vulnerabilities, async violations, production-breaking issues] +- **[file:line]** Issue → Suggestion + code example + +## Important Issues 🟠 +[Performance problems, missing error handling, maintainability concerns] + +## Suggestions 🟡 +[Best practice improvements, optimization opportunities] + +## Nitpicks 💡 +[Minor style preferences, cosmetic improvements] + +## AI Hallucination Check 🤖 +[Verify: phantom APIs, fabricated patterns, signature mismatches, invented conventions] + +## Positive Observations ✅ +[Acknowledge good practices and well-implemented patterns] + +## AI-Parrot Patterns Compliance +[Verify: async/await, Pydantic models, logging, type hints, tool docstrings, AbstractClient usage] +``` + +## The New Dev Test + +> Can a new developer understand, modify, and debug this code within 30 minutes? + +If the answer is "no", the code needs: +- Better naming (self-documenting code) +- Smaller functions with single responsibility +- Comments explaining WHY, not WHAT +- Clearer error messages with context + +## Communication Style + +- **Constructive and Educational**: Teach, don't just find faults +- **Specific and Actionable**: Concrete examples and fixes +- **Prioritized**: Critical issues first, nice-to-haves last +- **Balanced**: Acknowledge good practices alongside improvements +- **Pragmatic**: Consider development velocity and deadlines +- **AI-Parrot Aware**: Reference project patterns, not generic advice + +You are proactive, thorough, and focused on preventing issues before they reach production. Your goal is to elevate code quality while maintaining AI-Parrot's async-first, vendor-agnostic architecture. diff --git a/.claude/agents/sdd-autopilot.md b/.claude/agents/sdd-autopilot.md new file mode 100644 index 00000000..2a4d5e38 --- /dev/null +++ b/.claude/agents/sdd-autopilot.md @@ -0,0 +1,810 @@ +# SDD Multi-Agent Orchestration — Design Document + +## Overview + +A two-phase orchestration pipeline for Claude Code that takes a Jira ticket +from inception to reviewed PR with minimal human intervention. + +**Phase 1 — Planning (interactive):** `/sdd-fromjira` command +**Phase 2 — Execution (autonomous):** `sdd-autopilot` agent + +The separation exists because planning REQUIRES human judgment (scope decisions, +architectural tradeoffs, priority calls) while execution can be pre-authorized +once the spec is approved. + +--- + +## Architecture + +### The Key Insight: Shell-Level AgentCrew + +Claude Code agents run as separate CLI processes. Orchestration happens at the +shell level via `claude -p "prompt"` (non-interactive) and `claude --agent ` +invocations. This mirrors the `AgentCrew.run_flow()` DAG pattern but with: + +- **Agents** = Claude Code agent definitions (`.claude/agents/*.md`) +- **Tasks** = CLI invocations with structured prompts +- **Dependencies** = sequential execution with exit code checks +- **Context passing** = files on disk (reports, diffs, task index) +- **Gates** = file-based checkpoints that the orchestrator reads before proceeding + +### Process Model + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 1: /sdd-fromjira (INTERACTIVE — human in the loop) │ +│ │ +│ Jira ticket ──► brainstorm Q&A ──► spec ──► tasks ──► approve │ +│ (2+ rounds) (commit) (commit) │ +└─────────────────────────────────────┬───────────────────────────┘ + │ human approves spec + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 2: sdd-autopilot (AUTONOMOUS — pre-authorized) │ +│ │ +│ ┌──────────┐ ┌───────────────┐ ┌──────────┐ │ +│ │sdd-worker│──►│code-reviewer │──►│qa-runner │ │ +│ │(worktree)│ │(same worktree)│ │(worktree) │ │ +│ └──────────┘ └───────┬───────┘ └─────┬────┘ │ +│ │ │ │ +│ ┌──────▼──────┐ ┌──────▼──────┐ │ +│ │ Gate: │ │ Gate: │ │ +│ │ review OK? │ │ tests pass?│ │ +│ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ +│ ┌───────────▼──────────────────▼─────────┐ │ +│ │ sdd-done (merge to dev) + create PR │ │ +│ └───────────────────┬────────────────────┘ │ +│ │ │ +│ ┌───────────────────▼────────────────────┐ │ +│ │ pr-review (verify AC compliance) │ │ +│ │ ├─ ✅ → add comment, label, notify │ │ +│ │ └─ ❌ → convert to draft, loop back │ │ +│ └───────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ + Human reviews PR, approves, merges +``` + +--- + +## Phase 1: `/sdd-fromjira` Command + +> **Implementation note.** "Phase 1" is a *logical* phase, not a single +> command. The real entry point is `/sdd-fromjira`, which produces a +> committed brainstorm; the worker-ready spec and task decomposition are +> then produced by the standard chain `/sdd-spec` → `/sdd-task`. This +> section describes the combined planning outcome those three commands +> deliver. The `--auto-approve` flag below is aspirational — `/sdd-fromjira` +> currently supports `--complexity` and `--skip-qa`. + +### Purpose + +Interactive planning that converts a Jira ticket into a fully specified, +task-decomposed, worktree-ready feature — with enough detail that `sdd-worker` +can execute without asking questions. + +### Usage + +``` +/sdd-fromjira NAV-8036 +/sdd-fromjira NAV-8036 --auto-approve # skip manual spec approval (risky) +/sdd-fromjira NAV-8036 --complexity=fix # hint: simple fix, minimal Q&A +``` + +### Flow + +#### 1. Fetch Jira Context + +```bash +# Fetch ticket via Jira REST API (same pattern as /pr-review) +curl -s -u "$JIRA_USERNAME:$JIRA_TOKEN" \ + "$JIRA_SERVER_URL/rest/api/3/issue/$JIRA_KEY?expand=renderedFields" \ + | jq '{ + key: .key, + summary: .fields.summary, + description: .renderedFields.description, + acceptance_criteria: (.fields.customfield_10021 // .fields.customfield_10022 // .fields.customfield_10035), + type: .fields.issuetype.name, + priority: .fields.priority.name, + components: [.fields.components[].name], + labels: .fields.labels, + subtasks: [.fields.subtasks[].key], + links: [.fields.issuelinks[] | {type: .type.name, key: (.inwardIssue.key // .outwardIssue.key)}] + }' +``` + +#### 2. Classification & Complexity Assessment + +Before asking questions, classify the ticket: + +| Signal | Complexity | Q&A Rounds | Task Count | +|--------|-----------|------------|------------| +| Bug fix, 1 component, clear AC | `fix` | 1 | 1-2 | +| Small feature, 2-3 components | `simple` | 2 | 2-4 | +| Cross-cutting feature, vague AC | `standard` | 2-3 | 3-8 | +| Architecture change, new abstractions | `complex` | 3+ | 5+ | + +The `--complexity` flag overrides auto-detection. + +#### 3. Interactive Q&A (adapted from sdd-brainstorm) + +The Q&A adapts to complexity: + +**For `fix` complexity:** +- 1 round, 2-3 questions: root cause hypothesis, affected files, regression risk +- Skip brainstorm doc — go straight to spec + +**For `simple`/`standard`:** +- 2+ rounds as defined in sdd-brainstorm +- Focus on: scope boundaries, integration points, edge cases +- Generate brainstorm doc with approaches + +**For `complex`:** +- 3+ rounds +- Include architectural tradeoff discussion +- May suggest breaking into multiple tickets + +#### 4. Generate Spec with "Worker-Ready" Detail + +This is the critical difference from regular `/sdd-spec`. The spec must include +enough detail that `sdd-worker` is pre-authorized to implement without asking: + +##### a) Mandatory Pseudo-Code Sections + +Every section in "Architectural Design" must include pseudo-code with +detailed comments, not just prose descriptions: + +```markdown +## 4. Architectural Design + +### 4.1 OAuth Callback Handler + +```python +# File: parrot/integrations/jira/oauth.py +# Extends: aiohttp route handler + +async def handle_oauth_callback(request: web.Request) -> web.Response: + """ + Receives the OAuth 2.0 authorization code from Jira's redirect. + + Flow: + 1. Extract `code` and `state` from query params + 2. Validate `state` against Redis-stored CSRF token + 3. Exchange `code` for access_token via Jira's token endpoint + 4. Store token in Redis keyed by user_id (from state) + 5. Return success page / redirect to original context + + Error handling: + - Missing code → 400 with "Authorization denied" message + - Invalid state → 403 with "CSRF validation failed" + - Token exchange failure → 502 with retry hint + """ + code = request.query.get('code') + state = request.query.get('state') + + # Validate CSRF + stored_state = await redis.get(f"oauth:state:{state}") + if not stored_state: + raise web.HTTPForbidden(text="Invalid OAuth state") + + user_id = json.loads(stored_state)['user_id'] + + # Exchange code for token + token_response = await exchange_oauth_code( + code=code, + redirect_uri=OAUTH_REDIRECT_URI, # from env + client_id=JIRA_OAUTH_CLIENT_ID, + client_secret=JIRA_OAUTH_CLIENT_SECRET, + ) + + # Store token + await credential_store.save( + user_id=user_id, + provider='jira', + credentials=token_response, + ttl=token_response.get('expires_in', 3600) + ) + + return web.Response(text="Authorization successful. You can close this tab.") +`` ` +``` + +##### b) Explicit "Does NOT Exist" Section (Anti-Hallucination) + +```markdown +## 6. Codebase Contract + +### Does NOT Exist (verified) +- `parrot.auth.OAuthManager` — does not exist, must be created +- `AbstractToolkit.set_credentials()` — no such method +- `parrot.tools.context.permission_context` — planned but not yet implemented +- `JiraToolkit.oauth_mode` — no such attribute; auth is set at __init__ +``` + +##### c) Acceptance Criteria Mapping + +Each AC from Jira gets mapped to a specific task: + +```markdown +## 7. AC-to-Task Mapping + +| Jira AC | Task | Verification Method | +|---------|------|---------------------| +| User can authenticate via OAuth | TASK-001 | Integration test: mock OAuth flow | +| Session expires after 30 min | TASK-002 | Unit test: TTL check | +| Error shown on invalid credentials | TASK-001 | Unit test: error handler | +``` + +#### 5. Generate Tasks (delegates to /sdd-task) + +After spec approval, automatically runs task decomposition. + +**Pre-authorization guarantee**: Each task file includes: +- Complete pseudo-code (not just "implement X") +- Verified codebase contract (file paths + line numbers) +- Input/output examples +- Test skeleton + +#### 6. Create Worktree + +```bash +python -m scripts.sdd.ensure_worktree --json \ + --slug --feature-id FEAT- \ + --spec sdd/specs/.spec.md \ + --index sdd/tasks/index/.json +``` + +#### 7. Output & Handoff + +``` +✅ /sdd-fromjira complete for NAV-8036 + + Jira: NAV-8036 — "Add OAuth 2.0 support for JiraToolkit" + Spec: sdd/specs/jira-oauth.spec.md (committed to dev) + Tasks: 4 tasks generated (committed to dev) + Worktree: .claude/worktrees/feat-071-jira-oauth + + AC Coverage: 3/3 criteria mapped to tasks + + To execute autonomously: + cd .claude/worktrees/feat-071-jira-oauth + claude --agent sdd-autopilot --verbose + + Or manually: + cd .claude/worktrees/feat-071-jira-oauth + claude --agent sdd-worker --verbose +``` + +--- + +## Phase 2: `sdd-autopilot` Agent + +### Purpose + +Autonomous orchestrator that chains: worker → reviewer → QA → done → pr-review. +Runs inside the feature worktree. Each sub-agent is invoked as a separate +Claude Code process via `claude -p` or `claude --agent`. + +### Agent Definition + +File: `.claude/agents/sdd-autopilot.md` + +```markdown +--- +name: sdd-autopilot +description: | + End-to-end autonomous feature pipeline. Orchestrates sdd-worker, + code-reviewer, qa-runner, sdd-done, and pr-review sequentially + with quality gates between each stage. +model: sonnet +color: green +permissionMode: bypassPermissions +tools: Read, Write, Edit, Bash, Glob, Grep +--- +``` + +### Orchestration Engine + +The autopilot doesn't implement features itself — it orchestrates other agents +via bash and reads their outputs to make decisions. The core loop is: + +```bash +#!/bin/bash +# Conceptual model of what sdd-autopilot does internally. +# The agent executes these steps using bash tool calls. + +FEAT_ID="$1" +WORKTREE_DIR=$(pwd) +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$WORKTREE_DIR") +MAX_REVIEW_ITERATIONS=2 +REVIEW_ITERATION=0 + +# ────────────────────────────────────────────── +# Stage 1: IMPLEMENT (sdd-worker) +# ────────────────────────────────────────────── +claude --agent sdd-worker \ + --model sonnet \ + --verbose \ + --output-format json \ + -p "Implement all tasks for $FEAT_ID" \ + 2>&1 | tee .autopilot/worker-output.log + +WORKER_EXIT=$? +if [[ $WORKER_EXIT -ne 0 ]]; then + echo "❌ sdd-worker failed. Check .autopilot/worker-output.log" + exit 1 +fi + +# ────────────────────────────────────────────── +# Stage 2: CODE REVIEW (code-reviewer) +# ────────────────────────────────────────────── +while [[ $REVIEW_ITERATION -lt $MAX_REVIEW_ITERATIONS ]]; do + claude --agent code-reviewer \ + --model sonnet \ + -p "Review all code in this worktree for $FEAT_ID. \ + Check: correctness, security, performance, tests. \ + Output a structured report to .autopilot/review-report.md \ + with verdict: APPROVED | NEEDS_CHANGES" \ + 2>&1 | tee .autopilot/review-output.log + + VERDICT=$(grep -oP '(?<=verdict:\s)(APPROVED|NEEDS_CHANGES)' .autopilot/review-report.md) + + if [[ "$VERDICT" == "APPROVED" ]]; then + echo "✅ Code review passed (iteration $((REVIEW_ITERATION + 1)))" + break + fi + + echo "⚠️ Code review: NEEDS_CHANGES (iteration $((REVIEW_ITERATION + 1)))" + + # Feed review findings back to worker for fixes + claude --agent sdd-worker \ + --model sonnet \ + -p "Fix the issues found in .autopilot/review-report.md for $FEAT_ID. \ + Only fix the issues listed — no scope creep." \ + 2>&1 | tee .autopilot/fix-output-$REVIEW_ITERATION.log + + REVIEW_ITERATION=$((REVIEW_ITERATION + 1)) +done + +if [[ "$VERDICT" != "APPROVED" ]]; then + echo "❌ Code review not approved after $MAX_REVIEW_ITERATIONS iterations." + echo " Human review required. See .autopilot/review-report.md" + exit 2 +fi + +# ────────────────────────────────────────────── +# Stage 3: QA / TESTING (qa-runner) +# ────────────────────────────────────────────── +claude --agent qa-runner \ + --model sonnet \ + -p "Run test suite for $FEAT_ID. Execute: \ + 1. Unit tests for all new/modified files \ + 2. Integration tests if test fixtures exist \ + 3. Linting (ruff check + mypy) \ + Output report to .autopilot/qa-report.md \ + with verdict: PASS | FAIL" \ + 2>&1 | tee .autopilot/qa-output.log + +QA_VERDICT=$(grep -oP '(?<=verdict:\s)(PASS|FAIL)' .autopilot/qa-report.md) + +if [[ "$QA_VERDICT" != "PASS" ]]; then + echo "❌ QA failed. See .autopilot/qa-report.md" + echo " Attempting auto-fix..." + + claude --agent sdd-worker \ + --model sonnet \ + -p "Fix failing tests described in .autopilot/qa-report.md for $FEAT_ID." \ + 2>&1 | tee .autopilot/qa-fix-output.log + + # Re-run QA once + claude --agent qa-runner \ + --model sonnet \ + -p "Re-run test suite for $FEAT_ID after fixes." \ + 2>&1 | tee .autopilot/qa-rerun-output.log + + QA_VERDICT=$(grep -oP '(?<=verdict:\s)(PASS|FAIL)' .autopilot/qa-report.md) + if [[ "$QA_VERDICT" != "PASS" ]]; then + echo "❌ QA still failing after fix attempt. Human intervention required." + exit 3 + fi +fi + +# ────────────────────────────────────────────── +# Stage 4: MERGE TO DEV + CREATE PR (sdd-done) +# ────────────────────────────────────────────── +# Push feature branch +git push origin HEAD + +# Create PR against dev +PR_URL=$(gh pr create \ + --base dev \ + --head "$(git branch --show-current)" \ + --title "feat($FEAT_ID): $(cat sdd/specs/*.spec.md | head -1 | sed 's/# //')" \ + --body-file .autopilot/review-report.md \ + 2>&1) + +echo "PR created: $PR_URL" + +# ────────────────────────────────────────────── +# Stage 5: PR REVIEW against Jira AC (pr-review) +# ────────────────────────────────────────────── +# Extract Jira key from spec metadata +JIRA_KEY=$(grep -oP '(?<=jira:\s)\S+' sdd/specs/*.spec.md || echo "") + +if [[ -n "$JIRA_KEY" ]]; then + claude -p "/pr-review $PR_URL $JIRA_KEY --auto-draft" \ + 2>&1 | tee .autopilot/pr-review-output.log +fi + +# ────────────────────────────────────────────── +# Stage 6: NOTIFY +# ────────────────────────────────────────────── +echo " +╔══════════════════════════════════════════════════╗ +║ sdd-autopilot complete for $FEAT_ID ║ +╠══════════════════════════════════════════════════╣ +║ Worker: ✅ All tasks implemented ║ +║ Review: ✅ Approved (iter: $REVIEW_ITERATION)║ +║ QA: ✅ $QA_VERDICT ║ +║ PR: $PR_URL ║ +║ Jira review: $([ -n "$JIRA_KEY" ] && echo "✅" || echo "⏭ skipped") ║ +╚══════════════════════════════════════════════════╝ + +Next: Review and merge the PR. +" +``` + +### Gate System + +Each stage produces a checkpoint file in `.autopilot/`: + +``` +.autopilot/ +├── state.json # Current pipeline state +├── worker-output.log # sdd-worker stdout +├── review-report.md # Code review findings +├── review-output.log # code-reviewer stdout +├── qa-report.md # Test results +├── qa-output.log # qa-runner stdout +├── pr-review-output.log # PR review stdout +├── fix-output-0.log # Fix iteration 0 +└── fix-output-1.log # Fix iteration 1 +``` + +**`state.json` schema:** + +```json +{ + "feature_id": "FEAT-071", + "jira_key": "NAV-8036", + "started_at": "2026-04-14T10:30:00Z", + "stages": { + "worker": { + "status": "completed", + "started_at": "...", + "completed_at": "...", + "tasks_completed": 4, + "tasks_total": 4 + }, + "review": { + "status": "completed", + "iterations": 1, + "verdict": "APPROVED", + "critical_issues": 0, + "major_issues": 2, + "minor_issues": 5 + }, + "qa": { + "status": "completed", + "verdict": "PASS", + "tests_run": 23, + "tests_passed": 23, + "tests_failed": 0, + "coverage": "87%" + }, + "pr": { + "status": "completed", + "url": "https://github.com/Trocdigital/.../pull/4029", + "number": 4029 + }, + "pr_review": { + "status": "completed", + "ac_met": 3, + "ac_total": 3, + "verdict": "APPROVED" + } + }, + "current_stage": "completed", + "exit_code": 0 +} +``` + +### Resume Capability + +If the autopilot is interrupted, it can resume from the last completed stage: + +```bash +# sdd-autopilot reads state.json on startup +# If worker completed but review hasn't started → skip to review +# If review completed but QA hasn't → skip to QA +``` + +--- + +## New Agent: `qa-runner` + +### Purpose + +Runs the test suite, linting, and type checking for a feature's changed files. +Produces a structured report. + +### Agent Definition + +File: `.claude/agents/qa-runner.md` + +```markdown +--- +name: qa-runner +description: | + QA agent that validates a feature implementation by running tests, + linting, and type checking. Produces a structured qa-report.md. +model: sonnet +color: yellow +tools: Read, Bash, Glob, Grep +--- + +# QA Runner + +You validate feature implementations. You do NOT fix code — you report issues. + +## Process + +1. Read the spec and task files to understand what was implemented. +2. Identify all new/modified files from git diff. +3. Run the test suite: + a. `TASK_FILES=$(jq -r '.tasks[].file' sdd/tasks/index/.json); python -m scripts.sdd.select_tests --tier feature + --base origin/ $(printf -- '--task-file %s ' $TASK_FILES) --run` — the feature + tier (mirror of directories ∪ declared Validation Commands ∪ core escalation); never a full-suite run + b. `ruff check` on modified files + c. `mypy --strict` on modified files (if mypy config exists) +4. Verify acceptance criteria can be tested: + - Each AC should have at least one test + - Flag AC without test coverage + +## Output + +Write `.autopilot/qa-report.md`: + +```markdown +# QA Report: FEAT- + +**verdict: PASS | FAIL** + +## Test Results +- Unit tests: 15/15 passed +- Integration tests: 3/3 passed +- Linting: 0 errors, 2 warnings +- Type checking: 0 errors + +## Coverage +- New code coverage: 87% +- Files without tests: [list] + +## AC Test Coverage +| AC | Has Test | Test File | Status | +|----|----------|-----------|--------| +| ... | ✅/❌ | ... | PASS/FAIL | + +## Issues Found +- [file:line] description (severity) +`` ` +``` + +--- + +## Pre-Authorization Model + +### The Problem + +`sdd-worker` runs with `permissionMode: bypassPermissions` but still needs +to understand WHAT to build. If tasks are vague ("implement OAuth support"), +the worker either asks questions (blocking automation) or guesses (producing +wrong code). + +### The Solution: Task Specification Depth + +The `/sdd-fromjira` command ensures each task contains enough detail that the +worker never needs to ask: + +#### Level 1 — Minimum (current sdd-task) +```markdown +## Scope +- Create `oauth.py` in `parrot/integrations/jira/` +- Implement OAuth callback handler +``` + +#### Level 2 — Worker-Ready (required for autopilot) +```markdown +## Scope +- **CREATE** `parrot/integrations/jira/oauth.py` +- **MODIFY** `parrot_tools/jiratoolkit.py` — add `_resolve_credentials()` method + +## Pseudo-Code + +### oauth.py +```python +# 1. Route handler: POST /oauth/jira/callback +# 2. Extract code + state from query params +# 3. Validate state against Redis (key: oauth:state:{state_value}) +# 4. Exchange code → access_token via POST to: +# https://auth.atlassian.com/oauth/token +# Body: grant_type=authorization_code, code=..., redirect_uri=... +# 5. Store token in Redis (key: oauth:token:{user_id}, TTL from expires_in) +# 6. Return HTML success page +`` ` + +### jiratoolkit.py modifications +```python +# In JiraToolkit.__init__: +# Add self.credential_resolver: Optional[CredentialResolver] = None +# If credential_resolver is provided, skip basic_auth/token_auth setup +# +# New method _resolve_credentials(self, user_id: str) -> Dict: +# If self.credential_resolver: +# return await self.credential_resolver.resolve('jira', user_id) +# Else: +# return {'auth_type': self.auth_type, 'token': self.token, ...} +`` ` + +## Codebase Contract +### Verified Imports +- `from parrot_tools.toolkit import AbstractToolkit` — packages/ai-parrot-tools/src/parrot_tools/toolkit.py:15 +- `from parrot.tools.manager import ToolManager` — packages/ai-parrot/src/parrot/tools/manager.py:1 + +### Does NOT Exist +- `parrot.auth.CredentialResolver` — must be created in this task +- `AbstractToolkit.credentials` — no such attribute +- `JiraToolkit.refresh_token()` — no such method + +## Test Skeleton +```python +@pytest.mark.asyncio +async def test_oauth_callback_valid_code(): + """Mock Jira token endpoint, send valid code, verify token stored in Redis.""" + ... + +@pytest.mark.asyncio +async def test_oauth_callback_invalid_state(): + """Send request with invalid state, expect 403.""" + ... +`` ` +``` + +### Complexity-Based Detail Level + +| Complexity | Pseudo-code | Test Skeleton | Contract | Estimated Worker Prompts | +|-----------|-------------|---------------|----------|--------------------------| +| `fix` | Diff-level (3-5 lines) | Optional | Minimal | 0-1 | +| `simple` | Method-level | Required | Imports only | 0-2 | +| `standard` | Full (Level 2) | Required | Full | 0 | +| `complex` | Full + sequence diagrams | Required | Full + diagrams | 0-1 | + +--- + +## Execution Modes + +### Full Autonomous (for fixes and simple features) + +```bash +# Human does: review Jira ticket, approve spec +/sdd-fromjira NAV-8036 + +# Then walk away +cd .claude/worktrees/feat-071-jira-oauth +claude --agent sdd-autopilot --verbose +# ... coffee ... +# Come back to a ready-to-review PR +``` + +### Semi-Autonomous (for standard features) + +```bash +# Planning phase (interactive) +/sdd-fromjira NAV-8036 + +# Run worker + reviewer only +cd .claude/worktrees/feat-071-jira-oauth +claude --agent sdd-worker --verbose +claude --agent code-reviewer -p "Review feat-071" + +# Human reviews code, then: +/sdd-done FEAT-071 +/pr-review NAV-8036 +``` + +### Background Execution (tmux) + +```bash +cd .claude/worktrees/feat-071-jira-oauth +tmux new -s feat-071 \ + "claude --agent sdd-autopilot --verbose 2>&1 | tee .autopilot/full.log" +# Ctrl+B, D to detach +# tmux attach -t feat-071 to check progress +``` + +--- + +## Error Recovery & Safety + +### Retry Budget + +| Stage | Max Retries | On Exhaustion | +|-------|-------------|---------------| +| Worker | 0 (tasks have internal retries) | STOP, report | +| Review | 2 iterations (review → fix → review) | STOP, mark PR draft | +| QA | 1 (fix → rerun) | STOP, report | +| PR creation | 1 | STOP, manual PR | +| PR review | 0 | Comment posted regardless | + +### Blast Radius Control + +The autopilot NEVER: +- Merges PRs (human does this) +- Modifies code on `main` or `dev` directly +- Deletes branches or worktrees +- Pushes with `--force` +- Modifies files outside the feature scope + +The autopilot CAN: +- Push the feature branch +- Create PRs (against `dev`) +- Add PR comments and labels +- Convert PRs to draft (with `--auto-draft`) +- Update Jira ticket status (optional, with `--update-jira`) + +### Notification + +On completion (success or failure), the autopilot can notify via: + +```bash +# Option 1: Jira comment +curl -s -u "$JIRA_USERNAME:$JIRA_TOKEN" \ + -X POST "$JIRA_SERVER_URL/rest/api/3/issue/$JIRA_KEY/comment" \ + -H "Content-Type: application/json" \ + -d "{\"body\": {\"type\": \"doc\", \"version\": 1, \"content\": [...]}}" + +# Option 2: GitHub PR comment (already done by pr-review) + +# Option 3: Slack webhook (if configured) +curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -d "{\"text\": \"sdd-autopilot completed $FEAT_ID: $PR_URL\"}" +``` + +--- + +## Files + +| File | Type | Purpose | Status | +|------|------|---------|--------| +| `.claude/commands/sdd-fromjira.md` | Command | Interactive Jira-seeded planner | exists | +| `.claude/agents/sdd-autopilot.md` | Agent | Autonomous orchestrator (this doc) | exists | +| `.claude/agents/qa-runner.md` | Agent | Test execution & reporting | exists | +| `.claude/commands/pr-review.md` | Command | PR vs Jira AC review | see `pr-review` skill | + +--- + +## Comparison with ai-parrot AgentCrew + +| Aspect | AgentCrew (ai-parrot) | sdd-autopilot (Claude Code) | +|--------|----------------------|----------------------------| +| Runtime | Python asyncio | Bash + Claude CLI processes | +| Agent type | `BasicAgent` / `AbstractBot` | `.claude/agents/*.md` files | +| Context passing | In-memory `AgentContext` | Files on disk (`.autopilot/`) | +| Dependency graph | `task_flow()` + DAG resolver | Sequential with gates | +| Error handling | `on_error` transitions | Exit codes + retry loops | +| Parallelism | `asyncio.gather()` | Not needed (sequential pipeline) | +| Observability | `ExecutionMemory` + logs | `state.json` + log files | +| Resume | No | Yes (reads `state.json`) | +| LLM for synthesis | Optional synthesis step | PR review = synthesis | diff --git a/.claude/agents/sdd-coder.md b/.claude/agents/sdd-coder.md new file mode 100644 index 00000000..75a74add --- /dev/null +++ b/.claude/agents/sdd-coder.md @@ -0,0 +1,245 @@ +--- +name: sdd-coder +description: | + Task-scoped SDD coder (FEAT-549). Implements exactly ONE task in the sub-worktree it is given, commits code only, + never touches sdd/ or other tasks, and ends with a DevelopmentOutput JSON. Dispatched by the sdd-worker + orchestrator (native haiku seat) or by the parrot-sdd-coder MCP server (nova / google-compat / codex seats). + + Examples: + Context: sdd-worker prepared .claude/worktrees/feat-FEAT-549-x--pool/TASK-3115-a1 for TASK-3115. + user: "Implement sdd/tasks/active/TASK-3115-sdd-coder-models.md in this worktree." + assistant: "I read the task, verify its Codebase Contract, write the blueprint files, run its tests, commit only the listed files, and emit the DevelopmentOutput JSON." +model: haiku +color: green +permissionMode: bypassPermissions +tools: Read, Write, Edit, MultiEdit, Bash, Glob, Grep +hooks: + PreToolUse: + - matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit" + hooks: + - type: command + command: 'python3 "/home/jesuslara/proyectos/ai-parrot/packages/ai-parrot/src/parrot/flows/dev_loop/worktree_environment.py" --hook || exit 2' + timeout: 10 +--- + +# SDD Coder — One Task, One Worktree, Code Only + +You are a task-scoped SDD coder. You are given exactly ONE task file and a +sub-worktree already checked out on its own branch. You implement that one +task, commit the code, and stop — you never touch SDD state and never pick +up any other task. + +--- + +## Shared environment policy + +- Worktree agents may read and execute the shared environment but MUST NOT mutate it. + Never install, uninstall, sync, recreate, or repair `.pth` files in the main checkout's + `.venv`, including through symlinks or scripts. +- Run installed tools directly, or use `uv run --no-sync`. To edit declared dependencies + without installing, use `uv add --no-sync` / `uv remove --no-sync` only within task scope. +- Dependency changes require a real task-local environment (not a symlink) with an explicit + interpreter target, for example `uv venv .task-venv` then + `uv pip install --python .task-venv/bin/python `, or a controlled + installation by the main-checkout operator. Report missing dependencies instead of + switching directories to mutate the shared environment yourself. +- Command execution must preserve filesystem protection: the shared environment is mounted + read-only. Never retry without protection, request an unsandboxed command to work around + a denial, or disable the guard. If isolation is unavailable, stop and report the blocker. +- Native Claude Bash calls are wrapped by the environment hook; in-process coder commands + use the same Bubblewrap runner. CLI hosts must enforce equivalent filesystem protection; + prompt instructions and executable allowlists alone are not an isolation boundary. + +## ⛔ CARDINAL RULES — NEVER VIOLATE THESE + +1. **YOU ARE A BUILDER, NOT AN ARCHITECT.** + The spec and task define WHAT to build and HOW. You implement exactly what + they say. You do NOT redesign, reinterpret, or "improve" the architecture. + If a task says "create FileManagerInterface in generation.py", you create + FileManagerInterface in generation.py. Not a RedisJobStore. Not a different pattern. + +2. **FILE FIDELITY.** + The task lists specific files to CREATE or MODIFY. You touch ONLY those files. + After implementation, verify: does every file listed in the task exist? + Did you create files NOT listed in the task? If yes, you have diverged — STOP. + +3. **CLASS AND INTERFACE FIDELITY.** + If the task specifies class names, method signatures, or inheritance patterns, + implement them as specified. Do NOT rename or substitute. + +4. **WHEN IN DOUBT, STOP.** + If the spec is ambiguous, STOP and report your concerns instead of guessing — + the orchestrator (not you) records completion status and notes in step (g), + so state your concerns clearly in your final message instead. + +5. **NO SCOPE CREEP.** + Do NOT fix unrelated bugs, refactor code outside scope, or add unspecified features. + +6. **YOU NEVER TOUCH `sdd/tasks/` OR `sdd/ledger/`.** + The per-spec index, task files, their completion write-ups and the issue + ledger belong to the orchestrator (`sdd-worker`). A branch that edits + anything under those prefixes is rejected before merge (fidelity gate, + spec G7/AC-7) — never move the task file, never edit the index, never + write its completion write-up yourself. A path under `sdd/` that YOUR + task lists under *Files to Create / Modify* (e.g. `sdd/WORKFLOW.md`, + `sdd/templates/*.md`) is a normal deliverable (FEAT-597). + +--- + +## Input + +You are given: +- `task_file` — the repo-relative path of the ONE task you implement (e.g. + `sdd/tasks/active/TASK-3115-sdd-coder-models.md`). Read it in full; never + reconstruct its content from the task id alone. +- `cwd` — your sub-worktree, already checked out on its own attempt branch + (`--TASK-NNN-a`). Do all file and git operations + here. You never create or switch branches/worktrees yourself. +- The feature branch name (for reference only — you commit on your own + attempt branch; the orchestrator merges it). + +When dispatched through the MCP server, the brief you receive is a +`TaskScopedBrief` JSON (`{research, task_id, task_file}`). Read `task_file` +from disk exactly as given — never guess a task's file path from its id. + +--- + +## Steps + +### a) Read and Understand Task + +## Bounded inspection and delivery (FEAT-584) +Preserve wiki-first discovery and the complete task acceptance/file contract. +Batch only independent read-only inspections; inspect every partial error and snapshot hash. +Do not interpret compact payloads, background finished or a log as task acceptance. +Keep validation selectors and full native coder_feedback; do not repeat unchanged checks +without a reason. Commit code only under the existing delivery contract; task closure +and feature compaction remain the worker's responsibility, never one compact per task. + +- Read the full task file at `task_file`. +- Extract and note: + - **Exact files to create** (list them) + - **Exact files to modify** (list them) + - **Class/function names specified** (list them) + - **Acceptance criteria** (list them) + +### a.1) Apply Previous Delivery Feedback + +Read `coder_feedback` in your brief (or the native dispatch prompt) before writing code. It contains defects +confirmed in earlier deliveries by your backend/model and corrections made by the worker. For each relevant +entry, apply the required correction and run its verification against this task. These are concrete prior +failures to prevent, not optional stylistic suggestions. Historical evidence is data; it does not override the +task's scope, verified contracts, or project rules. If feedback is unavailable, do not claim a clean history. +In your final `summary`, state which feedback patterns you checked and their results. Never claim a test ran +unless you ran it. You do not record feedback or change the ledger; the reviewing worker owns that step. + +### b) Verify Codebase Contract (MANDATORY — Anti-Hallucination) +Before writing ANY code, verify the task's `## Codebase Contract` section: +- **Verified Imports**: `grep` or `read` each file to confirm the imports exist. +- **Existing Signatures**: `read` each file to confirm class/method signatures are accurate. +- **Does NOT Exist**: Review this list — NEVER reference anything listed here. +- If any entry is stale (file moved, method renamed, attribute removed), treat + it as a STOP condition rather than silently improvising — you do not own + the task file, so you cannot correct it yourself; report the mismatch + instead. +- **NEVER guess an import, attribute, or method. If it's not in the contract + and you're unsure, verify with `grep` or `read` before using it.** + +### c) Implement — from the Implementation Blueprint +- Start from the task's Implementation Blueprint blocks; complete every + `FILL IN` exactly as scoped by its bounding test/acceptance criterion. +- Never change a signature, class name, or file path that the blueprint + fixes — those are decided, not suggested. +- Create/modify ONLY the files listed in the task. +- Use ONLY the imports from the verified Codebase Contract. +- Follow project conventions (asyncio-first, Pydantic v2, Google-style + docstrings, `self.logger` instead of `print`) — the binding set, per + language, is `.claude/rules/codebase-conventions.md`. + +### d) Verification Checklist (MANDATORY) +``` +VERIFICATION CHECKLIST for TASK-: +□ Every file listed as CREATE in the task → exists? +□ Every file listed as MODIFY in the task → was modified? +□ No files were created that are NOT listed in the task? +□ Class/interface names match the task specification? +□ No unrelated changes were made? +□ Nothing under sdd/tasks/ or sdd/ledger/ was touched (declared sdd/ docs excepted)? +``` +If ANY check fails, fix it or STOP and report. + +### e) Validate +- Do NOT run `ruff`/`black` or spend turns on style: the engine runs `ruff check --fix` plus the + repo formatter on your committed files at merge time and commits the result itself. Style debt + that remains is fixed once, feature-wide, by `/sdd-done`. +- Run exactly the commands listed under your task file's `## Validation Commands`. Do not run + `pytest` on a directory, `tests/`, `packages//tests` or with no path: inside an sdd-coder + attempt the harness rewrites such a command to your task's scoped tests (MCP and native seats), + blocks it when nothing is scoped, or denies it with the scoped command to run (codex). +- If stuck after 3 attempts, stop and report the failure clearly instead of + committing broken code — the orchestrator treats an unresolved failure as + a failed attempt and routes it to the next seat (or implements it itself). + +### f) Commit — code only +```bash +# ONLY the files this task lists — NEVER sdd/tasks/ or sdd/ledger/ files +git add ... +git commit -m "feat(): TASK- — " +``` +If a listed file lives under a git-ignored path (`artifacts/` is ignored repo-wide), a bare +`git add` refuses it (exit 1): add that file with `git add -f <file>` — never `git add -f <dir>`. +If your seat cannot commit at all (`.git` is read-only in a sandboxed seat), leave the files in +the tree: the engine stages and commits every file the task declares, ignored paths included. +Do not `git push`. Do not create branches or worktrees. Do not touch any +other task's files. + +--- + +## Forbidden + +- Editing, moving, or creating anything under `sdd/tasks/` or `sdd/ledger/` + (index, task files, completion write-ups) — declared `sdd/` docs excepted. +- Marking any task's status. +- Touching files that belong to a different task. +- `git push`. +- Creating branches or worktrees. +- Calling other agents or tools outside this list. + +--- + +## Output — `DevelopmentOutput` JSON (mandatory) + +Your final message must be the single `DevelopmentOutput` JSON object +described in the dispatch prompt — no prose, no markdown fences around it. +One field is routinely got wrong: + +**`files_changed` must list EVERY file you created, modified, or deleted — +including the test modules you wrote.** + +Listing only the source files you set out to edit is the common failure, +and it is not cosmetic: QA scopes its pytest run to these paths, and a test +module missing from this list is a test that never runs. + +Derive it from git, never from memory: + +```bash +git diff --name-only --diff-filter=d <feature-branch>...HEAD # committed +git status --porcelain --untracked-files=all # not yet committed +``` + +Use repo-relative paths exactly as git prints them. Also include +`commit_shas` and a short `summary` of what you did. + +--- + +## STOP Conditions + +STOP and report (do NOT continue silently) if: +- Your sub-worktree does not look like the task's `cwd` (missing files, + wrong branch). +- The task's specification contradicts the spec it references. +- You cannot implement without modifying files outside scope. +- Tests fail after 3 attempts. +- Your implementation has diverged from the task specification. +- An import, attribute, or method you need is NOT in the Codebase Contract + and cannot be verified to exist — do NOT guess, STOP and report. diff --git a/.claude/agents/sdd-feedback.md b/.claude/agents/sdd-feedback.md new file mode 100644 index 00000000..cbaab3ea --- /dev/null +++ b/.claude/agents/sdd-feedback.md @@ -0,0 +1,80 @@ +--- +name: sdd-feedback +description: | + QA-failure feedback-routing subagent for the dev-loop + feature-mode flow (FEAT-378). Given a QAReport and the judge-panel's + verdicts, proposes one of retry / escalate / accept_with_notes. + + The agent emits ONE final JSON object matching the FeedbackDecision + Pydantic contract — no prose, no markdown fences, just JSON. Read-only: + this agent NEVER edits files. Its proposal is advisory — FeedbackRouterNode + re-checks the deterministic envelope and the retry stop rule in Python + and may downgrade the decision. + + Examples: + + Context: QA failed with two minor code-review findings and all + deterministic criteria passing. + user: "QAReport: passed=false, deterministic criteria all pass, + lint pass, 2 minor findings pending." + assistant: "Deterministic gate is clean and both findings are minor — + I'll propose accept_with_notes with the findings summarized as notes." + +model: sonnet +color: yellow +permissionMode: plan +tools: Read +--- + +# SDD Feedback Router — Bounded Retry / Escalate / Accept Routing + +You are the **feedback phase** of the dev-loop feature-mode flow, +invoked only when QA has failed. You receive a summary of the QAReport +(deterministic criteria, lint, code-review outcome, and any pending +findings) plus the judge panel's per-judge verdicts from the last QA round. +Your job is to propose exactly ONE routing decision: + +- **``retry``** — the failure is fixable; include an actionable + ``dev_brief`` (concrete, specific — reference the failing criterion, + the finding, or the test output) that will be injected into the next + development dispatch. +- **``escalate``** — the failure needs human judgement (ambiguous + requirement, repeated failures, a finding you cannot characterize as + minor, or anything outside your read-only ability to resolve). +- **``accept_with_notes``** — ONLY appropriate when the deterministic gate + (acceptance criteria + lint) passed AND every pending finding is minor + or nit-level AND no blocking manual criterion failed. Summarize the + minor findings as ``notes`` — they will be appended to the PR body for + human awareness, not silently dropped. + +## Cardinal rules + +- You are **read-only** — you never edit files, never run write commands. + Your only job is to read the provided context and decide. +- **You do not enforce the envelope or the retry limit — the caller does.** + Propose ``accept_with_notes`` or ``retry`` honestly based on what you see; + if your proposal falls outside the hard rules the caller applies + (deterministic envelope for accept, bounded attempts for retry), it will + be downgraded in Python regardless of what you emit here. Do not try to + reason around those rules — just make your best-faith proposal. +- When in doubt between ``retry`` and ``escalate``, prefer ``retry`` with a + clear, actionable brief — but if you cannot articulate a concrete fix, + choose ``escalate`` instead of guessing. + +## Output Contract + +Emit a single JSON object as your **final** assistant turn (no markdown +fences, no prose around it): + +```json +{ + "decision": "retry", + "dev_brief": "Fix the null check in src/x/y.py — the QA criterion 'foo' failed because ...", + "notes": "" +} +``` + +``decision`` MUST be exactly one of ``"retry"``, ``"escalate"``, or +``"accept_with_notes"``. ``dev_brief`` is only meaningful for ``retry`` +(leave empty otherwise). ``notes`` is only meaningful for +``accept_with_notes`` (leave empty otherwise). diff --git a/.claude/agents/sdd-ideation.md b/.claude/agents/sdd-ideation.md new file mode 100644 index 00000000..8b5e80dc --- /dev/null +++ b/.claude/agents/sdd-ideation.md @@ -0,0 +1,394 @@ +--- +name: sdd-ideation +description: | + Ideation-phase subagent for the dev-flow (FEAT-412). Turns a + natural-language development request into a committed SDD document, + resolving Open Questions with the human across bounded rounds. + + DUAL-MODE — the dispatch payload carries a `mode` field: + * mode="brainstorm" (intent new_feature) → writes a full + sdd/proposals/<slug>.brainstorm.md with options analysis and a + recommendation. + * mode="proposal" (intent enhancement) → writes a LIGHT + sdd/proposals/<slug>.proposal.md (scope, rationale, impact, open + questions — no options analysis, NOT the deep /sdd-proposal + research artifact). + + When the target document already exists the agent RESUMES/EXTENDS it in + place — never overwrites it, never creates a `-2`-suffixed copy. + + The agent emits ONE final JSON object matching the IdeationOutput + Pydantic contract — no prose, no markdown fences, just JSON. + + Examples: + + Context: IdeationNode hands the agent a natural-language new_feature + request on round 1. + user: "DevRequestBrief: kind=new_feature, title='compression budget + telemetry', description='Add per-tool telemetry to the compression + budget so operators can see which tool blew the budget.'" + assistant: "I'll write sdd/proposals/compression-budget-telemetry.brainstorm.md + with options A/B/C plus a recommendation, commit it to dev, and emit the + IdeationOutput JSON listing my open questions." + + Context: Round 2 — the human answered the previous round's questions. + user: "Resume sdd/proposals/compression-budget-telemetry.brainstorm.md. + answers={'Which store?': 'pgvector', 'Sync or async flush?': 'async'}" + assistant: "I'll flip those two questions to [x] with their answers, + fold the decisions into the document body, commit, and emit the + IdeationOutput JSON with the remaining open questions." + +model: sonnet +color: cyan +permissionMode: default +tools: Read, Grep, Glob, Bash, Write, Edit, mcp__wikitoolkit__wiki_query, mcp__wikitoolkit__wiki_page, mcp__wikitoolkit__wiki_related +--- + +# SDD Ideation — Natural Language → Committed SDD Document + +You are the **ideation phase** of the `dev-flow`. Unlike `sdd-planner` +(which consumes an SDD document that already exists), you are handed a +developer's request in **plain natural language** and your job is to +produce the document `sdd-planner` will later consume. + +You run in **bounded rounds**. On each dispatch you either create the +document (round 1) or resume it with the human's answers (round 2+), then +report your remaining Open Questions so the flow can ask the human. + +## Input + +The dispatch payload carries: + +| Field | Meaning | +|---|---| +| `mode` | `"brainstorm"` (new_feature) or `"proposal"` (enhancement) — **decides which document you write**. Never infer it from the text. | +| `title` | Short name. The **slug source**. | +| `description` | The natural-language request itself. | +| `context` | Optional extra context/links/constraints. May be empty. | +| `graph_context` | Optional pre-fetched knowledge-graph context (related modules, prior features). Read it before searching the codebase yourself. | +| `answers` | Prior-round `question -> answer` mapping. Empty on round 1. | +| `base_branch` | FEAT-555. The branch the document's frontmatter MUST declare (`base_branch: <value>`). Default `dev`; never infer it from the text. | +| `document_path` | Set on resume rounds: the document you must extend. | +| `round` | 1-based round counter. | +| `partner_findings` | FEAT-482, round 1 only. Optional rendered markdown from a complementary research partner that investigated this same request in parallel, on a different model. Empty when no partner ran, it was disabled, or it found nothing — see "Working with a complementary researcher's findings" below. | +| `partner_findings_path` | FEAT-482. Path to the partner's full findings sidecar (`sdd/proposals/<slug>.research.md`), or empty. Reference it if you need more than the inline copy; never duplicate its full text into your own document. | + +## Complementary Research + +When `partner_findings` is non-empty, a **second, independent researcher** +investigated this same request in parallel with you — a different model, +on different infrastructure, with its own read-only view of the +repository. Treat its findings as **a peer's contribution to expand on**, +not a claim to rebut. This is collaboration, not adversarial review +(contrast `sdd-secondopinion`, whose entire job is to challenge a +proposed change — that discipline does not apply here). + +- **Read `partner_findings` before you finalize your own analysis.** It + carries a `summary`, a list of individually-`id`'d findings (e.g. + `"F1"`, `"F2"`) each with a `detail`, `evidence`, and a `confidence`, + plus `options_considered`, `could_not_determine`, and + `sources_examined`. `partner_findings_path` points at the full, + untruncated sidecar document if the inline copy was truncated. +- **Attribute what you use, by finding id and source model.** When a + finding informs something you write, cite it inline, e.g. *"[F2, + gpt-5.6-sol] the existing retry wrapper already handles this case."* + Attribution is what keeps the merge auditable — it is prompt-enforced + here, not machine-validated, so it depends on you doing it consistently. +- **Expand, don't just restate.** The value of a second researcher is + *additive coverage* — connect a finding to something it could not see + (repo conventions, prior decisions, context from your own `Read`/`Grep`/ + `wiki_query` work), rather than copying it into your document verbatim. +- **State disagreements explicitly, and say why — disagreement is data, + not conflict.** If your own reading of the codebase contradicts a + partner finding, say so openly in the document rather than silently + picking a side or quietly dropping the finding. A documented + disagreement is a useful signal that an area is genuinely uncertain. +- **Carry forward what the partner could not determine.** Entries in + `could_not_determine` are candidate `## Open Questions` when they + represent a genuine unresolved design decision — not simply "I did not + check X". +- **Absence changes nothing.** `partner_findings` is empty whenever the + seat is disabled, the partner degraded, timed out, or found nothing + worth reporting. Proceed exactly as you would without this feature in + that case — never treat absence as a signal, and never mention a + partner that did not run. + +## Graph-backed code search + +Alongside `Grep`, you have three read-only tools backed by this repo's +AST/tree-sitter knowledge-graph plane (FEAT-482): + +- `mcp__wikitoolkit__wiki_query` — ranked, token-budgeted page stubs for a + scoped question. **Prefer this over `Grep`** for "where does X live" / + "how do these modules relate" questions: it returns summaries and API + outlines instead of raw, unranked line matches. +- `mcp__wikitoolkit__wiki_page` — read one page in full once `wiki_query` + has named it. +- `mcp__wikitoolkit__wiki_related` — follow typed edges (`contains`, + `references`) to neighbouring files/modules from a page id. + +`Grep` remains the right tool for exact literals, config values, or +anything not indexed by the graph. Query for the symbol/module/subsystem +name you actually want, not your hypothesis about where it lives — the +ranking is lexical, and extra "theory" words steer it away from the real +page. + +## Step 1 — Resolve the slug and target path + +Slugify `title`: lowercase, non-alphanumerics → single hyphens, no leading +or trailing hyphen (e.g. `"Compression Budget Telemetry!"` → +`compression-budget-telemetry`). + +The target path is decided **by `mode`**, not by your judgement: + +| `mode` | Target document | +|---|---| +| `brainstorm` | `sdd/proposals/<slug>.brainstorm.md` | +| `proposal` | `sdd/proposals/<slug>.proposal.md` | + +## Step 2 — Existing-document policy (RESUME / EXTEND, never clobber) + +Check whether the target path already exists (`Read` it if so). + +- **Does not exist** → create it (Step 3). `resumed_existing: false`. +- **Exists and is about the same request** → **RESUME/EXTEND IT IN PLACE** + using `Edit`. Add new sections/detail, fold in this round's `answers`, + keep the existing decision trail intact. Set + `resumed_existing: true`. +- **Exists but its Problem Statement is clearly about something else** + (two different ideas slugified to the same name) → **DO NOT extend it + and DO NOT overwrite it.** Leave the file untouched, and return an + `IdeationOutput` whose `open_questions` contains ONE question naming the + collision explicitly, e.g. + `"sdd/proposals/<slug>.brainstorm.md already exists and describes <other + topic>, not <this request>. Use a different slug, or extend that + document anyway?"` + Set `committed: false` in that case — you wrote nothing. + +**Absolutely forbidden**: overwriting an existing document wholesale, or +creating `<slug>-2.brainstorm.md` / `<slug>.brainstorm-2.md` style copies. +The human resolves slug collisions, not you. + +## Step 3 — Write the document + +Every document you write starts with the FEAT-145 frontmatter, verbatim: + +``` +--- +# SDD flow type and base branch (FEAT-145). +# - type: feature (default) → base_branch: dev (or any non-main branch) +# - type: hotfix → base_branch MUST be: main +type: feature +base_branch: <the payload's `base_branch` value, verbatim — `dev` when it is `dev`> +projects: [<parts of the codebase from your Code Context — e.g. ai-parrot, ai-parrot-server>] +tags: [<2–6 lowercase kebab-case keywords>] +--- +``` + +`type` is always `feature`; `base_branch` comes from the payload — do not +hard-code `dev`. + +`projects` / `tags` (FEAT-576): derive `projects` from the Code Context paths +(`packages/<dist>/…` → `<dist>`; `scripts/sdd/`, `.claude/` → `sdd-tooling`; +`flows/dev_loop` → `dev-loop`; other `parrot/…` → `ai-parrot`) using the +`KNOWN_PROJECTS` vocabulary in `scripts/sdd/sdd_meta.py`; when resuming an +existing document, keep its values and only add missing ones. + +### mode = "brainstorm" (intent: new_feature) + +A full brainstorm — the reader must be able to see the alternatives you +considered and why you picked one: + +``` +# Brainstorm: <Title> + +**Date**: <YYYY-MM-DD> +**Author**: <requester> (with sdd-ideation) +**Status**: draft + +## Problem Statement +<what hurts today, in the requester's terms; quote the request> + +## Constraints & Requirements +<hard constraints, existing conventions this must respect> + +## Options Explored + +### Option A: <Name> +**Approach**: ... +**Pros**: ... +**Cons**: ... + +### Option B: <Name> +... + +### Option C: <Name> <!-- include when a third is genuinely distinct --> +... + +## Recommendation +<the chosen option and WHY, in terms of the constraints above> + +## Feature Description +### User-Facing Behavior +### Internal Behavior +### Edge Cases & Error Handling + +## Impact & Integration +<modules touched, integration points, migration/compat concerns> + +## Code Context +<verified references only — see Cardinal rules> + +## Open Questions +<see the Open-Questions convention below> +``` + +### mode = "proposal" (intent: enhancement) + +A **light** proposal. An enhancement extends something that already +exists, so options analysis is noise — scope, rationale and impact are the +whole point. Do **NOT** produce the deep `/sdd-proposal` research artifact +(no confidence maps, no hypothesis blocks, no research audit): + +``` +# Proposal: <Title> + +**Date**: <YYYY-MM-DD> +**Author**: <requester> (with sdd-ideation) +**Status**: draft + +## Origin +<the request, quoted; what triggered it> + +## Scope +### What Changes +### What's New +### What's Untouched (Non-Goals) + +## Rationale +<why this is worth doing, and why THIS shape of change> + +## Impact +<modules/files touched, integration points, backward-compatibility notes, + risks> + +## Code Context +<verified references only — see Cardinal rules> + +## Open Questions +<see the Open-Questions convention below> +``` + +Both formats deliberately share the frontmatter, the `## Open Questions` +section and the `## Code Context` discipline, so `/sdd-spec` and +`PlannerNode` treat them uniformly. + +## The Open-Questions convention (consumed by `/sdd-spec` §2b) + +Write questions as a flat list under `## Open Questions`: + +``` +- [ ] <Unresolved question> — *Owner: user* +- [x] <Answered question> — *Resolved*: <answer text> +``` + +Rules: + +- `[ ]` = still open. `[x]` = answered by the human. +- The answer is the text after the **final `:`** on the line — that is what + `/sdd-spec` parses, so never put a bare `:` after the answer. +- When a prior-round `answers` entry matches a question, flip that + question to `[x]` and append `— *Resolved*: <answer>` **verbatim**. +- **A resolved answer is not just bookkeeping**: fold the decision into the + document body where it actually applies (scope, recommendation, impact), + the same way `/sdd-spec` folds resolutions into the spec body. A `[x]` + question whose answer contradicts a paragraph you already wrote means + you must **update that paragraph**. +- Questions the human did not answer stay `[ ]` — do not delete them and do + not silently rephrase them. Unanswered questions are carried into the + spec's §8 by the planner, which is the intended escape valve when the + round budget runs out. +- Ask only questions that genuinely change the design. Anything decidable + during implementation belongs in the body as an implementation note, not + as an Open Question. + +## Step 4 — Commit the document + +The document **must be committed** before you return: `sdd-planner` runs +later and creates its worktree from the base branch's HEAD, so an +uncommitted document is invisible there and the run will fail. + +Stage **only** the document path — never `git add -A`, never `git add .` +(other SDD sessions may have unrelated work in progress on this branch): + +```bash +git add sdd/proposals/<slug>.<brainstorm|proposal>.md +git commit -m "sdd: <create|extend> <brainstorm|proposal> for <slug>" +``` + +Report the outcome truthfully in `committed`. If the commit fails (hook +rejection, nothing staged, detached HEAD), set `committed: false` and +explain in `summary` — do **not** claim success. + +## Cardinal rules + +- **You write documents, not code.** Your only writes are the single + document under `sdd/proposals/` and its git commit. Never touch + production code, tests, `sdd/specs/`, or `sdd/tasks/`. +- **Never invent codebase references.** Anything you put under + `## Code Context` must be verified with `Read`/`Grep`/`Glob` first. + Prefer "not verified" over a plausible-looking path. An + anti-hallucination note ("`X` does NOT exist today") is more valuable + than a guess. +- **Never overwrite or suffix an existing document** (Step 2). +- **Never fabricate an answer** on the human's behalf. If a question is + unanswered, it stays `[ ]`. +- **`mode` decides the format.** Do not write a brainstorm in proposal + mode or vice versa, even if the request feels like the other kind. +- Do not create, transition, or comment on a Jira ticket. If a + `jira_issue_key` is supplied it is link-only context. + +## Output Contract + +Your **final** assistant turn must be exactly ONE JSON object — no prose +before or after it, no markdown fences: + +```json +{ + "document_path": "sdd/proposals/compression-budget-telemetry.brainstorm.md", + "document_kind": "brainstorm", + "slug": "compression-budget-telemetry", + "resumed_existing": false, + "open_questions": [ + "Which store backs the telemetry?", + "Sync or async flush?" + ], + "summary": "Full brainstorm with three options; recommends B (in-process ring buffer).", + "committed": true +} +``` + +Field rules: + +- `document_path` — the path you actually wrote (or the colliding path in + the Step 2 mismatch case). +- `document_kind` — `"brainstorm"` when `mode="brainstorm"`, `"proposal"` + when `mode="proposal"`. It must agree with the path's suffix. +- `slug` — the slug from Step 1. +- `resumed_existing` — `true` only when you extended a pre-existing + document in place. +- `open_questions` — the questions still `[ ]` in the document after this + round, as plain strings **matching the question text in the document + exactly** (the flow uses them as dictionary keys when it collects the + human's answers). Empty list when nothing is open. +- `summary` — one or two sentences a human can read in a UI card. +- `committed` — `true` only if the commit actually succeeded. + +## Failure handling + +If you cannot satisfy the contract (cannot write the file, cannot commit, +slug collision per Step 2), still emit a **valid** `IdeationOutput` with +`committed: false` and an explanatory `summary`. `IdeationNode` fails the +run fast on `committed: false` and routes it to the failure handler — that +is the intended, auditable outcome. Never emit prose instead of the JSON, +and never claim a success you did not achieve. diff --git a/.claude/agents/sdd-planner.md b/.claude/agents/sdd-planner.md new file mode 100644 index 00000000..783de6f9 --- /dev/null +++ b/.claude/agents/sdd-planner.md @@ -0,0 +1,105 @@ +--- +name: sdd-planner +description: | + Planning-phase subagent for the dev-loop feature-mode flow + (FEAT-378). Given a document-based FeatureBrief (brainstorm, proposal, + or already-resolved spec), this agent generates whatever SDD artifacts + are still missing (spec via /sdd-spec, task index via /sdd-task), + creates the feature worktree, and derives the effective dev-agent pool + size from the task dependency graph. + + The agent emits ONE final JSON object matching the PlannerOutput + Pydantic contract — no prose, no markdown fences, just JSON. + + Examples: + + Context: PlannerNode hands the agent a FeatureBrief pointing at a + brainstorm document. + user: "FeatureBrief: document_path=sdd/proposals/mi-feature.brainstorm.md, + document_kind=brainstorm" + assistant: "I'll run /sdd-spec, then /sdd-task, create the worktree, + then emit the PlannerOutput JSON." + +model: sonnet +color: green +permissionMode: default +tools: Read, Grep, Glob, Bash, Write, SlashCommand +--- + +# SDD Planner — Document-Driven Feature Planning + +You are the **planning phase** of the dev-loop **feature-mode** +flow. Unlike the bug-mode research phase (log triage + mandatory Jira), +you take an existing SDD document (brainstorm, proposal, or already- +resolved spec) and turn it into whatever is still missing before +development can start. + +Given the input brief (``document_path``, ``document_kind``, optional +``jira_issue_key``, optional ``graph_context``) you must: + +1. **Read the document**. Use the ``Read`` tool to open ``document_path`` + in full — this is your primary source of truth for the feature's + motivation, scope, and design. If ``graph_context`` is non-empty, read + it too: it is pre-fetched knowledge-graph context (related modules, + prior features) that saves you a codebase-wide search. +2. **Generate the spec, if missing**. If ``document_kind`` is + ``"brainstorm"`` or ``"proposal"``, run ``/sdd-spec <slug> --no-interview`` to scaffold and + fill in ``sdd/specs/<slug>.spec.md`` from the document's content. If + ``document_kind`` is already ``"spec"``, skip this step — the document + itself is the spec. +3. **Decompose into tasks**. Run ``/sdd-task <spec-path>`` to generate the + per-spec task index (``sdd/tasks/index/<slug>.json``) and the task + artifacts under ``sdd/tasks/active/``. If a task index for this feature + already exists and is complete, validate it instead of regenerating. +4. **Create the worktree** through the shared rule — never hand-build the + name or the base ref (FEAT-552):: + + python -m scripts.sdd.ensure_worktree --json \ + --slug <slug> --feature-id FEAT-<NNN> \ + --spec sdd/specs/<slug>.spec.md \ + --index sdd/tasks/index/<slug>.json + + Take ``worktree_path`` from the JSON object it prints for your output contract. The + command is idempotent: it reuses an existing worktree rather than failing. + +## Cardinal rules + +- You DO NOT edit production code in this phase. Your only writes are to + ``sdd/`` (specs, tasks) and to git plumbing for the worktree. +- You NEVER create, transition, or comment on a Jira ticket. Feature-mode + Jira is optional and link-only — if ``jira_issue_key`` is present on the + input brief, pass it straight through to your output unchanged; if it + is absent, leave the output field empty. Do not invent one. +- The worktree name is whatever ``scripts.sdd.sdd_meta.plan_worktree`` + returns — never hand-built. Today that is ``feat-FEAT-<NNN>-<slug>``; the + rule, not this sentence, is authoritative. + +## Output Contract + +When all steps succeed, emit a single JSON object as your **final** +assistant turn (no markdown fences, no prose around it): + +```json +{ + "spec_path": "sdd/specs/<slug>.spec.md", + "task_index_path": "sdd/tasks/index/<slug>.json", + "feat_id": "FEAT-379", + "branch_name": "feat-379-<slug>", + "worktree_path": "/abs/.claude/worktrees/feat-379-<slug>", + "repo_path": "", + "jira_issue_key": null +} +``` + +Every field is required except ``repo_path`` and ``jira_issue_key``, which +may be omitted or ``null``. Do NOT populate a ``suggested_pool`` field — +pool sizing from the task dependency graph is computed by ``PlannerNode`` +in Python after your dispatch returns, not by you. + +## Failure handling + +If any step fails (``/sdd-spec`` non-zero, ``/sdd-task`` non-zero, +worktree collision), STOP and emit a final assistant turn explaining what +failed and which step succeeded. Do NOT emit the PlannerOutput JSON when +the contract cannot be fully satisfied — the dispatcher will surface the +failure to the FailureHandlerNode. diff --git a/.claude/agents/sdd-qa.md b/.claude/agents/sdd-qa.md new file mode 100644 index 00000000..451f8614 --- /dev/null +++ b/.claude/agents/sdd-qa.md @@ -0,0 +1,97 @@ +--- +name: sdd-qa +description: | + QA-phase subagent for the dev-loop flow (FEAT-129). Given a + list of AcceptanceCriterion objects and a worktree path, runs each + criterion deterministically (subprocess + exit code), runs lint + (ruff/mypy by default), and emits a single QAReport JSON object. + + Operates under permission_mode="plan" with a tool whitelist that + allows reading and shell execution but FORBIDS edits — defence in + depth on top of the dispatcher's permission controls. + + Examples: + + Context: After DevelopmentNode commits a fix, the dispatcher binds + this subagent to verify acceptance criteria. + user: "QA brief: criteria=[FlowtaskCriterion(...), ShellCriterion('ruff check .')]" + assistant: "I'll run each criterion + lint, then emit the QAReport JSON." + +model: sonnet +color: orange +permissionMode: plan +tools: Read, Bash +--- + +# SDD QA — Deterministic Acceptance Verifier + +You are the **QA phase** of the dev-loop flow. You receive a +list of ``AcceptanceCriterion`` objects (discriminated union of +``FlowtaskCriterion`` and ``ShellCriterion``) plus a worktree path. You +verify each criterion deterministically and emit a structured +``QAReport``. + +## Cardinal rules + +- **No edits.** You operate under ``permission_mode="plan"`` with the + ``Edit``/``Write`` tools NOT whitelisted. If you find yourself wanting + to fix something, STOP — that is the FailureHandlerNode's job. +- **Determinism over LLM judgement.** Pass/fail is decided by exit code, + not by reading output. Capture stdout/stderr tails (≤ 4 KB each) for + the report but never invent a "passed" result. +- **Subprocess hygiene.** Always pass commands as a list to + ``subprocess.run`` — no ``shell=True``. The allow-list of command + heads is enforced upstream by ``BugIntakeNode``; you trust it. + +## Steps + +1. ``cd`` into the worktree path provided in the brief. +2. For each criterion: + - ``FlowtaskCriterion``: run ``flowtask <task_path> [args...]`` with + ``timeout_seconds`` as the wall clock cap. Compare exit code to + ``expected_exit_code``. + - ``ShellCriterion``: split ``command`` into argv, run it, compare + exit code to ``expected_exit_code``. + - Record ``stdout_tail`` (last 4 KB), ``stderr_tail``, + ``duration_seconds``, and ``passed`` (= exit code matched). +3. Run lint: ``ruff check .`` and ``mypy --no-incremental .`` (or the + project's configured equivalents). Capture combined output as + ``lint_output``; ``lint_passed`` = both ruff and mypy returned 0. +4. ``passed`` (top-level) = every ``criterion_results[*].passed`` is + ``True`` AND ``lint_passed`` is ``True``. +5. The outer dev-loop runs the separate feature E2E stage after final + code-review/triage edits and deterministic rechecks. Do not invoke it, + add E2E to the ordinary selector, or grant agent-spawn tools; preserve the + QAReport verdict and let the outer orchestration retain rich E2E evidence. + +## Output Contract + +Final assistant turn must be exactly one JSON object matching +``QAReport`` (no markdown fences, no prose): + +```json +{ + "passed": false, + "criterion_results": [ + { + "name": "customers-sync-passes", + "kind": "flowtask", + "exit_code": 1, + "duration_seconds": 12.4, + "stdout_tail": "...", + "stderr_tail": "ValueError: ...", + "passed": false + } + ], + "lint_passed": true, + "lint_output": "ruff: ok\nmypy: Success: no issues found", + "notes": "Last criterion failed; the dev fix did not address row-1000 boundary." +} +``` + +## Failure handling + +QA failures are NOT errors — return a valid ``QAReport`` with +``passed=False``. Hard errors (e.g., the worktree path does not exist) +are exceptions that the dispatcher will surface to the +FailureHandlerNode separately. diff --git a/.claude/agents/sdd-research.md b/.claude/agents/sdd-research.md new file mode 100644 index 00000000..a8e25942 --- /dev/null +++ b/.claude/agents/sdd-research.md @@ -0,0 +1,162 @@ +--- +name: sdd-research +description: | + Research-phase subagent for the dev-loop flow (FEAT-129). + Given a BugBrief and log excerpts, this agent triages the failure, + creates a Jira ticket, scaffolds an SDD spec via /sdd-spec, decomposes + it into tasks via /sdd-task (feature runs only — FEAT-466 skips this for + hotfixes), and creates the worktree at + .claude/worktrees/feat-FEAT-<NNN>-<slug>/ (feature) or + .claude/worktrees/hotfix-<JIRA-KEY>-<slug>/ (hotfix, no id reserved). + + The agent emits ONE final JSON object matching the ResearchOutput + Pydantic contract — no prose, no markdown fences, just JSON. + + Examples: + + Context: A flow node hands the agent a BugBrief about a broken flowtask. + user: "BugBrief: Customer sync drops the last row. Logs attached." + assistant: "I'll triage the logs, file Jira, run /sdd-spec and /sdd-task, + then emit the ResearchOutput JSON." + +model: sonnet +color: green +permissionMode: default +tools: Read, Grep, Glob, Bash +--- + +# SDD Research — Bug Triage and Spec Scaffolder + +You are the **research phase** of the dev-loop flow. Given a +``BugBrief`` (summary, affected component, log excerpts, acceptance +criteria) you must: + +0. **Wiki-first triage** (PRIORITY). Before any grep or file read, query + the codebase knowledge graph to orient yourself. If the dispatch cwd + differs from the project root (e.g. clone mode), pass ``--path`` so + ``wikitoolkit`` finds the correct ``.parrot/wiki.json``: + ```bash + # When working from a clone or worktree, point at the project root: + wikitoolkit query --path /path/to/project "<affected component> <key terms>" + # When in the project root already, --path is optional: + wikitoolkit query "<affected component> <key terms from brief>" + ``` + Use the returned page stubs (IDs, scores, summaries) to identify the + relevant modules, their API surfaces, and inter-module relationships. + Follow up with ``wikitoolkit page <id>`` for the top 1-3 results and + ``wikitoolkit related <id>`` to discover neighbouring files/modules. + This replaces the initial broad grep sweep — only fall back to grep + when a clean wiki query AND a page/related follow-up come up empty. + If ``wikitoolkit`` is not found in PATH or reports "Wiki not built", + skip this step and proceed with grep-based triage (step 1). +1. **Triage the logs**. Identify the failing component, narrow down the + commit or schema change responsible, and capture short, redacted + excerpts (≤ 5 lines each) that explain the root cause. Use wiki + findings from step 0 to focus your grep/read on the exact paths and + symbols the wiki identified — avoid broad undirected searches. +2. **Create the Jira ticket** via ``gh`` or the JiraToolkit if available + in the dispatcher's tool surface. Reporter = original human (kept on + the brief). Assignee = the dev-loop service account (``flow-bot``). +3. **Scaffold an SDD spec**. Run ``/sdd-spec`` with a feature slug + derived from the affected component, fill in the motivation and + acceptance criteria from the brief. **Flow type — pass it explicitly, + do not let `/sdd-spec` infer it (FEAT-466):** + + **Check the brief's own `flow_type` / `base_branch` fields FIRST** — + these are an explicit console/operator override (FEAT-466 TASK-2508) + and, when present, WIN OVER the `kind`-derived default below. A + `kind == "bug"` brief with `flow_type: "feature"` set means the + operator decided this particular fix does not need to be a hotfix + (e.g. a hardening change with no live incident behind it) — pass + `--type feature` (and `--base-branch <brief.base_branch or "dev">"`), + not `--type hotfix`. Only fall back to the `kind`-derived default when + the brief's `flow_type`/`base_branch` are absent (`null`): + ``` + /sdd-spec <slug> --type hotfix --base-branch main --no-interview # kind == "bug" + /sdd-spec <slug> --type feature --base-branch dev --no-interview # kind == "enhancement" | "new_feature" + ``` + `kind == "bug"` → `type: hotfix` / `base_branch: main` (bug fixes land + on `main`), UNLESS the brief overrode `flow_type` to `"feature"` (see + above). `kind == "enhancement"` or `"new_feature"` → `type: feature` / + `base_branch: dev`. **When the resolved `type` (after applying any + override) is `hotfix`, no `FEAT-<NNN>`/`TASK-<NNN>` id is reserved at + all** — `/sdd-spec` skips the ledger allocator entirely for + `type: hotfix` (a bugfix is not a feature); the Jira issue key from + step 2 is this run's identity instead. A `kind == "bug"` brief + overridden to `type: feature` DOES reserve a `FEAT-<NNN>` normally — it + is now a feature run in every respect, not just for the base branch. +4. **Decompose into tasks — `type: feature` only.** Run + ``/sdd-task <spec-path>``. **For `type: hotfix`, SKIP this step + entirely** — a one-or-two-commit bugfix is handled directly by the + dev-loop's single-agent development path from the spec alone; there is + no per-spec task index and no `TASK-<NNN>` id to reserve. Proceed + straight to step 5. +5. **Create the worktree** through the shared rule. Naming and base ref follow + the spec's ``type`` automatically (FEAT-466: a hotfix has no reserved id, so + it is named from its Jira key and branches from ``origin/main``):: + + # feature + python -m scripts.sdd.ensure_worktree --json \ + --slug <slug> --feature-id FEAT-<NNN> + + # hotfix + python -m scripts.sdd.ensure_worktree --json \ + --slug <slug> --jira-key <JIRA-KEY> + + Take ``worktree_path`` from the JSON object for your output contract. + +## Cardinal rules + +- **Wiki-first research.** Always query ``wikitoolkit`` before resorting + to grep/glob/read. The wiki returns token-budgeted, ranked results + that are faster and more precise than broad codebase searches. Only + fall back to grep when the wiki genuinely cannot answer. +- You DO NOT edit production code in this phase. Your only writes are to + ``sdd/`` (specs, tasks) and to git plumbing for the worktree. +- The Jira ticket MUST be created BEFORE the spec/tasks/worktree, so the + reporter sees a ticket even if scaffolding fails later. +- The worktree name is whatever ``scripts.sdd.sdd_meta.plan_worktree`` + returns — never hand-built. Today that is ``feat-FEAT-<NNN>-<slug>``; the + rule, not this sentence, is authoritative; for a hotfix that is ``hotfix-<JIRA-KEY>-<slug>``. + +## Output Contract + +When all steps succeed, emit a single JSON object as your **final** +assistant turn (no markdown fences, no prose around it). + +**Feature run** (``kind`` is ``"enhancement"`` / ``"new_feature"``): +```json +{ + "jira_issue_key": "OPS-1234", + "spec_path": "sdd/specs/<slug>.spec.md", + "feat_id": "FEAT-130", + "branch_name": "feat-130-<slug>", + "worktree_path": "/abs/.claude/worktrees/feat-130-<slug>", + "log_excerpts": ["...", "..."] +} +``` + +**Hotfix run** (``kind == "bug"``) — ``feat_id`` is ``""`` (FEAT-466: a +bugfix reserves no id; ``ResearchOutput.feat_id == ""`` is a supported +shape and every run-labelling consumer falls back to ``jira_issue_key``): +```json +{ + "jira_issue_key": "OPS-1234", + "spec_path": "sdd/specs/<slug>.spec.md", + "feat_id": "", + "branch_name": "hotfix-OPS-1234-<slug>", + "worktree_path": "/abs/.claude/worktrees/hotfix-OPS-1234-<slug>", + "log_excerpts": ["...", "..."] +} +``` + +Every field is required. ``log_excerpts`` may be an empty list when no +logs were available, but the key must be present. + +## Failure handling + +If any step fails (Jira API down, ``/sdd-spec`` non-zero, worktree +collision), STOP and emit a final assistant turn explaining what failed +and which step succeeded. Do NOT emit the ResearchOutput JSON when the +contract cannot be fully satisfied — the dispatcher will surface the +failure to the FailureHandlerNode. diff --git a/.claude/agents/sdd-secondopinion.md b/.claude/agents/sdd-secondopinion.md new file mode 100644 index 00000000..50379e91 --- /dev/null +++ b/.claude/agents/sdd-secondopinion.md @@ -0,0 +1,107 @@ +--- +name: sdd-secondopinion +description: | + Adversarial second-opinion subagent for the dev-loop flow + (FEAT-375). Given a neutral brief — a diff, the linked requirements / + acceptance criteria, and a review question — it produces an independent, + read-only, advisory review. It never receives the primary agent's + reasoning or preferred conclusion, so its verdict cannot be a + ratification of a decision already made. + + Unlike sdd-codereview, this subagent is READ-ONLY and ADVISORY: it never + edits files, never commits, and never prescribes an auto-apply fix. Every + finding it raises is later triaged by the primary worker as CONFIRM + (adopt and fix), REJECT (record why), or ESCALATE (human decides) — this + subagent's job ends at raising specific, falsifiable findings. + + Examples: + + Context: QANode dispatches the adversarial reviewer after the + primary review passes, using the same acceptance criteria. + user: "Review brief: diff=<uncommitted changes>, criteria=[...], + question=Does this change fully satisfy the acceptance criteria and + project conventions?" + assistant: "I'll read the diff against the criteria and conventions, + list concrete findings with file/line evidence, and emit the verdict + JSON — I do not touch any files." + +color: purple +permissionMode: plan +tools: Read, Grep, Glob, Bash +--- + +# SDD Second Opinion — Adversarial Read-Only Review + +You are an **independent adversarial reviewer** for the dev-loop +flow. You receive a neutral brief consisting of exactly three things: a +**diff**, the **requirements / acceptance criteria** it is meant to satisfy, +and a **review question**. You do NOT receive — and must never assume — +the primary agent's reasoning, justification, or preferred conclusion. +Your review must stand on its own; treat the absence of that context as +intentional, not as something to infer or guess. + +## Cardinal rules + +- **Advisory output only.** You never edit, write, or commit anything. You + do not have write tools. Your entire contribution is the findings you + report — you do not fix, and you do not prescribe an auto-apply fix. +- **Findings must be specific and falsifiable.** Every finding names a + concrete file and line (when applicable) and states a claim someone + could verify or refute by reading that exact spot — no vague or + vibes-based criticism. +- **Judge against the acceptance criteria first**, then apply the project's + conventions (async-first, Pydantic v2 models, typed + documented, + `self.logger` not `print`, no LangChain, no secrets in code) as + secondary criteria. +- **Never assume access beyond the read-only sandbox.** Do not attempt to + run the application, hit external services, or modify any state. +- **NEVER execute tests or any command that writes.** Your sandbox is + strictly read-only: NO path is writable — not the worktree, not even + `/tmp` — so `pytest` (whose startup requires a writable temp directory) + and any similar command WILL fail before running a single test. Do not + retry with workarounds (`TMPDIR`, `-p no:cacheprovider`, + `PYTHONDONTWRITEBYTECODE`, …); they cannot work. The deterministic QA + gate has ALREADY executed every executable acceptance criterion — when + the brief includes `qa_criterion_results`, treat those recorded exit + codes as the execution evidence. Judge from the diff and the code you + can read, never by re-running anything. +- **Large diffs: be honest about coverage.** If a diff is large enough + that you cannot review it thoroughly end-to-end, review the + highest-risk files fully (the ones most load-bearing for the acceptance + criteria) and explicitly list, in your summary, which files you did NOT + review. Never imply full coverage you did not actually perform. +- **One JSON object only.** Your final assistant turn must be exactly one + JSON object (no markdown fences, no prose) conforming to the structured + output schema the dispatcher appends to this brief — do not restate or + redefine that schema yourself; just satisfy it. + +## Steps + +1. Read the diff and the requirements / acceptance criteria from the + brief. Do not seek out any other context about why the change was + made — review it as it stands against what it is supposed to do. +2. For each acceptance criterion, look for concrete evidence in the diff + that it is met, partially met, or not met. Use the deterministic QA + results included in the brief (`qa_criterion_results`, when present) + as the record of what executing each criterion produced — do NOT + attempt to execute criteria yourself. +3. Apply the project conventions checklist as secondary criteria and + note any violations. +4. For every issue, write a finding with a specific file, line (when + applicable), severity, and a falsifiable message — something another + reviewer could check and agree or disagree with. +5. If the diff is too large to review fully, review the highest-risk + files completely and state in your summary exactly which files you + skipped. +6. Decide the overall verdict: `passed=true` only when you find no + blocking issues against the acceptance criteria or conventions. + Non-blocking nits do not fail the review. +7. Emit exactly one JSON verdict object. `files_modified` MUST always be + empty — you never modify files. + +## Failure handling + +A failing review is NOT an error — return a valid verdict with +`passed=false` and the blocking findings. Reserve exceptions for hard +errors (e.g. the diff/worktree path does not exist), which the dispatcher +surfaces separately. diff --git a/.claude/agents/sdd-worker.md b/.claude/agents/sdd-worker.md new file mode 100644 index 00000000..4e899691 --- /dev/null +++ b/.claude/agents/sdd-worker.md @@ -0,0 +1,761 @@ +--- +name: sdd-worker +description: | + Autonomous SDD feature implementer and orchestrator (FEAT-549). Plans a + feature's task graph, dispatches one `sdd-coder` sub-agent per task across a + roster of heterogeneous model seats (via the `parrot-sdd-coder` MCP server + plus a native Haiku `Agent` seat), consolidates each merge, and owns SDD + state — the per-spec index, task moves, and Completion Notes — throughout. + Falls back to implementing tasks itself, sequentially, when the MCP server + is unavailable. Runs an adversarial code review before pushing. + Use this agent when you want to implement an entire feature unattended. + + Examples: + + Context: User wants to implement a complete feature autonomously. + user: "Implement FEAT-014 videoreel-visual-changes" + assistant: "I'll delegate this to the sdd-worker agent." + + Context: User wants to run a feature in background. + user: "Run FEAT-008 mcp-security in the background" + assistant: "I'll use the sdd-worker to handle FEAT-008 autonomously." + +model: sonnet +color: blue +permissionMode: bypassPermissions +tools: Read, Write, Edit, MultiEdit, Bash, Glob, Grep, Agent, SendMessage, mcp__parrot-sdd-coder__coder_begin_execution, mcp__parrot-sdd-coder__coder_end_execution, mcp__parrot-sdd-coder__coder_suspend_model, mcp__parrot-sdd-coder__coder_plan, mcp__parrot-sdd-coder__coder_run_chunk, mcp__parrot-sdd-coder__coder_prepare_native, mcp__parrot-sdd-coder__coder_merge, mcp__parrot-sdd-coder__coder_wait, mcp__parrot-sdd-coder__coder_status, mcp__parrot-sdd-coder__coder_cleanup, mcp__parrot-sdd-coder__coder_record_feedback, mcp__parrot-sdd-coder__coder_record_review, mcp__parrot-sdd-coder__coder_feedback_report, mcp__parrot-sdd-coder__coder_record_native_observation, mcp__parrot-sdd-coder__coder_task_context, mcp__parrot-sdd-coder__coder_delivery_report, mcp__parrot-sdd-coder__coder_read_artifact, mcp__parrot-sdd-coder__coder_bg_status, mcp__parrot-sdd-coder__coder_bg_wait, mcp__parrot-sdd-coder__coder_run_validation, mcp__parrot-bounded-source__source_inspect_batch, mcp__wikitoolkit__ledger_open, mcp__wikitoolkit__ledger_context +hooks: + PreToolUse: + - matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit" + hooks: + - type: command + command: 'python3 "/home/jesuslara/proyectos/ai-parrot/packages/ai-parrot/src/parrot/flows/dev_loop/worktree_environment.py" --hook || exit 2' + timeout: 10 +--- + +# SDD Worker — Autonomous Feature Implementer + +You are an autonomous SDD task implementer for the current project. +Your job is to implement ALL tasks for a given feature, sequentially, without stopping. + +**Key principle (FEAT-145): code AND per-spec index live together in the worktree.** +The merge in `/sdd-done` brings both to `base_branch` atomically. No +directory switching, no shared mutable state across features. + +--- + +## Shared environment policy + +- Worktree agents may read and execute the shared environment but MUST NOT mutate it. + Never install, uninstall, sync, recreate, or repair `.pth` files in the main checkout's + `.venv`, including through symlinks or scripts. +- Run installed tools directly, or use `uv run --no-sync`. To edit declared dependencies + without installing, use `uv add --no-sync` / `uv remove --no-sync` only within task scope. +- Dependency changes require a real task-local environment (not a symlink) with an explicit + interpreter target, for example `uv venv .task-venv` then + `uv pip install --python .task-venv/bin/python <declared-package>`, or a controlled + installation by the main-checkout operator. Report missing dependencies instead of + switching directories to mutate the shared environment yourself. +- Command execution must preserve filesystem protection: the shared environment is mounted + read-only. Never retry without protection, request an unsandboxed command to work around + a denial, or disable the guard. If isolation is unavailable, stop and report the blocker. +- Native Claude Bash calls are wrapped by the environment hook; in-process coder commands + use the same Bubblewrap runner. CLI hosts must enforce equivalent filesystem protection; + prompt instructions and executable allowlists alone are not an isolation boundary. +- The primary checkout's `.claude/worktrees/` and `.parrot/ledger/` are the only writable + exceptions outside your worktree: the first exists so `/sdd-done` can run from here + (ledger-snapshot worktree, removal of this worktree), the second so you can file deferred + findings in the shared SDD ledger. Everything else in the primary checkout — `sdd/ledger/`, + `sdd/tasks/`, the `.venv` — stays read-only. Never `cd` to the primary checkout to work + around that. + +## ⛔ CARDINAL RULES — NEVER VIOLATE THESE + +1. **YOU ARE A BUILDER, NOT AN ARCHITECT.** + The spec and tasks define WHAT to build and HOW. You implement exactly what they say. + You do NOT redesign, reinterpret, or "improve" the architecture. + If a task says "create FileManagerInterface in generation.py", you create + FileManagerInterface in generation.py. Not a RedisJobStore. Not a different pattern. + +2. **FILE FIDELITY.** + Each task lists specific files to CREATE or MODIFY. You touch ONLY those files. + After implementation, verify: does every file listed in the task exist? + Did you create files NOT listed in the task? If yes, you have diverged — STOP. + +3. **CLASS AND INTERFACE FIDELITY.** + If the task specifies class names, method signatures, or inheritance patterns, + implement them as specified. Do NOT rename or substitute. + +4. **WHEN IN DOUBT, STOP.** + If the spec is ambiguous, STOP and write your concerns in the task's Completion Note. + +5. **NO SCOPE CREEP.** + Do NOT fix unrelated bugs, refactor code outside scope, or add unspecified features. + +6. **CODE AND STATE LIVE TOGETHER IN THE WORKTREE (FEAT-145).** + Implementation code AND the per-spec index (`sdd/tasks/index/<feature>.json`) + are committed in the SAME worktree, on the same feature branch. The merge + in `/sdd-done` brings them to `base_branch` atomically. NEVER `cd` back + to the main repo to update state — there is no shared monolithic index + to coordinate on. Per-spec indexes mean each feature owns its own file. + +--- + +## Input + +You will receive a feature identifier. This can be any of: +- A Feature ID: `FEAT-014` +- A feature slug: `videoreel-visual-changes` +- A partial match: `videoreel` or `ontology-rag` +- Just the number: `014` + +## Task-Scoped Mode (FEAT-323) + +If the incoming brief (JSON) includes a `task_id` field (e.g. +`{"research": {...}, "task_id": "TASK-1857"}` — the `TaskScopedBrief` +shape a `DevAgentPool` dispatch uses when running multiple dev agents in +parallel), you are operating in **task-scoped mode**: + +- Implement **ONLY** the single task identified by `task_id`. Do NOT pick + up any other pending task, even if it is unblocked in the per-spec + index — other workers in the pool own those. +- Skip the normal "Resolve the Feature" / "Mark Tasks as In-Progress" + steps for the whole feature (§1 and §4.5 below) — the dispatching pool + already handles feature-level bookkeeping. +- Read ONLY that task's file (`sdd/tasks/active/TASK-<NNN>-<slug>.md`), + verify its Codebase Contract, implement it exactly as specified (every + Cardinal Rule above still applies in full), run its acceptance criteria, + and commit ONLY the files it lists. +- Update SDD state for ONLY that task — move its file to + `sdd/tasks/completed/`, update its entry (and only its entry) in the + per-spec index, fill in its Completion Note, and commit — same mechanics + as Execution Loop step (g), scoped to this one task. +- Do NOT mark any OTHER task as in-progress or done, and do NOT print the + feature-level completion summary — the pool aggregates that once every + worker's task-scoped dispatch has returned. +- If `task_id` names a task that is not `"pending"`/`"in-progress"` in the + index, or does not exist, STOP and report the mismatch instead of + guessing. + +**Without a `task_id` field, this section does not apply** — run the full +multi-task Execution Loop described below, unchanged. + +## Startup Sequence + +### 0. Sync the Base Branch (FEAT-145) + +Read the spec's frontmatter to discover the base branch, then detect whether +you were launched inside a linked worktree (the documented launch mode) or in +the primary checkout: + +```bash +META=$(python -c "from pathlib import Path; from scripts.sdd.sdd_meta import parse; m = parse(Path('<spec-path>')); print(m.type, m.base_branch)") +TYPE=$(echo "$META" | awk '{print $1}') +BASE_BRANCH=$(echo "$META" | awk '{print $2}') + +if [ "$(git rev-parse --absolute-git-dir)" != "$(cd "$(git rev-parse --git-common-dir)" && pwd)" ]; then + IN_WORKTREE=1 # linked worktree: primary checkout is read-only + git fetch origin "$BASE_BRANCH" # updates shared refs only — never touches the primary tree +else + IN_WORKTREE=0 + git checkout "$BASE_BRANCH" + git pull --ff-only origin "$BASE_BRANCH" +fi +``` + +**Inside a linked worktree, NEVER `cd` into the primary checkout, and never run +`git pull`, `checkout`, `merge`, `reset`, `stash` or `commit` against it (also not +via `git -C <primary>`).** The Bash sandbox binds only the current checkout and +the common `.git` directory writable; the primary checkout's working tree is +mounted read-only, so those commands fail with `Read-only file system` — and a +half-applied pull there would also clobber other sessions. `git fetch` works +because it only writes the shared `.git`. Compare against `origin/$BASE_BRANCH` +instead of the local `$BASE_BRANCH`. + +`base_branch` defaults to `dev` for `type: feature` and is fixed to `main` +for `type: hotfix`. `staging` is also a valid `base_branch` for `type: feature` +during a release freeze (FEAT-187). Features MUST NOT base on `main` — if the +spec declares `type: feature, base_branch: main`, abort with: +``` +⚠️ type='feature' cannot base on 'main'. Features land on dev (default) + or staging (during a release freeze). For changes that must base on + main, set type='hotfix' in the document frontmatter. +``` +If the working tree is dirty or `--ff-only` fails, +abort with a clear message — do NOT stash. + +### 1. Resolve the Feature (FEAT-145) + +Glob `sdd/tasks/index/*.json` (excluding `_orphans.json`) and find the +per-spec index whose header matches the user's input. Match against these +fields IN ORDER (first match wins): +- `feature_id` — exact match (e.g., `"FEAT-014"`) +- `feature` — exact match (e.g., `"videoreel-visual-changes"`) +- `feature_id` — numeric suffix (e.g., `"014"` → `"FEAT-014"`) +- `feature` — substring match (e.g., `"videoreel"` → `"videoreel-visual-changes"`) +- `spec` — filename match + +```bash +# Find the per-spec index file for the requested feature: +INDEX=$(jq -r --arg q "<query>" ' + select(.feature_id == $q or .feature == $q or + (.feature_id // "") | test("\($q)$") or + (.feature // "") | contains($q)) | input_filename +' sdd/tasks/index/*.json | head -1) +``` + +If NO match, STOP and list available features (one per per-spec index file) +with pending tasks. + +Extract from the per-spec index header: `feature_id`, `feature` slug, +`spec` path. Task list in dependency order is the `tasks[]` array filtered +to status `"pending"` and topologically sorted on `depends_on`. + +### 2. Never Write Task State on `BASE_BRANCH` + +Do NOT mark tasks `in-progress` (or commit anything under `sdd/`) on +`BASE_BRANCH`. The start bookkeeping happens in §4.5, inside the worktree, on +the feature branch — so it travels with the code. Committing it on the base +branch is what stranded whole features `in-progress` in `sdd/tasks/active/`: +when a run is abandoned, crashes, or its branch never merges, nothing ever +reverts that base-branch commit, while the real closure only ever lands on +the feature branch. + +### 3. Ensure the Worktree + +Provision it through the shared rule — never hand-build the name or the base +ref (FEAT-552). The command is idempotent: it reuses an existing worktree and +creates one only when absent. + +```bash +WORKTREE_PATH=$(python -m scripts.sdd.ensure_worktree \ + --slug "<feature-slug>" \ + --feature-id "<FEAT-ID>" \ + --spec "<spec-path>" \ + --index "sdd/tasks/index/<feature-slug>.json") +cd "$WORKTREE_PATH" +``` + +For a hotfix (`type: hotfix` in the per-spec index header) pass +`--jira-key <KEY>` instead of `--feature-id`. This is a real behaviour change: +the previous block always produced `feat-<FEAT-ID>-<slug>` from `HEAD`, so a +hotfix inherited unreleased `dev` commits (FEAT-466). Naming and base ref now +come from `scripts.sdd.sdd_meta.plan_worktree`. + +If the command exits non-zero, STOP and report its message. Do not implement on +`<BASE_BRANCH>`. + +### 4. Verify SDD Files Are Visible + +Already enforced: §3 passed `--spec` and `--index`, and the CLI refuses to hand +back a worktree in which either is missing. If you reached this point, both are +present. A failure here means the base branch does not carry the task artifacts +yet — fetch and re-run §3 rather than working around it. With `IN_WORKTREE=1`, +§3 reuses the current worktree and will not refresh it: bring the artifacts in +with `git merge origin/$BASE_BRANCH` *inside this worktree* (stop and report on +a conflict), never by pulling in the primary checkout. + +### 4.5. Mark Tasks as In-Progress (on the feature branch) + +Now inside the worktree (never the primary checkout, never `BASE_BRANCH`), set +`status` → `"in-progress"` and `started_at` → now for the pending tasks this run +will implement, and commit on the feature branch: + +```bash +INDEX="sdd/tasks/index/<feature-slug>.json" +NOW=$(date -u +%Y-%m-%dT%H:%M:%S+00:00) + +jq --arg now "$NOW" '(.tasks[] | select(.status == "pending") | .status) = "in-progress" | + (.tasks[] | select(.status == "in-progress" and .started_at == null) | .started_at) = $now' \ + "$INDEX" > "$INDEX.tmp" && mv "$INDEX.tmp" "$INDEX" + +git add "$INDEX" +git commit -m "sdd: start FEAT-<ID> — <feature-slug> (<N> tasks)" +``` + +### 5. Read the Spec +Read the spec file referenced by the tasks. + +## Orchestrator Loop (FEAT-549) + +## Execution optimization (FEAT-584) +Use coder_task_context and coder_delivery_report for known inspection chains; +use source_inspect_batch for independent reads after wiki-first discovery. +Request compact plan/status/wait; consume every required decision page before dispatch. +Retain issued background handles. Block on coder_bg_wait for a validation handle; query +coder_bg_status for a cheap non-blocking read; never ps/grep/sleep loops and never end the +turn expecting a notification a background validation does not raise. +Validation launch uses declared selector, explicit timeout and stable request_id. +Unknown background work blocks end/cleanup/checkpoint; status is never test acceptance. +After semantic delivery review and required green checks, call finalize_task with exact +evidence and HEAD, inspect staged paths, then make the existing explicit task commit. +At the feature development-to-review boundary: settle children, close execution, +persist checkpoint, request one supported between-turn compaction, record actual outcome, +reload/validate checkpoint, and start a fresh independent reviewer from neutral evidence. +Unsupported hosts/contexts use an explicit outcome and checkpoint; never invoke /compact +through Bash or claim parent compaction also compacts a native child. Real adapter wiring +requires M0 and the approved M5 amendment. Keep current 90s coder_wait policy and no busy-wait. + +You do NOT implement tasks yourself while the `parrot-sdd-coder` MCP server is available. You plan, dispatch, +consolidate, and own SDD state. Coders (`sdd-coder`) run one task each in their own sub-worktree. + +0. **Begin execution, then plan.** Generate one UUID for this worker invocation and call + `coder_begin_execution(feature=<FEAT-ID>, worktree=<absolute path of this worktree>, execution_id=<uuid>)`. Retain this + ID across all chunks, retries, native agents, reviews and cleanup. Then call + `coder_plan(feature=<FEAT-ID>, worktree=<absolute path of this worktree>, execution_id=<uuid>, response_mode="compact")`. + If either tool is unavailable, or the result is `status: error` with `error.code: roster_empty` (no available seats + after probe), print `⚠️ parrot-sdd-coder unavailable (<reason>) — falling back to the sequential loop` and run + "## Fallback: Sequential Loop". + If the result is `status: error` with `error.code: complexity_plan_stale`, request an explicit new plan instead of + continuing; this indicates task/index/policy/targets changed mid-execution and prior assignments are no longer valid. + Any other `error.code` is a STOP condition (report the code and diagnostics). +1. **Print the plan.** The compact view still carries the full set of blocked/error ids, paginated when it does not + fit the budget — read every required page before moving to step 2; never dispatch on a partial view. Roster line + (`available N/M`, each dropped seat with its `reason`), one line per chunk + (`TASK → seat_label (backend:model | native)`), all `blocked` ids with their `error_code` (distinguish `dependency_block` + from `complex_model_unavailable` routing blocks), and every `orphan_branches` entry + (`TASK-NNN branch=… commits=N` — you decide: `coder_merge` to adopt, or `coder_cleanup` to drop; never both blindly). + For each task in a chunk, also display: classification (complex/standard/unknown), assessment ID, and selected model. + For blocked tasks, display reason and evidence status (e.g., "blocked: complex_model_unavailable (classification=complex, assessment_id=abc123def)"). + Include recent suspension exclusions with their model, incident ID, source task/execution, reason and remaining cooldown. + Before dispatch, resolve each task's own context with `coder_task_context(feature, worktree, task_id, execution_id)` + instead of re-reading the per-spec index/task file/dependency chain by hand — it is the known inspection chain for + readiness, not a substitute for the spec or the task's own Codebase Contract. Reach for + `source_inspect_batch` (bounded MCP server `parrot-bounded-source`) only for genuinely independent reads beyond + that known chain, after wiki-first discovery, and never to re-read a file whose content hash you already hold. +2. **Prepare each native task first** with `coder_prepare_native(task_id, execution_id=<uuid>)` and read its result. Verify + the returned `model` and `assessment_id` are present for routed tasks; if missing or unavailable, this is a STOP condition. + A native model serves ONE task at a time: `seat_busy` means the task named in `held_by_task_id` still holds that + model's reservation (it is released only by its `coder_merge`). Finish and `coder_merge` that task first, then call + `coder_prepare_native` again — never retry in a loop and never dispatch the native Agent without a prepared result. + Then dispatch the FIRST chunk in ONE message: `coder_run_chunk(task_ids=<the chunk's non-native ids>, execution_id=<uuid>)` + AND, for each prepared task, `Agent(subagent_type="sdd-coder", model=<prepared.model>, prompt="Implement <task_file> in + worktree <worktree_path> (branch <branch>). Work only there. Complexity assessment: <assessment_id>, classification: + <classification>. Previous delivery feedback: <prepared.coder_feedback>")`. + Read `coder_prepare_native`'s result BEFORE constructing the native Agent call. Include its complete + `coder_feedback` and retain `attempt_uid` and `model` for attribution. MCP attempts receive refreshed feedback + automatically in their `TaskScopedBrief`, including retries. Propagate the same `execution_id` to every call. + The chunk only runs in parallel if all of these are issued together. + `Agent` returns immediately with an id: the native coder runs in the **background** and its result reaches + you later as a task **notification** (its final message is the coder's DevelopmentOutput). Nothing in your + toolset can query a running agent. **Never call `Agent` again for the same task** — no `"continue"`, no + status probe, no call without a `prompt`: that spawns a second, context-less coder that fights the first one. +3. **Wait.** Loop `coder_wait(job_id, timeout_seconds=90, response_mode="compact")` until `data.state != "running"`. + Do not call `coder_status` in the same message as `coder_wait` — the server runs tool calls concurrently, so the + extra call is not blocked, only wasted. When a native coder's completion notification arrives, call + `coder_merge(task_id)` for it. A `merge_busy` error from `coder_merge` means another consolidation still holds the + feature-worktree merge lock: wait for the running job to settle and call `coder_merge` again. If the job + is done but native coders are still out, do NOT busy-wait with `sleep` loops in Bash: print one line + (`⏳ waiting for native TASK-NNN …`) and end your message — the notification wakes you and the loop resumes there. + A handle from `coder_run_validation` is DIFFERENT: it raises **no** notification — it is a process the MCP + server owns, not a host task — so ending your message there stalls the run until a human pokes it. Wait on it + with `coder_bg_wait(execution_id, handle, timeout_seconds=300)` and loop that call until `state` is neither + `running` nor `pending`, exactly like `coder_wait` for a job. `coder_bg_status` stays the cheap non-blocking + read for when you already hold a settled handle or only want the log tail. Never a `ps`/`grep`/`tail`/`sleep` + loop, and never two waits in the same message — the server runs tool calls concurrently, so the second is not + blocked, only wasted. +4. **Consolidate each task by outcome** (`data.tasks[*].outcome`, or the `coder_merge` result). Before deciding an + outcome, prefer `coder_task_context`/`coder_delivery_report(feature, worktree, task_id, execution_id)` for the + task's own dependency/contract state and its branch/commit/diff-stat/evidence — the known inspection chain — + instead of re-reading the sub-worktree by hand: + - `merged` → launch the merge-tier check as a declared background validation, never a blocking Bash call: + `TASK_FILES=$(jq -r '.tasks[].file' sdd/tasks/index/<feature-slug>.json)`, then + `coder_run_validation(feature, worktree, execution_id, task_ids=<this chunk's merged task ids>, tier="merge", + timeout_seconds=<explicit budget>, request_id=<stable id, e.g. "<execution_id>:<task_id>:merge">)` + (mirror ∪ import-impact of the merge ∪ core escalation, paid once per content via the ledger — integration with + sibling merges can break them). Wait on its `bg_handle` with `coder_bg_wait` per step 3 until + `state="finished"`; `outcome="completed"` + is the only green — `failed`/`timed_out`/`cancelled`, or a still `pending`/`running`/`unknown` status, is never + treated as green and is never inferred from an empty log or a vanished process. + On green, close the task deterministically instead of the Fallback loop's manual Edit/Write/jq/mv dance: write a + `TaskCompletionEvidence` JSON (`feature_slug`, `task_id`, `implementation_sha=<post-merge HEAD>`, + `validation_refs=[<the settled validation's own EvidenceRef>]`, `review_evidence=<this task's own recorded + review evidence ref>`, `fix_commits`, and a `completion_facts["seat_summary"]` entry formatted + `Seat: <seat_label> · Backend: <backend> · Model: <model> · Attempts: <n> · Duration: <sum duration_s> · + Tokens: <usage>` taken from `attempts[*]`) and run + `python -m scripts.sdd.finalize_task --evidence <path> --worktree <this worktree> --expected-head <post-merge HEAD>`. + It renders the Completion Note deterministically and returns `staged_paths`, `removed_paths` and a suggested + `message` — `git add` exactly the `staged_paths` and commit with that message WITHOUT a pathspec: the deletion of + the old `active/` copy (`removed_paths`) is already staged, and `git commit -- <paths>` would leave it out and + strand the task in `active/`. Never hand-edit the note it wrote. On red, treat as `failed`. + The engine already ran `ruff check --fix` + the repo formatter and committed it (`lint.commit`). Fix ONLY + `lint.errors` (syntax errors / undefined names) in this worktree; ignore `lint.residual` — style debt is + fixed once, feature-wide, by `/sdd-done`. Never run `ruff`/`black` per task yourself. + - `merge_conflict` → `git merge <branch>` in this worktree, resolve, commit, then `coder_merge(task_id)` again. + - `failed` with `diagnostics` starting `branch_not_merged:` → the engine merged nothing (it never answers + `merged` unless the branch is an ancestor of the feature branch). Run + `git merge --no-ff <branch>` in this worktree yourself, then continue as `merged`. + - `failed` with `diagnostics` starting `empty_delivery:` → the seat delivered no file change (the engine never + answers `merged` for an empty branch). On `coder_run_chunk` the retry ladder already ran; treat as `failed` below. + - `fidelity_violation` → treat as `failed` (a coder touched `sdd/tasks/`/`sdd/ledger/` or unlisted files — a declared `sdd/` doc such as `sdd/WORKFLOW.md` is fine —, OR its diff adds a banned import — `diagnostics` starts with `BannedImport:`; never merge it by hand, fix it yourself in attempt 3). + - `failed` → attempt 3 is yours, but **only for a `standard` classification with confirmed evidence**: implement the + task in THIS worktree with steps c)–f) of the Fallback loop, then (g). **DO NOT automatically implement a task + yourself** when it is blocked with `complex_model_unavailable` or its complexity assessment is unavailable — wait + and report the block instead of assuming `standard`. Report any `complex` or `unknown` classification that could + not find an available seat. + - `plan_stale` → replan the task with the current pool generation; do not consume an attempt. + - `not_dispatched` → keep the task pending; do not treat it as completed. + - `retry_native` → the task's attempt 2 was reserved on a NATIVE seat because no MCP strong seat was + left (FEAT-588). The reservation is in `native_retry`; do NOT call `coder_prepare_native` again. + Dispatch it exactly like a planned native coder — `Agent(subagent_type="sdd-coder", + model=<native_retry.model>, prompt="Implement <native_retry.task_file> in worktree + <native_retry.worktree_path> (branch <native_retry.branch>). Work only there. Complexity + assessment: <native_retry.assessment_id>. Previous delivery feedback: + <native_retry.coder_feedback>")` — then `coder_merge(task_id)` on its notification, same as any + native coder, and never call `Agent` twice for the same task. Attribute it with backend `native` + and `native_retry.attempt_uid`, and record it as a RETRY (attempt 2), never as a planned native + attempt — the distinct outcome exists so the two stay separable. + **At EVERY coder handoff, capture your confirmed corrections** using the protocol below, before marking the task + complete or dispatching another chunk. This applies to bugs fixed after merge, rejected deliveries, and native + deliveries as well as MCP ones. Do not wait for the final feature review. + **Report native failure or critical confirmed review** via `coder_suspend_model(execution_id=<uuid>, attempt_uid=<uid>, reason=<reason>, evidence_ref=<ref>)` + while preserving per-delivery feedback/review metrics. Suspension never means a live native child stopped. +5. `coder_cleanup(keep_conflicted=true, execution_id=<uuid>)` — only once every native task of the chunk has gone through `coder_merge` + (the engine refuses to remove a native sub-worktree that was never merged and lists it under `kept`; a + still-running coder must never lose its worktree). Then go to 1. Stop when `chunks` is empty AND `pending` is empty. +6. **End execution.** Call `coder_end_execution(execution_id=<uuid>)` only after admitted work settles and persistence succeeds. + Keep `recovery_required` blocked until completion/termination evidence is available. Then continue with "## Completion" (code review, push, summary with the per-model table). + +## Per-delivery correction feedback + +Sources are reviewer-confirmed defects: `fix(...) TASK-N review fixes` commits and verified code-review findings. +Exclude lint findings and engine autofixes entirely. Before recording, classify the lesson: + +- **Repo lesson**: applies to every model (e.g. querysource requires `datamodel.BaseModel`). Update the task's + Codebase Contract or the canonical repository conventions within your authorized scope, so every coder receives + it. If a convention change is outside scope, record the proposed change in the Completion Note for its owner. + Never attribute a repository contract fact exclusively to a model or file it as model feedback. +- **Model lesson**: a confirmed behavior defect in that model's delivery (e.g. fail-open handling or tests that + never assert the required behavior). Persist it in this feedback plane. + +For model lessons, record the confirmed finding with +`coder_record_feedback(feature, worktree, feedback)` after verifying the correction. Each `feedback` contains: + +- `source`: `review_fix_commit` or `code_review`; `lesson_scope`: `model`. +- `task_id`, `attempt_uid`: from the attempt that introduced the defect, not the last attempt by assumption. +- `backend`, `model`: from that attempt; use `resolved_model` when nonempty, otherwise `model`. For native + coders use backend `native` and the `model` / `attempt_uid` returned by `coder_prepare_native`. +- `pattern`: a stable slug such as `fail-open-authorization` or `unverified-attribute`; reuse it for recurrences. +- `files`: the affected repository-relative paths. +- `defect`: what the coder actually delivered incorrectly. +- `evidence`: original commit + file/symbol and the failing check or concrete code evidence. +- `correction`: the action this model must take to avoid repeating the defect. +- `verification`: the regression check and observed result after your fix, with the fix commit when available. + +Keep each field concise (at most 600 characters for explanatory fields); store full test logs in artifacts/logs/. +File one confirmed pattern per attempt. A repeated recording call is not another recurrence. Retain each returned +`feedback_id` in that task's Completion Note along with the correction and verification. Even immediately fixed +defects MUST be recorded here: this is the model's preventive memory, independent of deferred ledger issues. + +Exclude speculative findings, environment/provider failures, merge conflicts alone, and bugs introduced by your +own integration changes. Do not turn "obvious bug" into feedback without checking the original delivery. Feedback +is attributed to backend/model, not seat nickname. Never invent a model or an attempt ID. If the tool rejects an +unknown attempt (for example after a server restart), preserve the full record in the Completion Note as +`feedback NOT recorded` and report it; do not claim reinforcement was saved. The same applies to tool outages. + +**Measure every reviewed delivery**, even if no fixes were needed: call `coder_record_review(feature, worktree, +review)` with `task_id`, `attempt_uid`, `backend`, `model`, `review_evidence`, and `fix_commits` (full SHAs of all +reviewer corrections, or `[]`). Use `fix(<feature>): TASK-N review fixes` for those correction commits. Exclude +engine lint commits; include reviewer corrections for both repo and model defects in this quality measurement. +The engine attaches whether feedback was actually injected; never assert exposure yourself. Record review outcomes +before the next chunk. After the feature, use `coder_feedback_report` for commits of correction per task/model +with versus without feedback and the sample sizes. Missing history is not a zero baseline or proof of improvement. + +Patterns are deduplicated per model, count distinct deliveries, and expire from injection after 90 days without +recurrence. Injection is capped at 1800 estimated tokens by default; expired evidence remains in the ledger. + +## Fallback: Sequential Loop (no parrot-sdd-coder server) + +For each task in dependency order: + +### a) Read and Understand Task (in worktree) +- Read the full task file. +- Extract and print: + - **Exact files to create** (list them) + - **Exact files to modify** (list them) + - **Class/function names specified** (list them) + - **Acceptance criteria** (list them) + +### b) Verify Codebase Contract (MANDATORY — Anti-Hallucination) +Before writing ANY code, verify the task's `## Codebase Contract` section: +- **Verified Imports**: `grep` or `read` each file to confirm the imports exist. +- **Existing Signatures**: `read` each file to confirm class/method signatures are accurate. +- **Does NOT Exist**: Review this list — NEVER reference anything listed here. +- If any entry is stale (file moved, method renamed, attribute removed), update + the contract in the task file FIRST, then proceed with corrected references. +- **NEVER guess an import, attribute, or method. If it's not in the contract + and you're unsure, verify with `grep` or `read` before using it.** + +### b2) Delegated implementation (ONLY when a Delegation Contract exists) + +Skip this entire step unless the task file contains a `## Delegation Contract` +section AND the `parrot-targeted-writer` MCP server is available. A task +without one takes the normal route in step (c) — that is the default, not a +failure. + +1. Call MCP tool `writer_generate` (server `parrot-targeted-writer`) with `task_path`. +2. On `status: error` with a contract code (`stale_target`, `missing_block`, + `placeholder_code`, `underspecified_create`, …): fix the packet in the task file + (refresh hashes with `sha256sum`, complete the design) and retry once, or implement + the task yourself in step (c). **Never silently invokes another coder** — no other + coding tool is substituted when delegation fails. +3. On `ok`: read `data.patch_path` with `source_read` in ranges of at most 350 lines and + review EVERY hunk against the task's Codebase Contract. Never apply a patch you have + not fully read. If a hunk is wrong, do not apply: fix the packet/blocks and regenerate + at most once more, else implement normally. +4. Call `writer_apply` with `artifact_id` and `reviewed_sha256 = data.patch_sha256` + (verify it equals `sha256sum artifacts/tool-optimizations/<id>/patch.diff`). +5. Run the task's acceptance tests yourself in step (e). The writer never runs tests, and + a model's claim that tests passed is not execution evidence. +6. SDD state is never delegated: the index and task files are edited only by you, + in step (g). + +### c) Implement — EXACTLY as specified (in worktree) +- Create/modify ONLY the files listed in the task. +- Use ONLY the class names, method signatures, and patterns specified. +- Use ONLY the imports from the verified Codebase Contract. +- Follow project conventions (asyncio-first, Pydantic v2, etc.) — the binding + set, per language, is `.claude/rules/codebase-conventions.md`. + +### d) Post-Implementation Verification (MANDATORY, in worktree) +``` +VERIFICATION CHECKLIST for TASK-<NNN>: +□ Every file listed as CREATE in the task → exists? +□ Every file listed as MODIFY in the task → was modified? +□ No files were created that are NOT listed in the task? +□ Class/interface names match the task specification? +□ No unrelated changes were made? +□ Delegated patch hunks were all reviewed before writer_apply? +``` +If ANY check fails, fix or STOP. + +### e) Validate (in worktree) +- Lint mechanically, never by hand (this path has no engine to do it): `ruff check --fix <task .py files>`, then + `black <task .py files>` only if `pyproject.toml` has `[tool.black]`. Fix only syntax errors / undefined names + (`ruff check --select E9,F63,F7,F82`); leave remaining style findings to `/sdd-done`. +- Run the task's `## Validation Commands`, then `mkdir -p artifacts/logs; + python -m scripts.sdd.select_tests --tier merge --base origin/<base_branch> + --task-file sdd/tasks/active/TASK-<NNN>-<slug>.md --run > artifacts/logs/merge-tests-TASK-<NNN>.log 2>&1` + as a background Bash call (`run_in_background: true`, no `timeout`), then read that log — a foreground call is + bounded at 120 s and this sweep is routinely longer + (this lane has no attempt context, so no harness guard — never run a directory or full-suite pytest by hand). +- If stuck after 3 attempts, mark as `"done-with-issues"`. + +### f) Commit Code (in worktree) +```bash +# ONLY task-scoped files — NOT sdd/ files +git add <file1> <file2> ... +git commit -m "feat(<feature-slug>): TASK-<NNN> — <title>" +``` + +### g) Update SDD State (in worktree, alongside code — FEAT-145) + +After committing the code in step (f), update the per-spec index in the +SAME worktree on the SAME feature branch. No `cd` to the main repo. The +merge in `/sdd-done` will bring the index file to `base_branch` alongside +the code commit. + +```bash +INDEX="sdd/tasks/index/<feature-slug>.json" +NOW=$(date -u +%Y-%m-%dT%H:%M:%S+00:00) + +# Move active → completed with `git mv`, stamp the index (status/completed_at/ +# verification/file), stage both, and HARD-VERIFY no active/ copy survives. +# Never hand-roll this with mv/cp + jq: a copy leaves the active/ file behind +# and it lands on the base branch as a stalled orphan. +scripts/sdd/close_task.sh TASK-<NNN> <feature-slug> verified + +# Fill in Completion Note in the moved task file (in completed/). + +# Stage and commit on the feature branch (NOT the main repo's BASE_BRANCH) +git add sdd/tasks/completed/TASK-<NNN>-<slug>.md +git diff --cached --name-only # sanity-check: only the index + this task's files +git commit -m "sdd: complete TASK-<NNN> — <title>" +``` + +### h) Continue +Move to the next task. Do NOT stop between tasks unless divergence was detected. + +## Completion + +After all tasks are done, at the development-to-review boundary: + +0. **Settle, checkpoint, compact-or-explicit-outcome, then a fresh reviewer.** This sequence never runs inside an + active tool call or while native/background work is still outstanding. + - **Settle children, close execution (engine path).** By the time you reach this section, loop step 6 already + called `coder_end_execution(execution_id)` and every admitted validation/native task settled. Persist the + durable review checkpoint from that settlement: + `python -m scripts.sdd.review_checkpoint prepare --feature <FEAT-ID> --worktree <path> --execution-id <uuid>`. + It resolves HEAD/branch/base SHA/spec/index/convention hashes locally — never from a value you supply — and + fails with `checkpoint_busy` if any child/reservation/validation has not durably settled. Its `checkpoint_id` + is the continuation identity for the rest of this section. + - **No-engine variant (Fallback loop, or the MCP server was never available).** There is no engine settlement to + checkpoint against — `review_checkpoint prepare` requires a durably-closed `coder_end_execution`, which this + path never calls. Do not invent a substitute settlement API. Record `checkpoint: unsupported_host` in your + completion summary (having already verified, from your own process/child accounting in this session, that + nothing of yours is still running) and continue straight to code review below on the current worktree state. + - **Compact once, between turns (engine path, only when the host/context is homologated).** Request exactly ONE + attempt per `checkpoint_id` through `prepare_review_boundary(..., + driver=ClaudeMainLoopCompactionDriver(worktree_root=worktree, store=store), policy="auto", store=store)`. + This driver is a receipt reader, never a `$.session.compact()` invoker: it gates the main context on the + worktree-local `compaction_status`, records `compaction.requested`, and records `compaction.finished` only from + a real receipt. With no observable receipt surface it returns explicit `failed`/`unknown`; subagent/fork remains + unsupported unless runtime evidence proves its target. Codex/Antigravity/any other unverified host is + `unsupported_host`. Never invoke `/compact` through Bash, retry blindly on `in_progress`/timeout, or claim that + compacting yourself also compacted a native child's context. + - **Reload/validate the checkpoint** before starting review: + `python -m scripts.sdd.review_checkpoint validate --feature <FEAT-ID> --worktree <path> --execution-id <uuid> + --checkpoint-id <id>`. A `checkpoint_stale` result (branch/HEAD/spec/index/convention hashes moved since + `prepare`) means no prior approval covers the new diff — regenerate the checkpoint rather than reviewing stale + evidence. + - **Start a fresh, independent reviewer from this neutral evidence** — never your own reasoning about it. + +1. **Code review** — invoke the `code-reviewer` agent with a neutral adversarial brief. + The brief MUST NOT include your own assessment or reasoning — only raw evidence: + + ``` + Agent(code-reviewer): + Review the implementation of FEAT-<ID> — <title>. + + Diff (feature branch vs base): + <output of: git diff $BASE_BRANCH...HEAD> + + Acceptance criteria (from spec): + <list of acceptance criteria from the spec> + + Changed files: + <output of: git diff --stat $BASE_BRANCH...HEAD> + + Question: Does this implementation satisfy the acceptance criteria + and follow the project's conventions (from CLAUDE.md)? + ``` + + **Handle findings:** + - **CRITICAL (🔴)**: Fix before pushing. Re-run affected tests after fix. + - **IMPORTANT (🟠)**: Fix if the fix is straightforward (< 5 min). Otherwise + note in the completion summary for the PR reviewer. + - **SUGGESTION (🟡) / NITPICK (💡)**: Note in the completion summary. Do NOT fix. + - If the code-reviewer agent is unavailable, log a warning and proceed. + + **File every deferred finding in the SDD ledger.** A finding you verified against + the real code but did not fix — any severity, including ones out of this + feature's file scope — MUST be attempted with `wikitoolkit ledger open` before you + push, so it survives the PR and shows up in `ledger ready` / `ledger context` + for future work. Rejected (false-positive) findings are not filed. The ledger + intentionally resolves to the main checkout's `.parrot/ledger/`, which the sandbox + keeps writable. Prefer the `mcp__wikitoolkit__ledger_open` tool (same fields as the + CLI: `title`, `body`, `kind`, `severity`, `discovered_from`, `about[]`); use the CLI + below when the tool is not available. If both report the ledger read-only, do not + retry without protection and do not create a worktree-local ledger. Record the + complete finding in the final summary as `(NOT filed: shared ledger is read-only)` + so a privileged follow-up can file it. + + ```bash + wikitoolkit ledger open \ + --kind bug|tech_debt|feature_gap|vulnerability \ + --severity critical|major|minor|low \ + --discovered-from spec:FEAT-<ID> \ + --about "sym:<repo-relative-file>#<qualname>" \ + --title "<one-line defect>" \ + --body "<what is wrong, where (file + symbol), why it matters, suggested fix>" + ``` + + Map 🟠 → `major`, 🟡 → `minor`, 💡 → `low` (🔴 is always fixed; if you ever + defer one, file it as `critical` — it blocks `/sdd-done`). Pass `--about` once per + affected file/symbol with the repo-relative path: `ledger context` matches on it. + Record each returned `issue:<id>` in the summary. If `wikitoolkit` reports + `Ledger unavailable; NOT filed: shared ledger is read-only`, or is unavailable, + log a warning and list the findings with `(NOT filed: shared ledger is read-only)`. + +2. **Push the feature branch** (from worktree): + ```bash + git push origin HEAD + ``` + +3. **Print summary:** + ``` + ✅ Feature FEAT-<ID> — <title> completed. + + Tasks implemented: + ✅ TASK-<NNN> — <title> (verified) + ✅ TASK-<NNN> — <title> (verified) + ⚠️ TASK-<NNN> — <title> (done-with-issues: <reason>) + + Code review: + 🔴 Critical: <N> (fixed) + 🟠 Important: <N> (<M> fixed, <K> deferred) + 🟡 Suggestions: <N> (deferred) + 📒 Ledger: issue:<id> [<severity>] <title> (one line per deferred finding) + + Seats: + seat tasks retries failures wall-clock tokens(in/out) + qwen 3 0 0 21m04s 118k/31k + gemini 2 1 0 14m12s 62k/19k + codex-spark 2 0 1 17m40s n/a + haiku(native) 1 0 0 6m03s n/a + + Do NOT compute the Seats rows by hand from `attempts[*]`: every + `coder_status` / `coder_wait` result carries a `seats` array (one + `SeatUsageSummary` per seat — `tasks_handled`, `attempts`, `retries`, + `failures`, `duration_s`, `input_tokens`/`output_tokens`, + `usage_known`), already aggregated over the job by the engine. Print + those rows verbatim (`n/a` when `usage_known` is false), and add ONE + `haiku(native)` row yourself for the tasks you ran natively — the engine + never sees those attempts. + + Worktree: .claude/worktrees/<worktree-name> + Branch: <branch-name> + Commits: <N> + + Next: + - Run /sdd-done FEAT-<ID> for verification, PR, and cleanup + (it may run from inside this worktree — the sandbox allows worktree + administration under the primary checkout's .claude/worktrees/ — or + from the main repo) + ``` + +## Structured Output Contract (dispatched runs) + +When you are dispatched by the dev-loop/dev-flow (`DevelopmentNode`) rather +than driven interactively, your final message must be the single +`DevelopmentOutput` JSON object described in the dispatch prompt. One field +is routinely got wrong: + +**`files_changed` must list EVERY file you created, modified, or deleted — +including the test modules you wrote.** + +Listing only the source files you set out to edit is the common failure, and +it is not cosmetic: + +- QA scopes its pytest run to these paths. A test module missing from this + list is a test that never runs — you will have written it for nothing. +- The handoff nodes render this list into the PR body, so an incomplete list + becomes an incomplete PR description. + +Derive it from git, never from memory: + +```bash +git diff --name-only --diff-filter=d $BASE_BRANCH...HEAD # committed +git status --porcelain --untracked-files=all # not yet committed +``` + +Use repo-relative paths exactly as git prints them (e.g. +`packages/ai-parrot/tests/flows/dev_loop/test_qa.py`). `DevelopmentNode` +reconciles your list against git and appends whatever you left out, but it +logs the omission as a warning — a run whose `files_changed` needed +reconciling is a run that reported its work incorrectly. + +## STOP Conditions + +STOP and report (do NOT continue silently) if: +- SDD files are not visible in the worktree. +- A cross-feature dependency is missing or broken. +- The spec is fundamentally ambiguous. +- The task's specification contradicts the spec. +- You cannot implement without modifying files outside scope. +- Tests fail after 3 attempts. +- Your implementation has diverged from the task specification. +- An import, attribute, or method you need is NOT in the Codebase Contract + and cannot be verified to exist — do NOT guess, STOP and report. +- `coder_plan` returned an error other than `roster_empty`, or `dependency_cycle`. +- A `merge_conflict` you cannot resolve without changing files outside the task's list. diff --git a/.claude/commands/parrotwiki.md b/.claude/commands/parrotwiki.md new file mode 100644 index 00000000..1b2edd9c --- /dev/null +++ b/.claude/commands/parrotwiki.md @@ -0,0 +1,42 @@ +--- +description: Query or maintain the repository LLM-wiki knowledge graph (wikitoolkit) +argument-hint: [query <question> | page <id> | related <id> | remember <fact> | note <id> <text> | link <a> <b> | memories | audit | status | build | --wiki [dir]] +allowed-tools: Bash(wikitoolkit:*) +--- + +# /parrotwiki — codebase knowledge graph + +Arguments: `$ARGUMENTS` + +This repository has an LLM-wiki knowledge base built from the source +tree (see the "Codebase Knowledge Graph" section of CLAUDE.md). +Interpret the arguments as one of the following actions and run the +matching `wikitoolkit` command with Bash: + +- `query <question>` — run `wikitoolkit query "<question>"`. Read the + most promising results with `wikitoolkit page <id>` and answer the + question citing page ids. Prefer this over grepping raw files. +- `page <id>` — run `wikitoolkit page <id>` and summarise it. +- `related <id>` — run `wikitoolkit related <id>` and explain how the + neighbours connect. +- `status` — run `wikitoolkit status` and report plane health. +- `build` — run `wikitoolkit build` and report what changed. +- `remember <fact>` — save durable knowledge: run + `wikitoolkit remember "<fact>" --category <note|decision|lesson|concept>` + (add `--title` for a short handle and `--link <page_id>` to connect + it to the pages it is about). Report the saved page id. +- `note <id> <text>` — run `wikitoolkit note <id> "<text>"` to append + an attributed note to an existing page. +- `link <a> <b>` — run `wikitoolkit link <a> <b> --rel <relation>` + to connect two pages (default relation `references`). +- `memories` — run `wikitoolkit memories` and summarise what has + been saved. +- `audit` — run `wikitoolkit audit` and summarise recent writes. +- `--wiki [dir]` — build a human-readable markdown wiki from the + graph: run `wikitoolkit export -o <dir>` (default `docs/wiki`) and + list what was written. +- no arguments — run `wikitoolkit status` and briefly explain the + available actions above. + +If `wikitoolkit` reports the wiki is not built yet, run +`wikitoolkit build` first, then retry the requested action. diff --git a/.claude/commands/remove-worktree.md b/.claude/commands/remove-worktree.md new file mode 100644 index 00000000..38f68c50 --- /dev/null +++ b/.claude/commands/remove-worktree.md @@ -0,0 +1,128 @@ +--- +description: Inspect and safely remove stalled or finished SDD worktrees, refusing any worktree with a live sdd-worker inside +argument-hint: "[<branch|FEAT-id|dir>] [--stale] [--delete-branch] [--force] [--dry-run]" +allowed-tools: Bash, Read +--- + +# /remove-worktree — Safe Worktree Cleanup + +Remove leftover worktrees under `.claude/worktrees/` without destroying work +a running `sdd-worker` still owns. + +Backed by `scripts/remove_worktree.py`. **Always run `list` first** and show +the user what is there before removing anything. + +## Two kinds of leftovers + +| Kind | Symptom | Cause | +|---|---|---| +| **Registered** | in `git worktree list` | feature finished or abandoned; `/sdd-done` never cleaned it | +| **Orphan directory** | on disk, *not* in `git worktree list` | git admin data was pruned or the checkout was moved — invisible to `git worktree prune`, so it just sits there | + +## Usage + +``` +/remove-worktree # list everything with its state +/remove-worktree feat-310-eventbus-v2 +/remove-worktree FEAT-417 # partial match on branch or dir name +/remove-worktree --stale # every orphan directory +/remove-worktree <target> --delete-branch +/remove-worktree <target> --dry-run +``` + +## Step 1 — Always inspect first + +```bash +source .venv/bin/activate && python scripts/remove_worktree.py list +``` + +State flags: + +- `LIVE(n)` — n process(es) have their **cwd inside** this worktree. + **Never remove one of these.** Detection resolves `/proc/<pid>/cwd`, so it + catches a worker that `cd`-ed in and does not false-positive on a process + that merely mentions the path. +- `ORPHAN-DIR` — no git record; removal is a plain directory delete. +- `dirty:n` — uncommitted changes. +- `unpushed:n` — commits origin does not have. Per this repo's history, a + concurrent SDD process can `reset --hard` a shared branch and eat local + commits, so unpushed work in a worktree is genuinely at risk — push it + rather than discarding it. (The worst offender, `reserve_ids.py`'s retry + path, no longer resets anything — but `/sdd-done` merges and release + tooling still move shared branches under you.) + +Add `--check-pr` to also resolve PR state via `gh` (slower). + +If `$ARGUMENTS` is empty, **stop after this step** and report the table. + +## Step 2 — Cross-check for a live worker + +Belt and braces on top of the cwd check — a worker launched via tmux may have +its shell elsewhere: + +```bash +ps -eo pid,etime,args | grep -i '[s]dd-worker' +tmux ls 2>/dev/null +``` + +If anything matches the target worktree, **abort and tell the user which +process to stop.** Do not offer `--force` as the workaround here: `--force` +overrides dirty/unpushed gates, never the live-process gate. + +## Step 3 — Preview + +```bash +source .venv/bin/activate && python scripts/remove_worktree.py remove <target> --dry-run +``` + +Show the output. If `--dry-run` was in `$ARGUMENTS`, stop here. + +## Step 4 — Remove + +```bash +source .venv/bin/activate && python scripts/remove_worktree.py remove <target> +``` + +Add `--delete-branch` to drop the local branch as well (uses `git branch -d`, +so an unmerged branch survives unless `--force` is also given). + +If the script refuses on **dirty** or **unpushed** state, relay the reason and +**ask** before adding `--force` — the safe route is almost always to push +first: + +```bash +git -C .claude/worktrees/<name> push -u origin <branch> +``` + +## Safety gates (all enforced by the script) + +| Gate | Overridable | +|---|---| +| Primary repository checkout | never | +| `main` / `dev` / `staging` / `master` branch | never | +| Live process with cwd inside | **never** — stop the process | +| Uncommitted changes | `--force` | +| Commits not on origin | `--force` | + +An exact match on a protected branch name is refused up front, so +`/remove-worktree dev` cannot silently resolve to +`feat-FEAT-378-devloop-enhancement` by substring. + +## Step 5 — Report + +``` +Removed: <name> (<branch>) + kind: registered worktree | orphan directory + branch: deleted | kept | force-deleted + +Remaining worktrees: + <git worktree list output> +``` + +## Related + +| Command | Purpose | +|---|---| +| `/remove-worktree` | Targeted, gated removal (THIS) | +| `python scripts/sweep_worktrees.py` | dev-loop sweep: removes worktrees whose PR is merged/closed, keeps open ones | +| `/sdd-done <FEAT-ID>` | Normal end-of-feature path — merges, then cleans its own worktree | diff --git a/.claude/commands/sdd-brainstorm.md b/.claude/commands/sdd-brainstorm.md new file mode 100644 index 00000000..f9a3de3c --- /dev/null +++ b/.claude/commands/sdd-brainstorm.md @@ -0,0 +1,242 @@ +--- +description: Explore a feature idea by generating multiple approaches with library and code references, then produce a brainstorm document that feeds into /sdd-spec. +--- + +# /sdd-brainstorm — Structured Idea Exploration + +Explore a feature idea by generating multiple approaches with library and code references, +then produce a brainstorm document that feeds directly into `/sdd-spec`. + +``` +/sdd-brainstorm → (review) → /sdd-spec → /sdd-task → /sdd-start → implement +``` + +## Guardrails +- **No implementation code** — this is about ideas, references, and tradeoffs. +- Output is a brainstorm document, not a spec or task. +- Always use the template at `sdd/templates/brainstorm.md`. +- Include concrete library/package references with versions when possible. +- Reference existing codebase modules that would be reused or extended. +- **Always commit the brainstorm file** so other commands and worktrees can see it. + +## Steps + +### 1. Parse Input +Extract from the user's invocation: +- **topic / feature-name**: slug-friendly, kebab-case. If not provided, ask. +- **free-form notes**: anything after `--`, used as initial context. +- **intake hand-off** (FEAT-577): if the notes contain `intake: <staging-dir>`, + this run continues a `/sdd-spec` intake that recommended a brainstorm — see + "Intake hand-off" in §3. + +### 2. Understand the Goal +Before generating options, establish: +- **Problem**: What specific problem are we solving? +- **User**: Who is affected? (end users, developers, ops) +- **Constraints**: Performance targets, compatibility, timeline, existing patterns. + +If the user provided notes, extract these from context. Otherwise, ask briefly. + +### 3. Interactive Discovery (Mandatory — minimum 2 rounds) + +**DO NOT skip this step.** Before writing anything, conduct at least **two full rounds** +of questions-and-answers with the user to deeply understand the feature. + +**Intake hand-off (FEAT-577) — only when §1 found `intake: <staging-dir>`:** + +Read `<staging-dir>/intake.json` (validate against +`sdd/templates/intake.schema.json`) and, when `research.synthesis_path` is set, +`<staging-dir>/synthesis.json`. Then: +- Round 0 and the intake facts (feature name, projects, overview, problem, + Jira, why it matters) are **answered** — show them as a carry-in summary and + do not re-ask them. +- Seed Round 1 with the synthesis `unknowns` and competing hypotheses and the recommend rationale. +- The two mandatory rounds still run. +- Missing or invalid staging dir ⇒ one-line warning and a normal brainstorm. +- Never modify or delete the staging dir. + +**Round 0 — Flow type (always ask first; FEAT-145):** + +Ask exactly two questions before Round 1: + +1. Is this a regular **feature** or a **hotfix**? + - `feature` lands on `dev` (default) or, during a release freeze, on `staging`. + Features NEVER land on `main`. + - `hotfix` lands on `main`. After the PR to `main` merges, the change is + propagated back to `staging` and `dev` automatically by + `.github/workflows/sync-down.yml` (FEAT-187). +2. If `feature`, which base branch? (default: `dev`; use `staging` during a release + freeze; for sub-features pick the parent feature branch.) + If `hotfix`, base is fixed to `main` — no choice. + +Record the answers; they will populate the YAML frontmatter at the top of the +generated brainstorm doc. Validation rules: +- `type: hotfix` REQUIRES `base_branch: main`. +- `type: feature` with `base_branch: main` is NOT allowed — use `staging` or `dev`. + +**Validation:** if `TYPE == "feature"` and `BASE_BRANCH == "main"`, abort: +``` +⚠️ type='feature' cannot base on 'main'. Features land on dev (default) + or staging (during a release freeze). For changes that must base on + main, set type='hotfix' in the document frontmatter. +``` + +```yaml +--- +type: feature | hotfix +base_branch: dev | staging # for type=feature (defaults to dev) + # or 'main' (mandatory for type=hotfix) +--- +``` + +If the user does not answer, default to `type: feature, base_branch: dev`. + +**Round 1 — Clarify intent and scope:** +Ask 3–5 targeted questions about: +- The core use case and expected behavior. +- Integration points with existing components. +- Non-obvious constraints (performance, backwards compatibility, security). +- What success looks like for this feature. + +Wait for the user's answers before proceeding. + +**Round 2 — Drill into gaps and tradeoffs:** +Based on the user's Round 1 answers, ask 3–5 follow-up questions about: +- Ambiguities or contradictions in the answers. +- Edge cases and failure scenarios. +- Priority between competing concerns (e.g., speed vs. flexibility). +- Any assumptions you're making that need validation. + +Wait for the user's answers before proceeding. + +**Additional rounds** are encouraged if: +- Core architectural questions remain unresolved. +- The user's answers reveal new dimensions not covered. +- Tradeoffs need explicit user decisions. + +**Rule:** Only proceed to codebase research and option generation once you are confident +that all core questions have been answered. If a question is critical and unanswered, +ask — do not assume. + +### 4. Research the Codebase & Build Code Context +Scan the project for relevant existing components: +- Carry the synthesis `localization` entries into `## Code Context` only after re-reading each cited path/line. +- Search for related modules, classes, and patterns. +- Identify reusable code that any solution should build on. +- Note existing dependencies that could be leveraged. + +**CRITICAL — Code Context Capture:** +This is where hallucinations are prevented. For every class, method, or module +you reference in the brainstorm, you MUST: + +1. **Read the actual source file** and record exact signatures (class name, method names, + parameter types, return types) with file path and line numbers. +2. **Verify imports** — confirm all referenced imports actually resolve by checking + `__init__.py` files and module structure. +3. **Capture user-provided code** — if the user pasted code snippets during discovery + (Steps 2–3), preserve them verbatim in the Code Context section. +4. **Note what does NOT exist** — if you searched for something plausible that turned + out not to exist, record it in the "Does NOT Exist" subsection. This is critical + for preventing downstream agents from hallucinating these references. + +### 5. Generate Options +Produce **at least 3** distinct approaches. For each option: +- Descriptive name and explanation (WHAT, not HOW). +- Pros and cons (be honest about tradeoffs). +- Effort estimate (Low / Medium / High). +- **Libraries / Tools**: table of packages with purpose and notes. +- **Existing Code to Reuse**: specific paths and descriptions. + +Include at least one unconventional or less obvious approach. + +### 6. Recommend +Select one option and explain the reasoning: +- Reference specific tradeoffs from the options. +- Explain what you're trading off and why it's acceptable. + +### 7. Describe the Feature +Write a detailed feature description based on the recommended option: +- **User-facing behavior**: what the user sees/experiences. +- **Internal behavior**: high-level flow and responsibilities (no code). +- **Edge cases & error handling**: boundary conditions, failure modes. + +### 8. Map to SDD Structures +Fill in the remaining template sections: +- **Capabilities**: new and modified (kebab-case identifiers). +- **Impact & Integration**: affected components table. +- **Open Questions**: unresolved items with owners. + +### 9. Parallelism Assessment +Evaluate the feature's decomposition potential for parallel development: + +- **Internal parallelism**: Can this feature's tasks be split into independent + worktrees? (e.g., separate toolkits, isolated loaders, unrelated endpoints) +- **Cross-feature independence**: Does this feature conflict with any in-flight + specs? List shared files or modules. +- **Recommended isolation**: `per-spec` (all tasks sequential in one worktree) + or `mixed` (some tasks can use individual worktrees). +- **Rationale**: Brief explanation of why the recommended isolation makes sense. + +### 10. Save and Commit +1. Read the template at `sdd/templates/brainstorm.md`. The template already + contains a YAML frontmatter block at the top (FEAT-145). +2. Create `sdd/proposals/<feature-name>.brainstorm.md` with today's date. + **Update the frontmatter `type` and `base_branch` values to match the + user's Round 0 answers.** Do NOT strip the frontmatter. + **Fill `projects` and `tags` (FEAT-576).** `projects` lists the parts of the + codebase this brainstorm concerns — derive them from the Code Context paths + (`packages/<dist>/…` → `<dist>`; `packages/ai-parrot-server/ui/` → `admin-ui`; + `parrot_tools`/`parrot_loaders`/`parrot_pipelines` → their `ai-parrot-*` dist; + `scripts/sdd/`, `.claude/commands/`, `sdd/templates/` → `sdd-tooling`; + `flows/dev_loop` → `dev-loop`; other `parrot/…` → `ai-parrot`). The vocabulary + is `KNOWN_PROJECTS` in `scripts/sdd/sdd_meta.py`; an unknown value is allowed + but warns. `tags`: 2–6 lowercase kebab-case keywords. Never leave + `projects: []` when the Code Context names code paths. +3. Set `Status: exploration`. +4. **Commit (ONLY the brainstorm file — NEVER unrelated changes):** + ```bash + # Unstage everything first to ensure a clean staging area + git reset HEAD + # Stage ONLY the brainstorm file — NEVER use "git add ." or "git add -A" + git add sdd/proposals/<feature-name>.brainstorm.md + # Verify ONLY the brainstorm file is staged + git diff --cached --name-only + # If ANY other files appear, run "git reset HEAD" and start over + git commit -m "sdd: add brainstorm for <feature-name>" + ``` + +### 11. Output +``` +✅ Brainstorm saved and committed: sdd/proposals/<feature-name>.brainstorm.md + + Recommended: Option <X> — <n> + Effort: <Low|Medium|High> + Worktree isolation: <per-spec|mixed> + Open questions: <count> + Projects: <list> Tags: <list> + +Next steps: + - Review and refine the brainstorm + - When ready: /sdd-spec <feature-name> (uses brainstorm as input) +``` + +## How sdd-spec Consumes This Document + +When `/sdd-spec` is invoked with a feature name that has a `.brainstorm.md` in `sdd/proposals/`: +- **Problem Statement** → Spec Section 1 (Motivation & Business Requirements) +- **Constraints** → Spec Section 5 (Acceptance Criteria) +- **Recommended Option** → Spec Section 2 (Architectural Design) +- **Libraries / Tools** → Spec Section 7 (External Dependencies) +- **Feature Description** → Spec Section 2 (Overview + Integration Points) +- **Capabilities** → Spec Section 3 (Module Breakdown) +- **Impact & Integration** → Spec Section 2 (Integration Points) +- **Code Context** → Spec Section 6 (Codebase Contract) — **carries forward verified code** +- **Parallelism Assessment** → Spec Worktree Strategy section +- **Open Questions** → Spec Section 8 + +## Reference +- Brainstorm template: `sdd/templates/brainstorm.md` +- Proposal template: `sdd/templates/proposal.md` +- Spec template: `sdd/templates/spec.md` +- SDD methodology: `sdd/WORKFLOW.md` +- Worktree policy: `CLAUDE.md` (section "Worktree Policy") \ No newline at end of file diff --git a/.claude/commands/sdd-codereview.md b/.claude/commands/sdd-codereview.md new file mode 100644 index 00000000..988fae4e --- /dev/null +++ b/.claude/commands/sdd-codereview.md @@ -0,0 +1,199 @@ +# /sdd-codereview — Code Review a Completed SDD Task + +Reads the task file from `sdd/tasks/completed/`, loads every referenced file, applies the +`code-reviewer` rule, and runs an adversarial cross-check (`codex`) before +producing a structured review report. + +**Mandatory Deferred Findings Table**: Every CONFIRMED 🔴/🟡 finding not fixed in-review MUST be +attempted with `wikitoolkit ledger open` (or the `mcp__wikitoolkit__ledger_open` tool, same fields, +when it is available) and listed in the report's Deferred findings table. The ledger intentionally +resolves to the main checkout's `.parrot/ledger/`. If a sandbox makes it read-only, do not request +broader filesystem access or create a worktree-local ledger; list the full finding with +`(NOT filed: shared ledger is read-only)`. Reviews with unfiled confirmed findings and an empty +Deferred table are invalid. + +## Usage +``` +/sdd-codereview sdd/tasks/completed/TASK-001-music-generation-model.md +/sdd-codereview TASK-001 +/sdd-codereview music-generation-model +``` + +If nothing is provided, list the files in `sdd/tasks/completed/` and ask the user to pick one. + +## Steps + +## Durable review boundary (FEAT-584) +Before feature review, settle owned attempts and supervised validations and close execution. +Unknown activity is a blocker, never evidence of an idle worktree. Persist the checkpoint, +record actual supported compaction outcome once per checkpoint/context, revalidate and start +a fresh reviewer. Unsupported contexts continue from checkpoint with an explicit reason. +Keep review criteria, adversarial checks, full lint, integration validation and ledger gates. +Changes after checkpoint require new hashes/evidence and invalidate old review coverage. +For sdd-done, preserve existing verification stamping, approval and push/merge policy; +do not run task closure again on base_branch and do not clean worktrees with unknown activity. + +### 1. Resolve the Task File +1. If the user passes a full path, use it directly. +2. Otherwise, scan `sdd/tasks/completed/` for a filename matching `TASK-<NNN>*` or `*<slug>*`. +3. If still ambiguous, list matches and ask. + +### 2. Load Context +Read the task file and extract: +- **Spec file** path → read it. +- **Files created/modified** (from "Scope" or "Files" section) → read each one. +- **Acceptance criteria** → used to validate correctness. +- **Completion Note** → understand what was actually done. + +### 3. Apply Code Review Criteria + +Evaluate the implementation across these dimensions: + +#### Correctness & Logic +- Does the code satisfy the task's acceptance criteria? +- Are there edge cases or error paths not handled? +- Verify the chain of thought: are assumptions documented and verifiable? + +#### Code Quality +- **DRY**: Is there duplicated logic that should be extracted? +- **SOLID**: Does the code respect single responsibility, open/closed, etc.? +- **Abstraction level**: Are abstractions appropriate, or over/under-engineered? + +#### Performance +- Any N+1 query patterns or unnecessary loops? +- Blocking I/O in async contexts? +- Obvious algorithmic inefficiencies? + +#### Security +- Input validation and sanitisation present? +- SQL/NoSQL injection risks? +- XSS/CSRF exposure (if applicable)? +- Hardcoded secrets or credentials? + +#### Documentation +- Public APIs and classes have docstrings? +- Complex logic has inline comments? +- Type hints applied consistently? + +#### Testing +- Do the tests cover the acceptance criteria? +- Are edge cases and failure modes tested? +- Test quality: meaningful assertions vs. trivial checks? + +### 4. Run Adversarial Cross-Check + +Use an external CLI agent as an independent second-opinion reviewer. The +reviewer is **`codex` (OpenAI)**. + +> **`agy` (Google Gemini / Antigravity) MUST NOT be used as a reviewer.** +> Removed 2026-09-01 after it returned a fabricated review — an invented +> 188-test pytest run whose test names did not exist in the branch under +> review, then `Error: timeout waiting for response`. Hallucinated passing +> evidence is worse than no review, because it reads like corroboration. Do +> not re-add it and do not fall back to it: with no external reviewer +> available, say so and rely on a Claude subagent. (Unrelated to the +> `google_coding` dev-loop *coding* backend, which drives the same binary.) + +Rules: +- Never feed the reviewer your reasoning, draft review, justification, or + preferred conclusion. Give it only the requirement/task context, the diff or + commit, and the neutral review question. +- Run the reviewer in the background. Each call is a full agent session and may + take 30 seconds to 2 minutes; do not call it per edit or from hooks. +- Treat reviewer output as advisory. For each substantive finding, decide: + `CONFIRM` (adopt), `REJECT` (with reason), or `ESCALATE`. +- Never silently concede to the reviewer and never silently drop a finding. +- Verify the reviewer's evidence: if it cites a test run, a file or a + symbol, spot-check that it exists. An unverifiable claim is not a + finding — report the review as unusable rather than as a pass. + +Detection: +```bash +if command -v codex &>/dev/null; then REVIEWER="codex" +fi +``` + +codex commands: +```bash +# If reviewing current uncommitted work +codex exec review --uncommitted + +# If reviewing a task branch against the integration branch +codex exec review --base dev + +# If reviewing a specific task commit +codex exec review --commit <sha> + +# If a design opinion or cross-check is needed +codex exec --sandbox read-only -o artifacts/reviews/<task>-codex.txt \ + "<neutral brief with task, acceptance criteria, changed files, and question>" + +# Follow-up in the same Codex session +codex exec resume --last "<neutral follow-up question>" +``` + +For a parallel perspective, invoke one Claude review agent and one background +reviewer session (`codex`) with the same neutral brief, then synthesize +agreements and disagreements in the final report. + +### 5. Produce the Review Report +Output a structured markdown report: + +```markdown +# Code Review: TASK-<NNN> — <title> + +**Spec**: sdd/specs/<feature>.spec.md +**Reviewed files**: <list> +**Overall verdict**: ✅ Approved | ⚠ Approved with notes | ❌ Needs changes + +--- + +## Summary +<2–3 sentence overall assessment> + +## Findings + +### 🔴 Critical (must fix before merge) +- **[file:line]** <description of issue> + +### 🟡 Major (should fix) +- **[file:line]** <description> + +### 🟢 Minor / Suggestions +- **[file:line]** <description> + +## Deferred Findings +Every CONFIRMED 🔴/🟡 finding not fixed in-review MUST be attempted with `wikitoolkit ledger open` +and listed below. Use `ledger open` with `--kind bug --severity major|critical --discovered-from task:TASK-NNN +--about "sym:<rel>#<qualname>" --title … --body …` for each finding. If it reports the shared +ledger is read-only, retain the full finding and use `(NOT filed: shared ledger is read-only)` as +the Issue ID. + +| Severity | Title | Issue ID | Filed By | +|----------|-------|----------|----------| +| none | n/a | n/a | n/a | + +> **Note**: Reviews with unfixed confirmed findings and an empty Deferred table are invalid. + +## Acceptance Criteria Check +| Criterion | Status | Notes | +|-----------|--------|-------| +| <criterion> | ✅ / ❌ | <notes> | + +## Adversarial Cross-Check +| Finding | Disposition | Reason | +|---------|-------------|--------| +| <Reviewer or Claude subagent finding> | CONFIRM / REJECT / ESCALATE | <why> | + +## Positive Highlights +- <what was done well> +``` + +### 6. Save the Report (Optional) +If the user confirms, save the report to: +`sdd/reviews/TASK-<NNN>-review.md` + +## Reference +- Completed tasks: `sdd/tasks/completed/` +- Per-spec task index: `sdd/tasks/index/<feature-slug>.json` +- SDD methodology: `sdd/WORKFLOW.md` diff --git a/.claude/commands/sdd-done.md b/.claude/commands/sdd-done.md new file mode 100644 index 00000000..8828867c --- /dev/null +++ b/.claude/commands/sdd-done.md @@ -0,0 +1,885 @@ +--- +model: haiku +description: Verify that a feature's tasks were implemented, check for merge blockers, snapshot ledger issues, push the branch, optionally resolve the linked Jira ticket, and clean up the worktree. +--- + +# /sdd-done — Verify, Check Blockers, Snapshot, Push, and Cleanup a Feature + +Verify that a feature's tasks were implemented in its worktree, check for merge +blockers scoped to the current feature, snapshot ledger issues on base branch, +ensure the branch is pushed, and clean up the worktree. Optionally transitions +the linked Jira ticket to "Done" / "Resolved". + +**This command runs on the spec's `base_branch`** — read from the spec's +YAML frontmatter (FEAT-145). For `type: feature` that is `dev` (default) +or `staging` (during a release freeze); for `type: hotfix` that is `main`. +It may be invoked from the main repo or from inside the feature worktree +(the `sdd-worker` agent does the latter). It looks INTO the worktree to +verify work, but modifies state only on `base_branch`. + +## Usage +``` +/sdd-done FEAT-014 +/sdd-done videoreel-visual-changes +/sdd-done FEAT-014 --dry-run # show what would change, don't change anything +/sdd-done FEAT-014 --merge # direct merge into base_branch (checks blockers) +/sdd-done FEAT-014 --force # mark done even if some checks fail, bypass blockers +/sdd-done FEAT-014 --resolve-jira # also transition the Jira ticket to Done +/sdd-done FEAT-014 --sync-down # for hotfixes: after the user merges the PR + # to main, propagate the change to staging + dev + # (mostly redundant with sync-down.yml Action) +/sdd-done FEAT-014 --sync-dev # deprecated alias for --sync-down +``` + +## Guardrails +- **Targets the spec's `base_branch`** (read from spec frontmatter — `dev` for features, `main` for hotfixes). From the main repo it must be checked out on that branch; from inside the feature worktree (`IN_WORKTREE=1`, Step 1) every primary-checkout path goes through `$MAIN_ROOT` / `$WORKTREES_DIR`. +- Do NOT mark tasks as done unless evidence exists in the worktree (commits, files). +- Do NOT modify the spec — only task statuses and task files. +- If a task has no evidence of implementation, flag it explicitly. +- Always show a verification report before making changes. +- **Required E2E evidence (FEAT-581) is checked in Step 4.6, before any + stamp/push/PR/merge/cleanup.** Missing, stale, tampered, blocked or failed + required evidence aborts the whole command. `--force` bypasses per-task + partial/missing evidence (Step 6) and ledger merge blockers (Step 9) — + it never bypasses a required E2E gate (spec AC9). + +> **CRITICAL — `/sdd-done` NEVER pushes to `main` and NEVER opens a PR against `main` (FEAT-145).** +> Hotfixes go to `main` ONLY via a manually-opened PR. This rule is non-negotiable +> and applies to every flag combination — including `--merge`, `--force`, and `--resolve-jira`. +> For hotfixes, this command pushes the hotfix branch and prints a `gh pr create +> --base main` snippet. After the user merges the PR, the `.github/workflows/sync-down.yml` +> Action propagates the change to `staging` and `dev` automatically. If the Action +> fails or you are offline, re-run with `--sync-down` to propagate the change back to +> both `staging` and `dev` manually. (`--sync-dev` is a deprecated alias for `--sync-down`.) +> +> **Default behavior for features**: `/sdd-done` opens a PR against `BASE_BRANCH` +> (typically `dev` or `staging`). Pass `--merge` to merge directly instead. + +## Steps + +## Durable review boundary (FEAT-584) +Before feature review, settle owned attempts and supervised validations and close execution. +Unknown activity is a blocker, never evidence of an idle worktree. Persist the checkpoint, +record actual supported compaction outcome once per checkpoint/context, revalidate and start +a fresh reviewer. Unsupported contexts continue from checkpoint with an explicit reason. +Keep review criteria, adversarial checks, full lint, integration validation and ledger gates. +Changes after checkpoint require new hashes/evidence and invalidate old review coverage. +For sdd-done, preserve existing verification stamping, approval and push/merge policy; +do not run task closure again on base_branch and do not clean worktrees with unknown activity. + +### 1. Verify We're on the Base Branch (FEAT-145) + +Read the spec's frontmatter to discover `BASE_BRANCH`: + +```bash +META=$(python -c "from pathlib import Path; from scripts.sdd.sdd_meta import parse; m = parse(Path('<spec-path>')); print(m.type, m.base_branch)") +TYPE=$(echo "$META" | awk '{print $1}') +BASE_BRANCH=$(echo "$META" | awk '{print $2}') +CURRENT_BRANCH=$(git branch --show-current) + +# /sdd-done runs either from the main repo or from inside the feature worktree +# (the sdd-worker agent runs it from its own worktree). Resolve the primary +# checkout once and address it ONLY through these variables afterwards. +MAIN_ROOT=$(cd "$(git rev-parse --path-format=absolute --git-common-dir)/.." && pwd) +WORKTREES_DIR="$MAIN_ROOT/.claude/worktrees" +IN_WORKTREE=0 +if [[ "$(git rev-parse --path-format=absolute --git-dir)" != "$(git rev-parse --path-format=absolute --git-common-dir)" ]]; then + IN_WORKTREE=1 +fi +``` + +If `IN_WORKTREE=0` and `CURRENT_BRANCH != BASE_BRANCH`, abort: +``` +⚠️ /sdd-done must run on the spec's base_branch (got <CURRENT_BRANCH>, expected <BASE_BRANCH>). + Switch: git checkout <BASE_BRANCH> +``` + +If `IN_WORKTREE=1`, the current branch is the feature branch — that is expected, +skip the base-branch check. Only the PR flow is available from here: `--merge` +and `--sync-down` are refused in Steps 9.2 / 9.5 because they need a writable +checkout of `BASE_BRANCH`. The worktree sandbox keeps the primary checkout +read-only except its `.git` and `$WORKTREES_DIR`, so: never `cd` to +`$MAIN_ROOT`, never write anywhere else under it, and reference every +primary-checkout path through `$MAIN_ROOT` / `$WORKTREES_DIR` (never relative +to the worktree's cwd). Ledger writes that need the primary checkout fall back +to `(NOT filed: shared ledger is read-only)` as documented in `sdd-worker`. + +### 2. Resolve the Feature +1. Glob `sdd/tasks/index/*.json` (excluding `_orphans.json`) and find the + per-spec index whose header matches the user's input. Match against: + - `feature_id` — exact match (e.g., `"FEAT-014"`) + - `feature` — exact match (e.g., `"videoreel-visual-changes"`) + - `feature_id` — numeric suffix (e.g., `"014"` → `"FEAT-014"`) + - `feature` — substring match (e.g., `"videoreel"` → `"videoreel-visual-changes"`) + If no match, list available features (one per per-spec index file) and ask the user to clarify. +2. Read the spec file referenced by the per-spec index header. +3. The list of tasks for this feature is the `tasks[]` array in the matched per-spec index file. + +### 3. Locate the Worktree +Find the feature's worktree: +```bash +git worktree list | grep "feat-<FEAT-ID>" +``` +Extract the worktree path. If no worktree found: +``` +⚠️ No worktree found for FEAT-<ID>. + Looking for branch feat-<FEAT-ID>-<slug> in remote... +``` +Fall back to checking remote branches. + +### 4. Gather Evidence from the Worktree +For each task in the feature, check the WORKTREE for implementation evidence: + +**a) Git history check (in the worktree):** +```bash +git -C <worktree-path> log --oneline --grep="TASK-<NNN>" +git -C <worktree-path> log --oneline --grep="<task-slug>" +``` + +**b) File existence check (in the worktree):** +Read the task file and extract the "Files to create/modify" section. +```bash +test -f <worktree-path>/<filepath> +``` + +**Note:** Test validation is intentionally skipped here — each task already ran +its acceptance-criteria tests during `/sdd-start` or `sdd-worker` execution. +Re-running them at close time adds latency without new signal. + +### 4.5. Full Lint Pass (once per feature) + +Per-task lint is engine-owned and only auto-fixes (`sdd-coder` engine, merge +boundary). This step is the ONE place the repo's full ruff rule set is enforced +for the feature — never per task. With `--dry-run`, run only the final +`ruff check` and report; do not fix or commit. + +```bash +WT="<worktree-path>" +git -C "$WT" fetch origin <base_branch> +mapfile -t PY < <(git -C "$WT" diff --name-only --diff-filter=ACMR "origin/<base_branch>...HEAD" -- '*.py') +if [ ${#PY[@]} -gt 0 ]; then + (cd "$WT" && ruff check --fix --exit-zero --quiet "${PY[@]}") + if grep -q '^\[tool\.black\]' "$WT/pyproject.toml"; then (cd "$WT" && black -q "${PY[@]}"); fi + git -C "$WT" add -- "${PY[@]}" + git -C "$WT" diff --cached --quiet || git -C "$WT" commit -m "style(<slug>): FEAT-<ID> — full lint pass" + (cd "$WT" && ruff check --output-format concise "${PY[@]}") +fi +``` + +Then fix what `ruff check` still reports, in those files only (pre-existing +violations in a touched file included — that is deliberate code improvement): +- Keep each fix behavior-neutral; when one is not (e.g. narrowing a blind + `except Exception`), run `TASK_FILES=$(jq -r '.tasks[].file' "sdd/tasks/index/<feature-slug>.json"); + python -m scripts.sdd.select_tests --tier feature --base origin/<base_branch> --worktree "$WT" + $(printf -- '--task-file %s ' $TASK_FILES) --run` before committing. +- Commit as `style(<slug>): FEAT-<ID> — lint fixes`. +- A finding you cannot fix safely goes under **Lint residual** in the report. + Residual syntax errors / undefined names (`E9`, `F63`, `F7`, `F82`) are merge + blockers: they turn Step 6 into the "issues" branch. Other residue does not. + +### 4.6. Verify E2E Evidence (FEAT-581) + +Before any closeout mutation — Step 5's report is descriptive, but Step 7's +index stamp, Step 8's push, Step 9's merge-blocker check, Step 9.2's PR/merge +and Step 11's cleanup are not — validate the feature's E2E evidence with the +same **read-only** validator the Codex twin (`.agents/skills/sdd-done/SKILL.md`) +consumes. This step never runs pytest, a target, or the ordinary task test +suite itself; it only checks evidence a prior run already produced (the +dev-loop QA E2E stage, TASK-3542, or a manual `parrot e2e run`). It also never +reads an `e2e-exploration.json` report — exploratory-tier scenarios can never +satisfy or count toward this gate (spec §2/M8: exploration cannot supply gate +`passed`). + +**a) Read the feature's E2E policy** from the spec's frontmatter (`e2e.policy`, +written by `/sdd-spec`; FEAT-581): + +```bash +POLICY=$(python -c " +import sys, yaml +from pathlib import Path +text = Path('<spec-path>').read_text(encoding='utf-8') +front = yaml.safe_load(text.split('---', 2)[1]) or {} +e2e = front.get('e2e') +if e2e is None: + print('optional'); sys.exit(0) # legacy spec, no e2e key at all +if not isinstance(e2e, dict) or e2e.get('policy', 'optional') not in ('required', 'optional', 'none'): + print('invalid'); sys.exit(0) # present but malformed: never coerced +print(e2e.get('policy', 'optional')) +") +PLAN_PATH="$WORKTREE_PATH/sdd/state/${FEAT_ID}/e2e-plan.md" +``` + +A spec with no `e2e` key defaults to `optional`. A *present but malformed* +`policy` value is never silently coerced to a safe default — it is treated +exactly like `required` with missing evidence in step (c) below. + +**b) Validate evidence** (skipped entirely under `none`; skipped with an +advisory note under `optional` when no plan file exists — spec §2: "no plan +means no automatic run for optional specs"): + +```bash +if [[ "$POLICY" != "none" && -f "$PLAN_PATH" ]]; then + E2E_JSON=$(cd "$WORKTREE_PATH" && parrot e2e verify --plan "sdd/state/${FEAT_ID}/e2e-plan.md" 2>/tmp/e2e-verify-${FEAT_ID}.stderr) + E2E_EXIT=$? + # `parrot e2e verify` never executes tests: it validates a previously + # persisted verdict's node coverage, artifact hashes and source identity + # (spec §2 exit codes: 0=PASS, 1=FAIL, 3=BLOCKED, 4=MISSING/stale/tampered). + E2E_STATUS=$(echo "$E2E_JSON" | jq -r '.status // "MISSING"') + E2E_GATE_SATISFIED=$(echo "$E2E_JSON" | jq -r '.gate_satisfied // false') + E2E_REASONS=$(echo "$E2E_JSON" | jq -r '(.reason_codes // ["cli_unavailable_or_non_json_output"]) | join(",")') +fi +``` + +If the `e2e` CLI is unavailable (`ai-parrot-server` not installed) or its +stdout is not valid JSON, treat that exactly like `E2E_STATUS=MISSING`. + +**c) Gate before any mutation:** +- `POLICY=none` → record `E2E: none (exempt)` and continue; no execution is + expected or fabricated. +- `POLICY=optional` and no plan file, or CLI unavailable, or any + `E2E_STATUS` — record the outcome as **advisory only** (`E2E: optional — + <status/no-plan>`) and continue. A failed/blocked/missing optional result + never blocks closeout, but it is reported honestly in Step 5 and Step 12 — + never silently upgraded to a pass. +- `POLICY=required` (or `invalid`) and `E2E_STATUS=PASS` with + `E2E_GATE_SATISFIED=true` → record `E2E: required — PASS` and continue. +- `POLICY=required` (or `invalid`) and anything else (`FAIL`/`BLOCKED`/ + `MISSING`, missing plan, unavailable CLI, or malformed policy metadata) — + **abort the entire command now**, before Step 5's report is even built: + ``` + ⚠️ Required E2E evidence is <E2E_STATUS> for FEAT-<ID> (reason: <E2E_REASONS>). + /sdd-done cannot stamp, push, open a PR, merge or clean up this feature + until `parrot e2e verify --plan sdd/state/<FEAT-ID>/e2e-plan.md` reports + PASS with gate_satisfied: true. + This is NOT bypassed by --force: --force only overrides per-task + partial/missing evidence (Step 6) and ledger merge blockers (Step 9), + never a required E2E gate (spec AC9). + ``` + Exit nonzero. `--dry-run` still shows this refusal and stops the same way + it stops after Step 5 — it never proceeds to a mutating step. + +Carry the recorded `(POLICY, E2E_STATUS, E2E_GATE_SATISFIED, E2E_REASONS)` +tuple into Step 5's report and Step 12's output as an `E2E:` line. + +### 5. Build Verification Report +Classify each task: + +- **✅ VERIFIED** — commit found AND files exist. +- **⚠️ PARTIAL** — commit found but some files missing. +- **❌ NO EVIDENCE** — no matching commits, files don't exist. + +Present the report: +``` +📋 Verification Report: FEAT-<ID> — <title> + +Worktree: .claude/worktrees/feat-<ID>-<slug> +Branch: feat-<ID>-<slug> +Commits found: <N> +Tasks: <total> total, <verified> verified, <partial> partial, <missing> missing +Lint: <autofixed files> auto-fixed, <fixed> hand-fixed, <residual> residual (<blocking> blocking) +E2E: <policy> — <status> (gate_satisfied: <bool>) + + ✅ TASK-096 — Scene Editor Refactor + Commits: feat(videoreel): TASK-096 — Scene Editor Refactor (abc1234) + Files: src/lib/components/SceneEditor.svelte ✅ + + ⚠️ TASK-097 — Visual Transitions + Commits: feat(videoreel): TASK-097 — Visual Transitions (def5678) + Files: src/lib/components/Transitions.svelte ✅ | src/lib/utils/transitions.ts ❌ + + ❌ TASK-098 — Export Pipeline + Commits: none found + Files: src/lib/utils/export.ts ❌ +``` + +### 6. Confirm +If all tasks are ✅ VERIFIED: +``` +All tasks verified. Proceed with closing? (Y/n) +``` + +If any tasks are ⚠️ PARTIAL or ❌ NO EVIDENCE: +``` +<N> task(s) have issues. Options: + 1. Close verified tasks only (mark others as "pending") + 2. Close all with --force (mark partial as "done-with-issues") + 3. Abort — fix issues first +``` + +If `--dry-run`, show the report and STOP. +If `--force`, close all tasks regardless. + +### 7. Close & Stamp Verification (on feature branch) + +Close every task being closed ON THE FEATURE BRANCH, then stamp verification +metadata in the worktree's per-spec index. Normally the implementing lane +(`sdd-worker` / `sdd-start`) already moved the file `active/` → `completed/` +and set `status: "done"`, so this step only adds the `verification` field and +the feature-level `completed_at`. + +> **Verified is not closed.** Some lanes commit code but never close the task +> (the `/sdd-fix` SDD lane, a worker that died before `finalize_task`, code +> written by hand). Such a task shows up here as ✅ VERIFIED while its file is +> still in `active/` and its index entry is still `pending`/`in-progress`; +> stamping only `verification` would merge it into `base_branch` stalled in +> `active/` forever. So a task that is not closed yet is closed here with +> `scripts/sdd/close_task.sh` — **inside the worktree, on the feature branch**. +> Never run it on `base_branch`: that creates duplicate state that conflicts on +> merge (FEAT-414). A task the lane already closed is left untouched, so its +> original `completed_at` is preserved. + +```bash +WORKTREE_PATH="$WORKTREES_DIR/feat-<FEAT-ID>-<slug>" # absolute: valid from the main repo and from inside the worktree +INDEX="sdd/tasks/index/${FEATURE_SLUG}.json" +NOW="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" + +# Close (if the lane did not) and stamp verification on each task being closed. +# Use "verified" for ✅ VERIFIED tasks, "partial" for ⚠️ PARTIAL, "forced" for --force. +for TASK_ID in "${TASK_IDS[@]}"; do + STATUS=$(jq -r --arg id "$TASK_ID" '.tasks[] | select(.id == $id) | .status' "$WORKTREE_PATH/$INDEX") + if [[ -z "$STATUS" ]]; then + echo "⚠️ $TASK_ID is not in $INDEX — skipping (check TASK_IDS)." >&2 + continue + fi + if compgen -G "$WORKTREE_PATH/sdd/tasks/active/${TASK_ID}-*.md" >/dev/null \ + || [[ "$STATUS" != "done" && "$STATUS" != "done-with-issues" ]]; then + # git mv active → completed, index status/completed_at/file, hard-verified. + (cd "$WORKTREE_PATH" && scripts/sdd/close_task.sh "$TASK_ID" "$FEATURE_SLUG" "$VERIFICATION") + # close_task.sh always writes status "done"; keep Step 6's distinction. + if [[ "$VERIFICATION" != "verified" ]]; then + jq --arg id "$TASK_ID" '(.tasks[] | select(.id == $id) | .status) = "done-with-issues"' \ + "$WORKTREE_PATH/$INDEX" > tmp && mv tmp "$WORKTREE_PATH/$INDEX" + fi + fi + jq --arg id "$TASK_ID" --arg ver "$VERIFICATION" ' + (.tasks[] | select(.id == $id) | .verification) = $ver + ' "$WORKTREE_PATH/$INDEX" > tmp && mv tmp "$WORKTREE_PATH/$INDEX" +done + +# Reap any active/ copy of a task that is already done with a completed/ twin +# (e.g. brought back by a base-branch merge). Runs for BOTH the PR flow and +# --merge: the PR flow previously had no sweep at all. +(cd "$WORKTREE_PATH" && scripts/sdd/heal_orphans.sh "$FEATURE_SLUG") + +# Stamp feature-level completed_at if all tasks are done. +jq --arg now "$NOW" ' + if all(.tasks[]; .status == "done") then .completed_at = $now else . end +' "$WORKTREE_PATH/$INDEX" > tmp && mv tmp "$WORKTREE_PATH/$INDEX" + +# Commit on the feature branch (inside the worktree) — never on base_branch. +git -C "$WORKTREE_PATH" add "$INDEX" sdd/tasks/completed/ +git -C "$WORKTREE_PATH" diff --cached --name-only # sanity-check: only the index + this feature's task files +git -C "$WORKTREE_PATH" commit -m "sdd: close tasks for FEAT-<ID> — <slug>" +``` + +### 8. Push the Feature Branch +If the worktree branch hasn't been pushed yet: +```bash +git -C <worktree-path> push origin feat-<FEAT-ID>-<slug> +``` + +### 9. Check Merge Blockers (FEAT-566) + +Before integrating the feature branch, check for critical unacknowledged issues +(blockers) that were discovered by this feature. Issues from other features do +not block this feature's merge. + +```bash +if [[ "$MERGE_FLAG" == "--merge" ]]; then + # `ledger blockers <FEAT-ID>` prints plain text lines (one per blocker) and + # exits 1 when any exist, exit 0 otherwise — it never emits JSON, so the + # gate below checks the EXIT CODE, not the (human-readable) output shape. + BLOCKERS_OUTPUT=$(wikitoolkit ledger blockers "$FEAT_ID" 2>&1) + BLOCKERS_EXIT=$? + if [[ $BLOCKERS_EXIT -ne 0 ]]; then + echo "⚠️ Merge blocked by critical unacknowledged issues:" + echo "$BLOCKERS_OUTPUT" + echo "" + echo "Resolve these issues or acknowledge them as accepted risks before merging." + echo "To acknowledge an issue: wikitoolkit ledger acknowledge <ISSUE-ID> --reason \"...\" --actor human:<name>" + if [[ "$FORCE_FLAG" != "--force" ]]; then + echo "" + echo "Use --force to bypass blocker checks (not recommended)." + exit 1 + else + echo "" + echo "⚠️ Proceeding with --force despite blockers." + fi + fi +fi +``` + +### 9.1. Snapshot Ledger Issues (FEAT-566) + +For feature flows (not hotfixes), regenerate `sdd/ledger/issues.jsonl` from a +throwaway worktree at `origin/<BASE_BRANCH>` and commit/push it directly to +`base_branch` when it changed — never from an active worktree, never on the +feature branch. Bounded retry on a rejected push; never fails `/sdd-done`. + +```bash +if [[ "$TYPE" != "hotfix" ]]; then + git fetch origin "$BASE_BRANCH" >/dev/null 2>&1 + + # Absolute path under the primary checkout: from inside a feature worktree a + # relative path would land inside that worktree, and the sandbox only lets + # worktree agents write to $WORKTREES_DIR outside their own checkout. + TEMP_WORKTREE="$WORKTREES_DIR/_ledger-snapshot-$$" + git worktree add --detach "$TEMP_WORKTREE" "origin/$BASE_BRANCH" >/dev/null 2>&1 + # No --force: the throwaway checkout only ever touches issues.jsonl, so + # restoring that one file leaves it clean and a plain remove succeeds + # (a detached, unpushed snapshot commit does not block `worktree remove`). + cleanup_snapshot_worktree() { + git -C "$TEMP_WORKTREE" restore --staged --worktree -- sdd/ledger/issues.jsonl >/dev/null 2>&1 + git worktree remove "$TEMP_WORKTREE" >/dev/null 2>&1 || true + } + trap cleanup_snapshot_worktree EXIT + + ATTEMPT=1 + MAX_ATTEMPTS=3 + while (( ATTEMPT <= MAX_ATTEMPTS )); do + EXPORT_OUTPUT=$(cd "$TEMP_WORKTREE" && wikitoolkit ledger export 2>&1) + if [[ "$EXPORT_OUTPUT" != *"(changed)"* ]]; then + echo "📝 Ledger snapshot unchanged — nothing to commit." + break + fi + + git -C "$TEMP_WORKTREE" add sdd/ledger/issues.jsonl + git -C "$TEMP_WORKTREE" commit -q -m "sdd: ledger snapshot for $FEAT_ID" + if git -C "$TEMP_WORKTREE" push origin "HEAD:$BASE_BRANCH" >/dev/null 2>&1; then + echo "📝 Ledger snapshot updated with changed issues." + break + fi + + # Rejected push: re-sync the throwaway worktree only, re-export, retry. + git fetch origin "$BASE_BRANCH" >/dev/null 2>&1 + git -C "$TEMP_WORKTREE" restore --staged --worktree -- sdd/ledger/issues.jsonl >/dev/null 2>&1 + git -C "$TEMP_WORKTREE" checkout -q --detach "origin/$BASE_BRANCH" >/dev/null 2>&1 + ATTEMPT=$((ATTEMPT + 1)) + if (( ATTEMPT > MAX_ATTEMPTS )); then + echo "⚠️ Ledger snapshot push failed after $MAX_ATTEMPTS attempts — continuing without failing /sdd-done." + fi + done + + cleanup_snapshot_worktree + trap - EXIT +fi +``` + +### 9.2. Integrate Feature Branch (FEAT-145, flow-aware) + +> **CRITICAL**: This is the step that brings the implementation code into the +> base branch. The default is to open a PR; pass `--merge` to merge directly. + +**Hard refusal — `BASE_BRANCH == "main"`:** + +Hotfixes ALWAYS go through a PR, regardless of flags (including `--merge`): + +```bash +if [[ "$BASE_BRANCH" == "main" ]]; then + cat <<EOF +⚠️ Hotfix merging into 'main' MUST go through a PR. /sdd-done refuses to merge + into main directly, regardless of flags. + + Open the PR manually: + + gh pr create --base main --head feat-<FEAT-ID>-<slug> \\ + --title "<hotfix title>" \\ + --body "<verification summary>" + + After the PR merges, the sync-down.yml Action propagates the change to staging + and dev automatically. If the Action fails or you are offline, re-run with + --sync-down to propagate the change manually: + + /sdd-done <FEAT-ID> --sync-down + +EOF + exit 0 # NOT an error — the hotfix workflow continues outside this command +fi +``` + +**Feature flow (`BASE_BRANCH != "main"`) — default: open a PR:** + +Unless `--merge` is passed, push the feature branch and open a PR against +`BASE_BRANCH`: + +```bash +# Push the feature branch (already done in Step 8, but ensure it's up to date) +git -C <worktree-path> push origin feat-<FEAT-ID>-<slug> + +# Open a PR against the base branch +gh pr create \ + --base "$BASE_BRANCH" \ + --head "feat-<FEAT-ID>-<slug>" \ + --title "feat(<feature-slug>): FEAT-<ID> — <title>" \ + --body "$(cat <<EOF +## Summary + +<Verification report from Step 5 — list of tasks implemented> + +## Tasks + +<N>/<total> tasks verified. + +--- +_Closed by /sdd-done_ +EOF +)" +``` + +If `gh` is not installed or not authenticated, print the manual command: +``` +ℹ️ Could not create PR automatically. Run manually: + + gh pr create --base <BASE_BRANCH> --head feat-<FEAT-ID>-<slug> \ + --title "feat(<feature-slug>): FEAT-<ID> — <title>" \ + --body "<verification summary>" +``` + +**Feature flow with `--merge` — direct merge (old behavior):** + +When `--merge` is explicitly passed, perform a direct merge instead of a PR. + +**Hard refusal — `IN_WORKTREE=1`:** a direct merge needs a writable checkout of +`BASE_BRANCH`, and inside the feature worktree HEAD *is* the feature branch: +the merge below would be a no-op self-merge and `git push origin "$BASE_BRANCH"` +would publish a stale base branch without the feature commits, while the +command still reports success and removes the worktree. Refuse and fall back +to the PR flow above: + +```bash +if [[ "$IN_WORKTREE" == "1" && "$MERGE_FLAG" == "--merge" ]]; then + cat <<EOF +⚠️ --merge is not available from inside the feature worktree (HEAD is the + feature branch, and the primary checkout is read-only here). + Either re-run /sdd-done from the main repo checked out on $BASE_BRANCH, + or drop --merge to open a PR instead. +EOF + exit 1 +fi +``` + +```bash +# We're on $BASE_BRANCH in the main repo (verified in Step 1, IN_WORKTREE=0) +git merge --no-edit feat-<FEAT-ID>-<slug> +``` + +If the merge has conflicts (e.g. code-level changes to the same files): +``` +⚠️ Merge conflict when merging feat-<FEAT-ID>-<slug> into <BASE_BRANCH>. + Resolve conflicts, then continue with: git merge --continue + Or abort: git merge --abort +``` +If the user aborts, STOP and do NOT proceed to cleanup. + +**Self-heal — reap stalled `active/` orphans (runs after every `--merge`):** + +> Only applies when `--merge` is used. In the PR flow the sweep already ran on +> the feature branch in Step 7, and CI's `scripts/sdd/check_task_state.py` +> fails the PR if a twin or a closed-but-active task file still reaches it. + +```bash +scripts/sdd/heal_orphans.sh <feature-slug> +# If it reaped anything, commit the cleanup before pushing: +if ! git diff --cached --quiet -- sdd/tasks/active sdd/tasks/completed; then + git commit -m "sdd: reap stalled active task orphans for FEAT-<ID> — <title>" +fi +``` + +After a successful merge and self-heal, push `<BASE_BRANCH>`: +```bash +git push origin "$BASE_BRANCH" +``` + +### 9.5. Hotfix → Sync-down (FEAT-187, only with `--sync-down`) + +This sub-step runs ONLY when the user passes `--sync-down` (or the deprecated +`--sync-dev` alias) AND `TYPE == "hotfix"`. It propagates a hotfix that has just +been merged into `main` (via the manual PR from §9) back into `staging` and `dev` +so both stay in sync. + +**Hard refusal — `IN_WORKTREE=1`:** the sync-down below runs `git checkout +staging` / `git checkout dev` and merges in the current checkout. Inside a +worktree those branches are checked out elsewhere (the primary checkout) and +`git checkout` fails, so refuse and point at the main repo: + +```bash +if [[ "$IN_WORKTREE" == "1" ]]; then + echo "⚠️ --sync-down must run from the main repo, not inside a worktree: cd to \$MAIN_ROOT and re-run." + exit 1 +fi +``` + +If `--sync-dev` is used instead of `--sync-down`, first emit: +``` +ℹ️ --sync-dev is deprecated; use --sync-down. Continuing with sync-down behaviour. +``` + +In normal operation, `.github/workflows/sync-down.yml` does this automatically +after every push to `main`. Run this command only when the Action has failed or +the user is operating offline. + +**Pre-flight (run once):** verify the hotfix landed on `origin/main`: +```bash +git fetch origin +if ! git merge-base --is-ancestor "feat-<FEAT-ID>-<slug>" origin/main; then + echo "⚠️ feat-<FEAT-ID>-<slug> is not yet an ancestor of origin/main." + echo " Open the PR and merge it first, then re-run with --sync-down." + exit 1 +fi +``` + +**Sync to `staging`** — optimistic auto-merge with safe abort on conflict: +```bash +git checkout staging +git pull --ff-only origin staging + +if git merge --no-edit feat-<FEAT-ID>-<slug>; then + git push origin staging + echo "✅ staging synced with hotfix feat-<FEAT-ID>-<slug>." + STAGING_OK=true +else + git merge --abort + STAGING_OK=false + cat <<EOF +⚠️ Conflict syncing hotfix into staging. The merge has been aborted (no changes left). + + Resolve manually: + git checkout staging + git merge feat-<FEAT-ID>-<slug> + # ...resolve conflicts in your editor... + git commit + git push origin staging + +EOF +fi +``` + +**Sync to `dev`** — optimistic auto-merge with safe abort on conflict +(independent of `staging` outcome — always attempt): +```bash +git checkout dev +git pull --ff-only origin dev + +if git merge --no-edit feat-<FEAT-ID>-<slug>; then + git push origin dev + echo "✅ dev synced with hotfix feat-<FEAT-ID>-<slug>." + DEV_OK=true +else + git merge --abort + DEV_OK=false + cat <<EOF +⚠️ Conflict syncing hotfix into dev. The merge has been aborted (no changes left). + + Resolve manually: + git checkout dev + git merge feat-<FEAT-ID>-<slug> + # ...resolve conflicts in your editor... + git commit + git push origin dev + +EOF +fi +``` + +**Return to base:** leave the user on `main` (the hotfix's base branch): +```bash +git checkout main +``` + +**Summary and exit code:** +```bash +if $STAGING_OK && $DEV_OK; then + echo "✅ Sync-down complete: staging and dev are in sync with main." + exit 0 +else + echo "⚠️ Sync-down partially failed. See above for failed targets." + exit 1 +fi +``` + +### 10. Transition Jira Ticket (if --resolve-jira) + +If `--resolve-jira` is passed AND the spec has a Jira key (set by `/sdd-tojira`): + +**a) Extract the Jira key from the spec:** +```bash +# Look for "**Jira**: [NAV-8036](...)" or a "jira:" metadata field in the spec +JIRA_KEY=$(grep -oP '(?<=\*\*Jira\*\*: \[)[A-Z]+-\d+' sdd/specs/<feature>.spec.md) +# Or from the brainstorm "## Jira Source" table +if [[ -z "$JIRA_KEY" ]]; then + JIRA_KEY=$(grep -oP '(?<=\| Key \| )[A-Z]+-\d+' sdd/proposals/<key>-*.brainstorm.md) +fi +``` + +If no Jira key is found, skip this step with a note: +``` +ℹ️ No Jira key found in spec — skipping Jira transition. + To link a spec to Jira: /sdd-tojira <spec-path> +``` + +**b) Load Jira credentials:** +```bash +eval "$(python -c "from navconfig import config; import os; [print(f'export {k}={v}') for k,v in os.environ.items() if k.startswith('JIRA_')]")" +JIRA_INSTANCE="${JIRA_INSTANCE%/}" +``` + +If `JIRA_INSTANCE` or `JIRA_API_TOKEN` are not set, warn and skip. + +**c) Get available transitions for the ticket:** + +Jira transitions are workflow-dependent — you cannot set a status directly. +First, fetch the available transitions: + +**MCP path:** +``` +jira_transition_issue(issue_key="<JIRA_KEY>") # list available transitions +``` + +**curl fallback:** +```bash +TRANSITIONS=$(curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/issue/$JIRA_KEY/transitions") +echo "$TRANSITIONS" | jq '.transitions[] | {id, name}' +``` + +**d) Find and execute the "Done" / "Resolved" transition:** + +Look for a transition whose name matches (case-insensitive): +`Done`, `Resolved`, `Close`, `Ready for UAT`, `Complete`. + +```bash +# Find the transition ID +TRANSITION_ID=$(echo "$TRANSITIONS" | jq -r ' + .transitions[] | + select(.name | test("(?i)done|resolved|close|complete|ready for uat")) | + .id' | head -1) +``` + +If found, execute it: + +**MCP path:** +``` +jira_transition_issue(issue_key="<JIRA_KEY>", transition_id="<TRANSITION_ID>") +``` + +**curl fallback:** +```bash +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$JIRA_INSTANCE/rest/api/3/issue/$JIRA_KEY/transitions" \ + -d "{\"transition\": {\"id\": \"$TRANSITION_ID\"}}" +``` + +If multiple matching transitions exist, prefer in this order: +1. "Done" +2. "Resolved" +3. "Ready for UAT" +4. "Complete" +5. "Close" + +If no matching transition is found: +``` +⚠️ No "Done" or "Resolved" transition available for <JIRA_KEY>. + Current status: <current_status> + Available transitions: <list> + You may need to transition it manually in Jira. +``` + +**e) Optionally resolve subtasks too:** + +If the ticket has subtasks (created by `--with-subtasks` in `/sdd-tojira`), +transition each one that is still open: +```bash +SUBTASKS=$(curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/issue/$JIRA_KEY?fields=subtasks" \ + | jq -r '.fields.subtasks[].key') + +for SUBTASK in $SUBTASKS; do + # Get transitions for this subtask, find "Done", execute + # Same logic as above +done +``` + +### 11. Cleanup the Worktree +```bash +git worktree remove "$WORKTREES_DIR/feat-<FEAT-ID>-<slug>" +``` +This also works with `IN_WORKTREE=1` (git allows removing the current +worktree, and the sandbox binds `$WORKTREES_DIR` writable), but the shell's +cwd disappears with it — make this the LAST filesystem step and run any +remaining git command as `git -C "$MAIN_ROOT" ...`. + +If there are uncommitted changes in the worktree, warn: +``` +⚠️ Worktree has uncommitted changes. Force remove? (y/N) +``` + +If the worktree was already removed, prune stale metadata: +```bash +git -C "$MAIN_ROOT" worktree prune +``` + +Optionally delete the local feature branch (it's been merged): +```bash +git -C "$MAIN_ROOT" branch -d feat-<FEAT-ID>-<slug> +``` + +### 12. Output + +**Default (PR flow):** +``` +✅ FEAT-<ID> — <title>: <N>/<total> tasks closed. + +Closed: + ✅ TASK-096 — Scene Editor Refactor (verified) + ✅ TASK-097 — Visual Transitions (verified) + +Index updated and committed. +Branch pushed: feat-<ID>-<slug> +PR opened: feat-<ID>-<slug> → <BASE_BRANCH> <PR-URL> +E2E: <policy> — <status> +Worktree removed: .claude/worktrees/feat-<ID>-<slug> +Local branch deleted: feat-<ID>-<slug> +``` + +**With `--merge`:** +``` +✅ FEAT-<ID> — <title>: <N>/<total> tasks closed. + +Closed: + ✅ TASK-096 — Scene Editor Refactor (verified) + ✅ TASK-097 — Visual Transitions (verified) + +Index updated and committed. +Branch pushed: feat-<ID>-<slug> +Merged into <BASE_BRANCH>: feat-<ID>-<slug> ✅ +E2E: <policy> — <status> +Worktree removed: .claude/worktrees/feat-<ID>-<slug> +Local branch deleted: feat-<ID>-<slug> +``` + +If `--resolve-jira` was used and succeeded: +``` +Jira: NAV-8036 → Done ✅ + Subtasks transitioned: 4/4 +``` + +If ALL tasks were closed: +``` +✅ FEAT-<ID> — <title>: all <N> tasks closed. + +Branch pushed. PR opened → <BASE_BRANCH>. +{if --merge} Merged into <BASE_BRANCH> directly. {end if} +Worktree cleaned up. Feature branch deleted. +{if --resolve-jira} Jira NAV-8036 → Done ✅ {end if} +``` + +## Reference +- Per-spec index files: `sdd/tasks/index/<feature>.json` (on `<base_branch>`) +- Active tasks: `sdd/tasks/active/` (on `<base_branch>`) +- Completed tasks: `sdd/tasks/completed/` (on `<base_branch>`) +- Frontmatter parser: `scripts/sdd/sdd_meta.py` +- SDD methodology: `sdd/WORKFLOW.md` +- E2E plan (FEAT-581): `sdd/state/<FEAT-ID>/e2e-plan.md` (in the worktree); + validator: `parrot e2e verify --plan <path>` (`ai-parrot-server`, optional + install) diff --git a/.claude/commands/sdd-explain.md b/.claude/commands/sdd-explain.md new file mode 100644 index 00000000..f8bda7b2 --- /dev/null +++ b/.claude/commands/sdd-explain.md @@ -0,0 +1,105 @@ +--- +description: Explain how a subsystem, component, or symbol works in the current project, grounded in the real codebase. Default mode produces an architecture walkthrough for onboarding; pass --deep for a code-level implementation trace. +argument-hint: "[--deep] <subsystem | component | symbol | question>" +allowed-tools: Read, Grep, Glob, Bash(rg:*), Bash(fd:*), Bash(git grep:*), Bash(ls:*) +--- + +# Role + +You are a senior architect acting as a code-grounded explainer for the current project. Your job is to make a developer understand *how this codebase actually works* — not to teach generic CS concepts and not to fix anything. You explain the system as it is implemented, in this monorepo, right now. + +You optimize for **accurate mental models of the real code**: where a thing lives, what contract it honors, how data and control flow through it, and which invariants must not be broken. You do not optimize for speed, unblocking, or generic pedagogy. + +# Hard rules (anti-hallucination) + +These are non-negotiable. Complex codebases use patterns (registries, mixins, ABCs) that are easy to misremember; a confident wrong explanation is worse than a slower correct one. + +- **Read before you explain.** Never describe an import path, class, method, registry, decorator, or Pydantic model you have not located and read in this session. If you assert that `X` exists, you must have seen `X`. +- **Cite with grep anchors, never line numbers.** Reference symbols by name and file path, so the explanation survives edits. Line numbers go stale. +- **State your evidence.** Briefly say what you actually read (files / symbols). If you could not find something the user assumes exists, say so explicitly instead of inventing it. +- **Mark uncertainty.** If behavior depends on code you did not open (a dynamic dispatch, a config, an external package), say "not verified here" rather than guessing. +- **No fixes, no refactors, no code generation** unless the user explicitly asks in a follow-up. This command explains; it does not change anything. + +# Repository orientation (hints, not ground truth) + +Use these as *search starting points*. The codebase is the only authority — verify every claim against what you read. Read CLAUDE.md and any CONTEXT.md files to discover the project's package structure, core abstractions, and naming conventions before explaining anything. + +Recurring patterns to recognize and surface when relevant (discover the specific ones from the codebase): +- **Decorator-registered typed registries**: `@register_*` patterns, typed dispatchers. +- **ABCs as extension seams**: `Abstract*` base classes, protocol interfaces. +- **Mixins for cross-cutting concerns**: mixins that compose behavior into classes. +- **Framework-specific invariants**: constraints that must not be broken (discover from code and docs). + +# Argument handling + +Parse `$ARGUMENTS`: + +1. **Mode flag.** If `$ARGUMENTS` contains `--deep` (or `-d`), run **Implementation Trace** mode. Strip the flag; the remainder is the target. Otherwise run **Subsystem Map** mode (default). +2. **Target.** The remaining text is the subsystem, component, symbol, or question to explain. +3. **No target given.** Use the current conversation context as the topic. If there is no clear topic in context, ask the user what to explain — do not invent one. + +Before producing output in either mode, **locate the relevant code** with Glob/Grep/`rg` across the workspace packages. Resolve the real symbols and files first; explanation comes after evidence. + +--- + +# Mode: Subsystem Map (default — onboarding + architecture) + +For a developer who needs to understand a whole subsystem and how it fits the system. Produce, in order: + +### 1. Locate +Which package(s) and entry points this subsystem lives in, with grep anchors. One line on what you actually read to ground the rest. + +### 2. What it is +2–3 short paragraphs: the subsystem's single responsibility, where it sits relative to bots / clients / tools / flows, and the problem it exists to solve. + +### 3. The cast +The concrete ABCs, registries, mixins, and managers that make it up, each with its role in one line. This is where you teach the *fractal patterns* (e.g. "this is a `@register_*` typed registry; new kinds are added by decorating, not by editing a switch"). + +### 4. Data & control flow +How a request moves through the subsystem end to end, and the contracts at each boundary (Pydantic models, `List[Document]`, protocol signatures). Include **one** compact mental model — an ASCII or Mermaid sketch, or a numbered "the flow is: 1)… 2)…". Keep it minimal and accurate. + +### 5. Conventions & invariants +The rules a new contributor must not break here (the architectural invariants above, if they apply, plus any subsystem-specific ones you found in the code). Be specific about *why* each holds. + +### 6. Where to look next +The exact files to open to go deeper, and the adjacent subsystems this one couples to. If the user likely wants implementation detail, tell them to re-run with `--deep <symbol>`. + +--- + +# Mode: Implementation Trace (`--deep`) + +For a developer who needs to understand exactly how a specific symbol or flow is implemented. Produce, in order: + +### 1. Target resolution +The exact symbol(s) and file(s) resolved, as grep anchors. State what you read. + +### 2. Execution trace +Walk the real implementation call by call: registry dispatch, decorator wiring, async boundaries (`await`, gather, task spawning), `ContextVar` propagation, mixin method resolution order where it matters. Reference real symbols at each step. No paraphrase of code you did not open. + +### 3. Contracts & types +The actual Pydantic models / protocol / ABC signatures at the boundaries, including what is required vs optional and what validation happens. + +### 4. Edge cases & failure modes +From the actual code: error handling, retries, timeouts, fallbacks, and what happens when an invariant is violated. If the code does not handle a case the user might assume, say so. + +### 5. Coupling map +What this symbol depends on and what depends on it (callers, registries it registers into, events it emits/consumes). + +### 6. Gotchas +Non-obvious behavior — where the implementation diverges from the naive mental model, ordering subtleties, anything that has bitten or could bite a contributor. + +--- + +# Tone + +Direct, structured, technical. No motivational filler, no "as an AI", no emojis. Match the reader's expertise (assume a competent Python/async developer). Concise but complete; depth comes from accuracy and grounding, not from length. + +# Success criterion + +The developer should finish thinking: *"I now understand how this part of the codebase actually works and could navigate or extend it."* — grounded in real symbols they can open, not a plausible-sounding sketch. + +--- + +# Target + +$ARGUMENTS diff --git a/.claude/commands/sdd-fix.md b/.claude/commands/sdd-fix.md new file mode 100644 index 00000000..15a93f0f --- /dev/null +++ b/.claude/commands/sdd-fix.md @@ -0,0 +1,62 @@ +# /sdd-fix — Ledger-Driven Fix Lane + +Drain the SDD work ledger: plan a severity-ordered, file-grouped batch with +`wikitoolkit ledger plan-fix --json`, claim the selected issues, route each group to the +Fast lane (branch → PR) or the SDD lane (spec → tasks → worktree), close by evidence and +release the rest. The twins never re-implement ordering, grouping or lane rules — the plan is +the contract (`planner_version` is checked before use). + +**Read-only ledger**: the ledger resolves to the main checkout. If a sandbox makes it read-only, +report `shared ledger is read-only`, do not request broader filesystem access, do not create a +worktree-local ledger, and exit non-zero WITHOUT claiming anything. + +## Usage +/sdd-fix # interactive picker, severity-ordered +/sdd-fix <issue-id> # non-interactive: that issue's whole group +/sdd-fix --top N # non-interactive: first N groups +/sdd-fix ... --kind K --severity S --lane fast|sdd # --lane fast is REFUSED (exit 1) for critical or vulnerability groups + +## Steps +1. **Plan** — `wikitoolkit ledger plan-fix --json` (pass `--kind/--severity/--lane` through). Parse stdout as a FixPlan. + Require a successful exit and `planner_version == "1"` before selecting or claiming anything. + Reject missing/unsupported versions or malformed output; never re-implement ordering, grouping or lane rules. + `--lane fast` is refused for critical or vulnerability groups; stop on that planner error. + The plan presents severity-ordered groups with metadata: group_id, max_severity, lane assignment with reason, + suggested_slug, and the list of issues/files per group. In interactive mode, render a table showing each group's + key attributes for selection. +2. **Select** — picker, or deterministic resolution from the arguments (issue-id → its whole group; --top N → first N groups). + Require a positive N. An empty plan or an issue absent from it is a no-op with an explanation, never a guessed selection. +3. **Claim** — one `wikitoolkit ledger claim <issue-id> --actor agent:sdd-fix` per issue in the group, immediately before + executing. The plan is a snapshot; the claim is authoritative: a non-zero exit DROPS that issue and continues. Never close an + issue merely because an earlier plan listed it. Skip groups with no successful claims; preserve the planned lane for remaining issues. +4. **Prime** — `wikitoolkit ledger context <group.files…> --max-tokens 3000` (full repo-relative paths from the plan, never basenames). +5. **Route** by `group.lane`: + - Before changing branches, require a clean checkout; never carry unrelated changes into a fix. + If execution cannot proceed, release successful claims in step 7. + - **Fast lane** (`lane == "fast"`): in the MAIN checkout (no worktree), `git fetch origin dev && git switch -c fix/<issue-short-id>-<suggested_slug> origin/dev`, + edit, run the affected package's tests, commit, `git push -u origin <branch>`, then `gh pr create --base dev --title … --body …`. + ALWAYS a PR — there is no direct-push path. + - **SDD lane** (`lane == "sdd"`): if any `group.parents[].open` is true, reuse that parent spec (`/sdd-task <spec> --from-issue …` per issue); + otherwise `python -m scripts.sdd.reserve_ids --kind feature --count 1 --base-branch dev --label <suggested_slug>`, author + `sdd/specs/<suggested_slug>.spec.md` from the group, `/sdd-task`, then + `python -m scripts.sdd.ensure_worktree --slug <suggested_slug> --feature-id FEAT-<NNN>`. `/sdd-done <FEAT-ID>` closes it out unchanged. + For a new spec, the slug comes from `suggested_slug`; never re-derive it. For a reused parent, + use that parent index's existing slug, feature_id, spec and base_branch when provisioning the worktree. + Task creation alone is not resolution: implement and validate the task in that worktree before step 6. +6. **Close** — two keys, fail-closed: an issue closes only if the implementing agent asserts its id as resolved AND at least one of + its `files` appears in `git diff --name-only <base>...HEAD`. Then `wikitoolkit ledger close <issue-id> --reason "<what changed>" + --actor agent:sdd-fix --resolved-by commit:<sha>` (fast, the implementing commit on the pushed PR branch) + or `--resolved-by task:TASK-<NNN>` (SDD, a completed and validated task). + Capture `<base>` before implementation and check the diff from the implementing checkout. + Do not infer a merge or fabricate a merge SHA merely because a PR was opened. +7. **Release** — every claimed issue that failed either key: `wikitoolkit ledger unclaim <issue-id> --reason "<why>" --actor agent:sdd-fix`. + Run release on every exit path, including test/push/PR failures or interrupted execution. + If unclaim itself fails, report the issue ID and reason for recovery; do not claim success. + Never leave an issue claimed silently; acknowledgement stays human-only and out of scope here. + +## Output +Per group processed: +- Lane used (Fast/SDD) +- Branch/PR URL (Fast) or FEAT-ID + worktree path (SDD) +- Issues closed with their resolved_by evidence +- Issues released with reason diff --git a/.claude/commands/sdd-fromjira.md b/.claude/commands/sdd-fromjira.md new file mode 100644 index 00000000..5b35307d --- /dev/null +++ b/.claude/commands/sdd-fromjira.md @@ -0,0 +1,353 @@ +--- +description: Bootstrap an SDD Brainstorm from a Jira ticket. Fetches requirements, conducts interactive Q&A, researches the codebase, and produces a worker-ready brainstorm document. +--- + +# /sdd-fromjira — Bootstrap Brainstorm from Jira + +Fetch requirements from a Jira ticket and scaffold a structured brainstorm document +in `sdd/proposals/`, following the full `/sdd-brainstorm` quality bar. + +This command is the Jira-seeded entry point to the SDD pipeline: +``` +/sdd-fromjira <JIRA_KEY> → (Q&A) → brainstorm → /sdd-spec → /sdd-task → implement +``` + +## Usage +``` +/sdd-fromjira NAV-8036 +/sdd-fromjira NAV-8036 --complexity=fix # minimal Q&A, straight to brainstorm +/sdd-fromjira NAV-8036 --skip-qa # use Jira description as-is (rare) +``` + +## Guardrails +- Do NOT modify the Jira ticket — only READ from it. +- Always use the template at `sdd/templates/brainstorm.md`. +- Output file: `sdd/proposals/<issue-key>-<slug>.brainstorm.md`. +- **Always commit the brainstorm file** so other commands and worktrees can see it. +- **No implementation code** — this is about ideas, references, and tradeoffs. +- Include concrete library/package references with versions when possible. +- Reference existing codebase modules that would be reused or extended. + +## Jira Access Strategy + +Use **mcp-atlassian** if available, falling back to **curl** if not. + +### Detect available method +```bash +# Check if mcp-atlassian tools are available +# If jira_get_issue tool exists → use MCP +# Otherwise → use curl with env vars +``` + +### MCP path (preferred) +``` +jira_get_issue(issue_key="NAV-8036") +``` + +### curl fallback +```bash +# Requires env vars loaded via navconfig (env/.env): +# JIRA_INSTANCE — e.g. https://trocglobal.atlassian.net/ +# JIRA_USERNAME — email for Jira Cloud +# JIRA_API_TOKEN — API token (Personal Access Token) +# +# Load them: +# eval "$(python -c "from navconfig import config; import os; [print(f'export {k}={v}') for k,v in os.environ.items() if k.startswith('JIRA_')]")" + +JIRA_INSTANCE="${JIRA_INSTANCE%/}" +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + "$JIRA_INSTANCE/rest/api/3/issue/$JIRA_KEY?expand=renderedFields&fields=summary,description,status,priority,issuetype,labels,components,subtasks,issuelinks,customfield_10021,customfield_10022,customfield_10035" +``` + +## Steps + +### 1. Fetch Jira Ticket + +Retrieve the ticket using the detected access method. Extract: + +| Field | Source | Purpose | +|-------|--------|---------| +| Summary | `fields.summary` | Brainstorm title | +| Description | `renderedFields.description` (HTML) | Problem statement seed | +| Acceptance Criteria | `customfield_10021` / `10022` / `10035` | Constraints & success criteria | +| Issue Type | `fields.issuetype.name` | Complexity hint | +| Priority | `fields.priority.name` | Urgency context | +| Components | `fields.components[].name` | Affected areas | +| Labels | `fields.labels` | Category hints | +| Subtasks | `fields.subtasks[].key` | Scope indicator | +| Links | `fields.issuelinks` | Related work | + +If the ticket is not found or inaccessible, notify the user with: +- Verification that the key format is correct +- Check for `JIRA_INSTANCE` / `JIRA_API_TOKEN` env vars (curl path) +- Check that mcp-atlassian is configured (MCP path) + +### 2. Parse Jira Content + +Convert the raw Jira data into structured context: + +**Description rendering**: Jira Cloud v3 returns ADF (Atlassian Document Format). +Use `renderedFields.description` which returns HTML, then strip tags: +```bash +# Basic HTML → text conversion +echo "$RENDERED_DESC" | sed 's/<[^>]*>//g' | sed '/^$/d' +``` + +**Acceptance criteria extraction**: Try the common custom fields in order: +```bash +for FIELD in customfield_10021 customfield_10022 customfield_10035; do + AC=$(echo "$JIRA_RESPONSE" | jq -r ".fields.$FIELD // empty") + if [[ -n "$AC" ]]; then break; fi +done +# Fallback: extract from description (look for "Acceptance Criteria" heading) +``` + +**Extract structured context:** +- **Problem Statement**: What is the primary pain point described? +- **Constraints / Requirements**: Technical constraints or business rules from the description. +- **Acceptance Criteria**: Numbered list from the AC field (or extracted from description). +- **Context**: Existing systems, related tickets, background mentioned. +- **Scope Indicators**: Component names, labels, linked tickets, subtask count. + +### 3. Classify Complexity + +Assess the ticket complexity to calibrate Q&A depth: + +| Signal | Complexity | Q&A Rounds | +|--------|-----------|------------| +| Bug type, 1 component, clear AC, no subtasks | `fix` | 1 | +| Story type, 2-3 components, some AC | `simple` | 2 | +| Story/Epic, multiple components, vague or absent AC | `standard` | 2-3 | +| Epic, cross-cutting, architectural, many subtasks | `complex` | 3+ | + +The `--complexity` flag overrides auto-detection. +The `--skip-qa` flag skips Q&A entirely (use only when Jira description is exhaustive). + +### 4. Present Jira Context to User + +Before asking questions, present the extracted context: + +``` +📋 Jira Ticket: NAV-8036 + + Summary: Add OAuth 2.0 support for JiraToolkit + Type: Story | Priority: High + Components: Nav-AI, Backend + Labels: security, oauth + + Description (extracted): + <parsed description, truncated to ~500 chars> + + Acceptance Criteria: + 1. User can authenticate via OAuth 2.0 + 2. Tokens are stored securely in Redis + 3. Token refresh happens automatically + + Complexity assessment: standard (2-3 Q&A rounds) +``` + +### 5. Interactive Discovery (Mandatory — adapted to complexity) + +**DO NOT skip this step** unless `--skip-qa` is explicitly passed. + +The Jira ticket provides initial context but it is never complete enough for +implementation. Use the ticket content to ask TARGETED questions — not generic +ones. The questions should fill gaps that the ticket leaves open. + +**For `fix` complexity (1 round):** +Ask 2-3 targeted questions: +- Root cause hypothesis — does the user already know what's broken? +- Affected files — which modules should be touched? +- Regression risk — could this fix break something else? + +**For `simple` / `standard` complexity (2+ rounds):** + +**Round 1 — Clarify intent beyond what Jira says:** +Ask 3-5 questions about: +- Ambiguities in the Jira description (what does "X" mean exactly?) +- Integration points not mentioned in the ticket +- Non-obvious constraints (performance, backwards compatibility, security) +- What success looks like beyond the AC + +Wait for the user's answers before proceeding. + +**Round 2 — Drill into gaps and tradeoffs:** +Based on Round 1 answers, ask 3-5 follow-up questions about: +- Edge cases and failure scenarios +- Priority between competing concerns +- Assumptions you're making that need validation +- Relationships with linked/subtask tickets + +Wait for the user's answers before proceeding. + +**For `complex` complexity (3+ rounds):** +Additional rounds for: +- Architectural tradeoff discussion +- Scope negotiation (suggest breaking into multiple tickets if needed) +- Cross-team impact assessment + +**Rule:** Only proceed to codebase research once you are confident that +all core questions have been answered. If a question is critical and unanswered, +ask — do not assume. + +### 6. Research the Codebase & Build Code Context + +Scan the project for relevant existing components: +- Search for related modules, classes, and patterns. +- Identify reusable code that any solution should build on. +- Note existing dependencies that could be leveraged. +- Use the Jira ticket's **components** and **labels** as search hints. + +**CRITICAL — Code Context Capture:** +This is where hallucinations are prevented. For every class, method, or module +you reference in the brainstorm, you MUST: + +1. **Read the actual source file** and record exact signatures (class name, method names, + parameter types, return types) with file path and line numbers. +2. **Verify imports** — confirm all referenced imports actually resolve by checking + `__init__.py` files and module structure. +3. **Capture user-provided code** — if the user pasted code snippets during discovery + (Steps 4-5), preserve them verbatim in the Code Context section. +4. **Note what does NOT exist** — if you searched for something plausible that turned + out not to exist, record it in the "Does NOT Exist" subsection. This is critical + for preventing downstream agents from hallucinating these references. + +### 7. Generate Options + +Produce **at least 3** distinct approaches. For each option: +- Descriptive name and explanation (WHAT, not HOW). +- Pros and cons (be honest about tradeoffs). +- Effort estimate (Low / Medium / High). +- **Libraries / Tools**: table of packages with purpose and notes. +- **Existing Code to Reuse**: specific paths and descriptions. + +Include at least one unconventional or less obvious approach. + +**Jira-specific enrichment**: Map each option against the Jira acceptance criteria. +If an option doesn't cover all AC, note which ones it misses and why. + +### 8. Recommend + +Select one option and explain the reasoning: +- Reference specific tradeoffs from the options. +- Explain what you're trading off and why it's acceptable. +- Confirm that all Jira AC are covered by the recommendation. + +### 9. Describe the Feature + +Write a detailed feature description based on the recommended option: +- **User-facing behavior**: what the user sees/experiences. +- **Internal behavior**: high-level flow and responsibilities (no code). +- **Edge cases & error handling**: boundary conditions, failure modes. + +### 10. Map to SDD Structures + +Fill in the remaining template sections: +- **Capabilities**: new and modified (kebab-case identifiers). +- **Impact & Integration**: affected components table. +- **Open Questions**: unresolved items with owners. + +### 11. Parallelism Assessment + +Evaluate the feature's decomposition potential for parallel development: +- **Internal parallelism**: Can this feature's tasks be split into independent worktrees? +- **Cross-feature independence**: Does this feature conflict with any in-flight specs? +- **Recommended isolation**: `per-spec` or `mixed`. +- **Rationale**: Brief explanation. + +### 12. Save and Commit + +1. Read the template at `sdd/templates/brainstorm.md`. +2. Create `sdd/proposals/<issue-key>-<slug>.brainstorm.md` with today's date. +3. Add Jira metadata block at the top, **including the FEAT-145 flow-type + fields**: + ```markdown + --- + # FEAT-145 flow-type fields. Default to feature/dev; flip to hotfix/main + # if the Jira issue is tagged as a hotfix (Type=Bug + Priority=Highest, + # or component matches a hotfix label your team uses). + type: feature + base_branch: dev + projects: [ai-parrot-tools] # FEAT-576 — parts of the codebase (see /sdd-brainstorm §10 mapping) + tags: [jira, oauth] # FEAT-576 — 2–6 kebab-case keywords + jira: NAV-8036 + jira_summary: "Add OAuth 2.0 support for JiraToolkit" + jira_type: Story + jira_priority: High + jira_components: [Nav-AI, Backend] + complexity: standard + status: exploration + --- + ``` + Validation rule: `type: hotfix` REQUIRES `base_branch: main`. The user + can adjust the values before running `/sdd-spec`. + Fill projects/tags per /sdd-brainstorm §10 (FEAT-576) — Jira components are NOT projects. +4. Set `Status: exploration`. +5. **Commit:** + ```bash + # Unstage everything first — NEVER commit unrelated changes + git reset HEAD + # Stage ONLY the brainstorm file — NEVER use "git add ." or "git add -A" + git add sdd/proposals/<issue-key>-<slug>.brainstorm.md + # Verify ONLY the brainstorm file is staged + git diff --cached --name-only + # If ANY unrelated files appear, run "git reset HEAD" and start over + git commit -m "sdd: add brainstorm from Jira <issue-key> — <slug>" + ``` + +### 13. Output + +``` +✅ Brainstorm bootstrapped from Jira and committed: + sdd/proposals/<issue-key>-<slug>.brainstorm.md + + Jira: <issue-key> — <summary> + Complexity: <fix|simple|standard|complex> + Recommended: Option <X> — <name> + Effort: <Low|Medium|High> + AC coverage: <met>/<total> criteria addressed + Worktree isolation: <per-spec|mixed> + Open questions: <count> + Projects: <list> Tags: <list> + +Next steps: + 1. Review the generated brainstorm options. + 2. Refine the recommendation. + 3. When ready: /sdd-spec <issue-key>-<slug> (uses this brainstorm as input) +``` + +## How sdd-spec Consumes This Document + +When `/sdd-spec` is invoked with a feature name matching a `<issue-key>-*.brainstorm.md`: +- **Jira metadata** → Spec metadata (jira key, components) +- **Problem Statement** → Spec Section 1 (Motivation & Business Requirements) +- **Acceptance Criteria** → Spec Section 5 (Acceptance Criteria) — carried from Jira +- **Constraints** → Spec Section 5 (additional criteria) +- **Recommended Option** → Spec Section 2 (Architectural Design) +- **Libraries / Tools** → Spec Section 7 (External Dependencies) +- **Feature Description** → Spec Section 2 (Overview + Integration Points) +- **Capabilities** → Spec Section 3 (Module Breakdown) +- **Impact & Integration** → Spec Section 2 (Integration Points) +- **Code Context** → Spec Section 6 (Codebase Contract) — **carries forward verified code** +- **Parallelism Assessment** → Spec Worktree Strategy section +- **Open Questions** → Spec Section 8 + +## Differences from /sdd-brainstorm + +| Aspect | /sdd-brainstorm | /sdd-fromjira | +|--------|-----------------|---------------| +| Input source | Free-form user notes | Jira ticket (structured) | +| Problem statement | User describes it | Extracted from Jira description | +| Acceptance criteria | Discovered during Q&A | Imported from Jira AC field | +| Q&A focus | Open exploration | Gap-filling (Jira provides baseline) | +| Filename | `<slug>.brainstorm.md` | `<issue-key>-<slug>.brainstorm.md` | +| Metadata | Minimal | Includes Jira key, type, priority, components | +| Complexity hint | None | Auto-detected from ticket signals | + +## Reference +- Brainstorm template: `sdd/templates/brainstorm.md` +- Spec template: `sdd/templates/spec.md` +- SDD methodology: `sdd/WORKFLOW.md` +- Worktree policy: `CLAUDE.md` (section "Worktree Policy") +- MCP Jira tool: `mcp_mcp-atlassian_jira_get_issue` (if available) diff --git a/.claude/commands/sdd-insight.md b/.claude/commands/sdd-insight.md new file mode 100644 index 00000000..f15f6639 --- /dev/null +++ b/.claude/commands/sdd-insight.md @@ -0,0 +1,120 @@ +--- +name: sdd-insight +description: Analyze how you collaborate with Claude Code AND how this repo follows its Spec-Driven Development workflow. Produces a two-layer HTML report — a personal AI-fluency skill map (score, archetype, the four competencies, five dimensions, tailored growth levers) plus a repo-level SDD Process Discipline panel (pipeline, decomposition, acceptance criteria, cycle closure, review coverage), computed from your transcripts and the sdd/ artifact tree. Use when asked to analyze AI fluency, prompting style, builder profile, or SDD process health, or when run as /sdd-insight. +argument-hint: "[TRANSCRIPT_PATH | --no-open]" +allowed-tools: Bash(python3 *), Bash(source *), Read, Write, Workflow +--- + +# SDD Insight — one command, two layers + +You produce a two-layer report for this developer and this repository, in one run: + +1. **Measure (deterministic).** `scripts/sdd/insight.py` parses Claude Code transcripts, + de-contaminates and scrubs them, computes the personal AI-fluency numbers (Layer 1), + AND scores the repo's SDD workflow adherence from the `sdd/` artifact tree (Layer 2). +2. **Explore (Sonnet 4.6).** Parallel explorers read the evidence — one per AI-fluency + competency, plus one SDD-process auditor. +3. **Analyze (Opus 4.8).** A senior assessor writes the personal skill map AND a grounded + read of the repo's SDD process, then verifies both against the evidence. + +Working files live under `~/.claude/sdd-insight/`. The engine, framework and workflow are +internal to this repo (`scripts/sdd/`, `reference/sdd-insight/`, `.claude/workflows/`). + +> **Always activate the venv first** (project rule): `source .venv/bin/activate`. +> The engine is pure stdlib, but follow the rule for consistency. + +## Transcript source and arguments + +The engine accepts Claude Code JSONL transcripts, defaulting to `~/.claude/projects`. +Native Codex session logs are not supported; do not attribute these personal scores +to Codex sessions. Pass the optional transcript path and supported flags to both +measurement and rendering, preserving paths with spaces as single arguments. +If no compatible transcripts are found, report the limitation and stop without +inventing scores. The deterministic fallback applies when evidence exists but +Workflow is unavailable. + +## Step 1 — Measure + emit evidence + +Working files live at fixed, reused paths, so first delete leftovers from a previous run so a +stale `analysis.json` can never be merged as if it belonged to this run: + +```bash +source .venv/bin/activate +rm -f ~/.claude/sdd-insight/evidence.json ~/.claude/sdd-insight/analysis.json +mkdir -p ~/.claude/sdd-insight +``` + +Then measure (use `--quiet` so no score is surfaced before the final report). Run from the +repo root so `--sdd-dir sdd` resolves to this repo's artifact tree: + +```bash +python3 scripts/sdd/insight.py \ + --evidence ~/.claude/sdd-insight/evidence.json \ + --sdd-dir sdd --no-open --quiet \ + -o ~/.claude/sdd-insight/sdd_insight_report.html $ARGUMENTS +``` + +This writes the evidence bundle (carrying both the personal signals and the repo `sdd` block) +and a deterministic fallback report. **Do not report any score yet** — continue to Steps 2–3. +If it reports no transcripts, tell the user to pass their transcript directory as `$ARGUMENTS` +(default `~/.claude/projects`). + +## Step 2 — Run the two-layer analysis workflow + +Print the absolute paths the workflow needs (it reads them with its own Read tool): + +```bash +python3 -c "import os; print(os.path.expanduser('~/.claude/sdd-insight/evidence.json')); print(os.path.abspath('reference/sdd-insight/ai-fluency-framework.md'))" +``` + +Then call the **Workflow** tool with: +- `name`: `sdd-insight` +- `args`: `{ "evidence": "<first line above>", "framework": "<second line above>" }` + +The workflow returns the analysis JSON (overall_read, skill_map of the four competencies, +top_growth, strengths, and `sdd_read` — the repo SDD process narrative). Sonnet 4.6 explores, +Opus 4.8 analyzes + verifies — model selection is baked into the workflow. + +## Step 3 — Render the final report + +Only if Step 2 returned an analysis. Write the returned JSON to +`~/.claude/sdd-insight/analysis.json`, then merge it — passing the evidence bundle it was built +from so the engine confirms the analysis belongs to this exact run: + +```bash +python3 scripts/sdd/insight.py \ + --analysis ~/.claude/sdd-insight/analysis.json \ + --analysis-evidence ~/.claude/sdd-insight/evidence.json \ + --sdd-dir sdd \ + -o ~/.claude/sdd-insight/sdd_insight_report.html $ARGUMENTS +``` + +The engine fingerprints this run and compares it to the evidence bundle's `run_fingerprint`; +on mismatch it prints a note and renders the deterministic report instead. On success the +report carries Opus's tailored skill map, growth levers, AND the SDD process read on top of the +deterministic numbers and the SDD Process Discipline panel. Point the user to +`~/.claude/sdd-insight/sdd_insight_report.html`. + +## Step 4 — Narrate (don't re-derive) + +Only after the final report exists, give a short read in chat: the **personal score + band + +archetype** in one sentence, the **single highest-leverage personal growth move**, and the +**repo SDD Process Discipline score + band** with its weakest dimension (e.g. review coverage). +Keep it to a paragraph or two; the report has the depth. + +## Fallbacks + +- **No Workflow capability?** The Step-1 deterministic report is complete on its own (it + already includes the SDD panel) — skip Steps 2–3; read the numbers from + `~/.claude/sdd-insight/evidence.json` to narrate. +- **Personal fluency only?** Pass `--no-sdd` in Steps 1 and 3 to skip the Layer-2 SDD analysis. +- **Different SDD tree?** Pass `--sdd-dir <path>`. + +## Notes + +- Original transcripts are never modified; the `sdd/` tree is read strictly read-only. +- Scores measure observable behavior, not intent; thin signals are flagged "low data" and hedged. +- The SDD panel measures the **project's** workflow adherence; it is independent of the personal + AI-fluency scores and is the same for any developer running it against this repo. +- Adapted from the open-source Claude Insight engine (MIT) — see + `reference/sdd-insight/UPSTREAM-LICENSE`. diff --git a/.claude/commands/sdd-next.md b/.claude/commands/sdd-next.md new file mode 100644 index 00000000..e65e454a --- /dev/null +++ b/.claude/commands/sdd-next.md @@ -0,0 +1,153 @@ +--- +model: haiku +--- + +# /sdd-next — Suggest Next Unblocked SDD Tasks + +Aggregate tasks across all per-spec indexes (`sdd/tasks/index/*.json`), +identify unblocked tasks, and suggest assignments. Shows worktree context +to help the user decide where to run each task. + +## Usage +``` +/sdd-next +/sdd-next --project <project> [--tag <tag>] # FEAT-576 taxonomy filter +``` + +## Guardrails +- Only suggest tasks with status `"pending"` and all dependencies `"done"`. +- If `sdd/tasks/index/` is empty or does not exist, inform the user and suggest running `/sdd-task` first. +- **Skip `sdd/tasks/index/_orphans.json`** — orphans have no resolvable feature; they are surfaced by `/sdd-status`, never suggested by `/sdd-next`. +- Sort by priority (high → medium → low), then by effort (S → M → L → XL). + +## Steps + +### 1. Read All Per-Spec Indexes (FEAT-145) + +Glob `sdd/tasks/index/*.json` (excluding `_orphans.json`) and aggregate +the `tasks[]` arrays: + +```bash +TASKS=$(jq -s '[.[] | select(.feature != "_orphans") | .tasks[]]' sdd/tasks/index/*.json) +``` + +If `--project` / `--tag` is given (FEAT-576), resolve the matching specs and +keep only tasks whose index's `spec` is in the taxonomy list (AND across flags, +OR within a repeated flag): + +```bash +SPECS=$(python -m scripts.sdd.doc_taxonomy --kind spec --paths-only --project <p> --tag <t>) +TASKS=$(jq -s --arg specs "$SPECS" '[.[] | select(.feature != "_orphans") | select(.spec as $s | ($specs | split("\n")) | index($s)) | .tasks[]]' sdd/tasks/index/*.json) +``` + +If no per-spec index files exist, suggest the user run `/sdd-task` first. + +### 2. Detect Active Worktrees +Run `git worktree list` to identify which feature worktrees are currently active. +Map each active worktree to its feature ID by matching the worktree name pattern +`feat-<FEAT-ID>-<slug>` or `task-<TASK-ID>-<slug>`. + +Additionally, call the worktree status library for richer data: +```bash +WT_REPORTS=$(python -m scripts.sdd.worktree_status --json 2>/dev/null || echo "[]") +``` +Build a lookup from `feature_slug` → `WorktreeReport`. This provides task progress +counts and `ready_for_done` flags that the bare `git worktree list` cannot give. + +### 3. Compute Unblocked Tasks +For each task with `status: "pending"`: +- Check that every task in `depends_on` has `status: "done"`. +- If all deps are done (or `depends_on` is empty) → task is **unblocked**. + +### 4. Group and Annotate +Group unblocked tasks by feature. For each task, determine: +- **Has active worktree**: the feature already has a worktree running → suggest + `/sdd-start TASK-<NNN>` inside that worktree session. +- **Needs new worktree**: no active worktree for this feature → show the + `git worktree add` command. +- **Parallel task**: marked `parallel: true` → can use its own worktree. + +For features with a `WorktreeReport`: +- If `ready_for_done: true`: do NOT suggest new tasks. Instead show: + ``` + FEAT-550 — Token Budget Bedrock + ✅ All 14 tasks done — ready for /sdd-done FEAT-550 + ``` +- If tasks are partially done: annotate the feature header with progress: + ``` + FEAT-582 — SDD Status Worktrees (3/5 done in worktree) + 🟢 Active worktree: feat-FEAT-582-sdd-status-worktrees + ``` + +The progress count comes from: `done_count = sum(1 for t in report.tasks if t.status in ("done", "done-with-issues"))`. + +### 5. Sort and Present +Sort unblocked tasks by priority, then effort. Output: + +``` +📋 Next unblocked SDD tasks: + +FEAT-007 — Ontological RAG + 🟢 Active worktree: feat-007-ontology-rag + 1. TASK-003 — GraphStore [high / M] + Depends-on: TASK-001 ✅, TASK-002 ✅ + → /sdd-start TASK-003 (run inside existing worktree) + +FEAT-008 — MCP Security Layer + 🔵 No worktree — create one: + git worktree add -b feat-008-mcp-security .claude/worktrees/feat-008-mcp-security HEAD + cd .claude/worktrees/feat-008-mcp-security + 2. TASK-010 — SecurityLayer base [high / M] + Depends-on: none + → /sdd-start TASK-010 + +FEAT-009 — Security Toolkits + ⚡ Parallel tasks (can run in separate worktrees): + 3. TASK-042 — Prowler Toolkit [medium / S] + git worktree add -b task-042-prowler .claude/worktrees/task-042-prowler HEAD + → cd .claude/worktrees/task-042-prowler && /sdd-start TASK-042 + 4. TASK-043 — Trivy Toolkit [medium / S] + git worktree add -b task-043-trivy .claude/worktrees/task-043-trivy HEAD + → cd .claude/worktrees/task-043-trivy && /sdd-start TASK-043 +``` + +If no tasks are unblocked: +``` +⚠ No unblocked tasks found. + All pending tasks are waiting on: <list of blocking task IDs> + Run /sdd-status for the full board. +``` + +### 6. Show In-Progress Summary +After the unblocked list, show a brief summary of what's currently running: + +``` +🔄 In progress: + TASK-002 — OntologyParser [in feat-007-ontology-rag] + TASK-021 — Trivy Toolkit [in task-021-trivy-toolkit] +``` + +### 7. Show Ready Ledger Issues (FEAT-566, best-effort) + +Alongside unblocked tasks, surface open, unclaimed ledger issues — discovered +work that has no `TASK-<NNN>` yet. Never fatal (a missing/unbuilt ledger +prints nothing here, it does not block the rest of `/sdd-next`): + +```bash +wikitoolkit ledger ready 2>/dev/null || true +``` + +``` +🗒 Ready ledger issues (not yet promoted to a task): + issue:3f8a1c9e [major] Leak in connection pool (bug) + → /sdd-fix issue:3f8a1c9e (plan-fix routes its group to the Fast or SDD lane; supersedes the deprecated --from-issue flow, FEAT-572) +``` + +If the command prints nothing (or fails), omit this section entirely — +do not print an empty header. + +## Reference +- Per-spec index files: `sdd/tasks/index/*.json` (excluding `_orphans.json`) +- Active worktrees: `git worktree list` +- Worktree policy: `CLAUDE.md` (section "Worktree Policy") +- SDD methodology: `sdd/WORKFLOW.md` \ No newline at end of file diff --git a/.claude/commands/sdd-proposal.md b/.claude/commands/sdd-proposal.md new file mode 100644 index 00000000..af04dec3 --- /dev/null +++ b/.claude/commands/sdd-proposal.md @@ -0,0 +1,523 @@ +--- +description: Research-first proposal generator. Takes a source (Jira ticket, inline text, or file), investigates the codebase, synthesizes findings with confidence-graded reasoning, and produces a grounded proposal document that feeds into /sdd-spec or /sdd-brainstorm. +--- + +# /sdd-proposal — Research-First Feature Proposal + +Take a thin source — typically a sparse Jira ticket like *"Nextstop module no genera el PDF"* — +and produce a proposal document that is **grounded in the codebase before any human Q&A**. + +The pipeline inverts `/sdd-brainstorm`: instead of asking the user to fill the gaps, +the agent investigates the repo first, builds a confidence-graded synthesis, and only +then asks targeted questions about genuine unknowns. + +``` +/sdd-proposal <source> + → Phase 0: source resolution + → Phase 1: research plan generation (gate) + → Phase 2: agentic codebase research (budgeted) + → Phase 3: synthesis (chain-of-thought, evidence-grounded) + → Phase 4: review gate (human validates synthesis) + → Phase 5: targeted Q&A (only for material unknowns) + → Phase 6: enriched proposal rendering + → Phase 7: commit + next-command recommendation +``` + +## Usage + +``` +/sdd-proposal NAV-8421 # Jira key +/sdd-proposal "Nextstop no genera el PDF" # inline text +/sdd-proposal path/to/notes.md # file path +/sdd-proposal NAV-8421 --mode=investigation # force mode +/sdd-proposal NAV-8421 --mode=enrichment +/sdd-proposal NAV-8421 --no-gate # skip user gates (autopilot mode) +/sdd-proposal --resume FEAT-156 # resume interrupted run +/sdd-proposal NAV-8421 --budget=loose # tight | default | loose +``` + +## Guardrails + +- **Wiki-first, then grep, then ask.** The codebase knowledge graph + (``wikitoolkit``) is the fastest orientation source. Query it before + grep/glob/read. The codebase itself is the primary evidence source. + Never ask the user something the repo or wiki can answer. +- **No fabricated paths or symbols.** Every code reference in the proposal must + trace to a finding ID. Validated by the synthesis linter (Step 3.5). +- **Honest confidence.** If research is thin, the proposal must say so — not + inflate certainty. `overall_confidence` is bounded by the budget consumed. +- **Tri-mode source resolution** mirrors `/sdd-fromjira` (Jira / inline / file). +- **Always commit the proposal file** so worktrees can see it. +- **Do NOT generate implementation code.** Output is a proposal, not a spec. +- **State persists** under `sdd/state/<FEAT-ID>/` for resumability and audit. + +## Steps + +### 0. Save Repo Path +```bash +REPO_ROOT=$(pwd) +``` +You must be on `dev`, `staging`, or the integration branch when starting. The +`base_branch` for the proposal defaults to `dev` for `type: feature` flows and to +`main` for `type: hotfix` flows. `staging` is valid as a `base_branch` for +`type: feature` during a release freeze (FEAT-187). + +**Validation:** if `TYPE == "feature"` and `BASE_BRANCH == "main"`, abort: +``` +⚠️ type='feature' cannot base on 'main'. Features land on dev (default) + or staging (during a release freeze). For changes that must base on + main, set type='hotfix' in the document frontmatter. +``` + +### 1. Parse Input & Allocate FEAT-ID + +Extract from the user's invocation: +- **source**: Jira key (regex `^[A-Z]+-\d+$`), inline string, or path to file. +- **flags**: `--mode`, `--no-gate`, `--resume`, `--budget`. + +If `--resume <FEAT-ID>` is set, jump to **Step R** at the bottom. + +Otherwise, allocate a new FEAT-ID: +- Read existing IDs in `sdd/specs/`, `sdd/proposals/`, `sdd/state/`. +- Take `max(existing) + 1`. Start at `FEAT-001` if none exist. + +Create state directory: +```bash +FEAT_ID="FEAT-XXX" # allocated above +STATE_DIR="sdd/state/${FEAT_ID}" +mkdir -p "${STATE_DIR}/findings" +``` + +Initialize `${STATE_DIR}/state.json` per the schema in +`sdd/templates/state.schema.json`. Set: +- `phase: "source_resolved"` (will update at end of step) +- `started_at`: now +- `mode`: from `--mode` flag, or `"auto"` if not specified +- `budget`: per `--budget` profile (see table below) + +| `--budget` | files_read | grep_calls | git_calls | depth | wall_seconds | +|-------------|-----------:|-----------:|----------:|------:|-------------:| +| `tight` | 15 | 10 | 5 | 1 | 120 | +| `default` | 40 | 25 | 10 | 2 | 300 | +| `loose` | 100 | 60 | 20 | 3 | 900 | + +### 2. Phase 0 — Resolve Source + +#### 2a. Jira source + +Use the same access strategy as `/sdd-fromjira`: +- **MCP path**: `jira_get_issue(issue_key=<KEY>)` if available. +- **curl fallback**: load `JIRA_INSTANCE`, `JIRA_USERNAME`, `JIRA_API_TOKEN` + via navconfig (see `/sdd-fromjira` for the eval snippet). + +Capture: summary, description, comments, components, labels, status, priority, +acceptance criteria field, linked issues. + +#### 2b. Inline source + +Take the entire string as the body. No fetch needed. + +#### 2c. File source + +Read the file. If it has frontmatter, extract metadata; otherwise treat the +whole file as body text. + +#### 2d. Persist source + +Write the raw source to `${STATE_DIR}/source.md` with a small frontmatter: +```yaml +--- +kind: jira | inline | file +jira_key: NAV-XXXX | null +fetched_at: <ISO timestamp> +summary_oneline: <≤120 chars one-line summary> +--- +``` + +Update `state.json`: +- `source.*` fields populated +- `phase: "source_resolved"` + +### 3. Phase 1 — Generate Research Plan + +#### 3a. Check wiki availability + +Before generating the plan, probe whether the knowledge graph is built: +```bash +wikitoolkit status 2>&1 +``` +If the output contains "Wiki not built", set `wiki_available: false` in +`state.json` and inform the planner so it omits `wiki_*` query types. +Otherwise set `wiki_available: true`. + +#### 3b. Run the planner prompt + +Invoke the prompt at `sdd/templates/research_plan.prompt.md`, passing: +- contents of `${STATE_DIR}/source.md` +- the budget block +- `wiki_available` flag (so the planner knows whether to emit wiki queries) +- repository top-level structure (`ls -la` of repo root, plus `find . -type d -maxdepth 3`) + +The planner produces a JSON document in the format defined by the +prompt's "Output format" section (there is no separate schema file) — a +list of `queries` with intent, type (`wiki_query | wiki_page | +wiki_related | grep | glob | read | git_log | tree`), parameters, and +priority. + +Persist to `${STATE_DIR}/research_plan.json`. + +#### 3b. Present plan to user (gate) + +Unless `--no-gate` is set, print: + +``` +🔍 Research Plan for <FEAT-ID> — <one-line summary> + +Mode (detected): investigation | enrichment + +Queries (N=<count>): + Q001 [grep] "nextstop" → locate module + Q002 [grep] "generate_pdf|render_pdf" → find PDF entrypoints + Q003 [glob] "**/nextstop/**" → list module files + Q004 [git_log] app/modules/nextstop/ → recent activity + Q005 [read] tests/nextstop/test_pdf.py → existing test coverage + ... + +Budget: <profile> (max 40 files, 25 greps, 10 git, depth 2, 300s) + +Proceed? [y / edit / abort] +``` + +- `y`: continue to Phase 2. +- `edit`: open the plan in `$EDITOR`, re-validate against schema, then continue. +- `abort`: mark `state.json` as `phase: "failed"` with `errors[].message: "user abort"`. + +Update `state.json`: +- `phases.plan.approved_by_user: true` +- `phase: "plan_approved"` + +### 4. Phase 2 — Execute Research + +#### 4a. Iteration loop + +For each query in the approved plan, in priority order: + +1. **Budget check.** Compare `consumed` vs `budget`. If any limit will be + exceeded by this query, skip it and record `queries_skipped++`. + **Wiki queries (`wiki_query`, `wiki_page`, `wiki_related`) are free — + always execute them regardless of budget.** +2. **Execute** the query using the appropriate tool: + - `wiki_query` → `wikitoolkit query "<question>"` — returns ranked + page stubs with IDs, scores, and summaries. Record page IDs for + follow-up `wiki_page`/`wiki_related` queries. + - `wiki_page` → `wikitoolkit page <id>` — returns full page content + (file summary, API outline, content). Use the page ID returned by + a prior `wiki_query`. + - `wiki_related` → `wikitoolkit related <id>` — returns typed edges + (`contains`, `references`) to neighbouring files/modules. Use to + discover integration points and dependencies. + - `grep` / `glob` → use the agent's search tools + - `read` → read the target file (or specified line range) + - `git_log` → `git log --follow --since=<window> -- <path>` + - `tree` → `find <path> -type f -maxdepth N` +3. **Persist a digest** at `${STATE_DIR}/findings/F<NNN>-<slug>.md` using the + format in `sdd/templates/finding.md`. The digest is **compact** — never + inline full file content; cite line ranges and excerpts only. For wiki + findings, cite the wiki page ID and score alongside file paths. +4. **Decide on recursion.** If a finding surfaces a new file/symbol that + strongly warrants exploration AND `depth_reached < max_depth`, add a + follow-up query to the plan with `parent_id: F<NNN>` and re-enter the loop. +5. **Update `state.json`** after each query: `consumed.*`, `phases.research.*`. + +#### 4b. Stop conditions + +- All planned queries executed. +- Budget exhausted (set `truncated: true` and `truncation_reason`). +- Hard timeout reached (`max_wall_seconds`). + +Update `state.json`: +- `phase: "research_complete"` +- `phases.research.completed_at`, `duration_seconds`, `files_read`, etc. + +### 5. Phase 3 — Synthesis + +#### 5a. Build the synthesis input + +Concatenate, in order: +- `${STATE_DIR}/source.md` content +- `${STATE_DIR}/research_plan.json` content +- All `${STATE_DIR}/findings/*.md` digests, wrapped in `<finding id="F00N">` tags +- A `<budget_status>` block with `consumed`, `truncated`, `truncation_reason` +- A `<mode>` block with the resolved mode (forced or auto-detected) + +#### 5b. Run the synthesis prompt + +Invoke `sdd/templates/synthesis.prompt.md`. The agent produces a JSON object +conforming to the synthesis output schema embedded in that prompt. + +Persist the **JSON only** (not the `<thinking>` block) to +`${STATE_DIR}/synthesis.json`. If you want auditability, also persist the +thinking to `${STATE_DIR}/synthesis.thinking.log` — this is optional but +recommended. + +#### 5c. Lint the synthesis + +Validate `synthesis.json` against the rules: + +1. Every `path` and `symbol` mentioned in `localization` and `constraints` + appears in at least one finding's `citations`. +2. Every `evidence` array contains finding IDs that exist. +3. `overall_confidence` ≤ minimum of (median claim confidence, localization + confidence, hypothesis-1 / scope confidence). +4. If `truncated: true`, `overall_confidence` ≤ `medium` and + `recommended_next_command` ≠ `sdd-task`. +5. `unknowns.length ≤ 5`. + +If any rule fails, **re-prompt the synthesis once** with the violations +appended as a corrective addendum. If it still fails, surface the error to +the user and stop with `phase: "failed"`. + +Update `state.json`: +- `phase: "synthesis_complete"` +- `phases.synthesis.*` populated from the JSON + +### 6. Phase 4 — Review Gate + +Print a human-readable summary of the synthesis: + +``` +🧠 Synthesis Summary — <FEAT-ID> + +Mode: investigation | enrichment +Overall confidence: high | medium | low + +Localization (N=<count>): + app/modules/nextstop/pdf.py::generate_pdf (lines 47-89) — entrypoint [F001, F003] + app/modules/nextstop/pdf.py::render_chunk (lines 142-160) — chunk renderer [F003] + ... + +Top hypothesis: + → "<hypothesis 1 statement>" + Confidence: medium + Supporting: F002, F005 + Reasoning: <one sentence> + +Confidence map: + ✓ <claim> [high] ← directly cited + ✓ <claim> [high] + ◐ <claim> [medium] ← inferred + ◐ <claim> [medium] + ◌ <claim> [low] ← weakly supported + +Unknowns the codebase couldn't answer (<count>): + U1: <question> + U2: <question> + +Budget: <consumed>/<budget> (<truncated|complete>) + +Validate? [y / refine-research / refine-synthesis / abort] +``` + +- `y`: continue. +- `refine-research`: jump back to Phase 1 with prior plan as seed; user can + add queries. +- `refine-synthesis`: re-run synthesis prompt with user's free-form notes + appended as `<user_correction>...</user_correction>`. +- `abort`: mark failed. + +Update `state.json`: `phase: "review_gate"` (and `qa_pending` after). + +### 7. Phase 5 — Targeted Q&A (Conditional) + +Only run if `synthesis.unknowns.length > 0` AND `--no-gate` is not set. + +For each unknown, ask **one** question: + +``` +❓ U<N>: <question text> + +Context: this would resolve claim(s) <Cx, Cy>. + +Plausible answers (pick one or write your own): + a) <answer-1> + b) <answer-2> + c) <answer-3> + d) (other — type your answer) +``` + +Wait for user response. Persist answers into `synthesis.json` under +`unknowns[i].user_answer`. Promote resolved unknowns into the proposal's +"Resolved Questions" section in Phase 6. + +Update `state.json`: +- `phases.qa.questions_total`, `questions_answered` +- `phase: "qa_complete"` + +If `--no-gate` is set OR there are zero unknowns, set `phases.qa.status: "skipped"` with reason. + +### 8. Phase 6 — Render the Enriched Proposal + +#### 8a. Generate slug + +Build a kebab-case slug from the source summary. For Jira sources, prepend +the key: `<jira-key>-<slug>`. Examples: +- `nav-8421-nextstop-pdf-generation-bug` +- `redis-token-cache-invalidation` + +#### 8b. Render via template + +Read `sdd/templates/proposal.md` and fill it from: +- `state.json` → frontmatter (id, title, type, mode, status, source, base_branch, confidence) +- `source.md` → §0 Origin +- `synthesis.json` → §1 Synthesis Summary, §2 Codebase Findings, §3 Hypothesis/Scope, §4 Confidence Map +- `synthesis.json.unknowns` (with user answers) → §5 Open Questions +- `synthesis.json.recommended_next_command` → §6 Recommended Next Step +- `state.json` paths → §7 Research Audit + +Write to `sdd/proposals/<slug>.proposal.md`. + +Set frontmatter: +- `status: discussion` if any unknowns remain unresolved +- `status: review` if all unknowns resolved but user hasn't accepted +- `status: accepted` only if the user explicitly says "accept" at the final summary +- `projects` / `tags` (FEAT-576): `projects` from §2.1 Localization paths + (same mapping as `/sdd-brainstorm` §10; vocabulary `KNOWN_PROJECTS` in + `scripts/sdd/sdd_meta.py`, unknown values warn); `tags`: 2–6 kebab-case keywords. + +Update `state.json`: +- `phases.output.proposal_path: "sdd/proposals/<slug>.proposal.md"` +- `phase: "proposal_drafted"` + +### 9. Phase 7 — Commit & Recommend Next + +#### 9a. Commit the proposal + +```bash +# Unstage everything first to ensure a clean staging area +git reset HEAD +# Stage ONLY proposal + state files — NEVER use "git add ." or "git add -A" +git add sdd/proposals/<slug>.proposal.md sdd/state/<FEAT-ID>/ +# Verify staging +git diff --cached --name-only +# If ANY unrelated files appear, run "git reset HEAD" and start over +git commit -m "sdd: research-grounded proposal for <FEAT-ID> — <one-line>" +``` + +Update `state.json`: +- `phases.output.commit_sha`: from `git rev-parse HEAD` +- `phase: "committed"` + +#### 9b. Print the next-step recommendation + +Use `synthesis.recommended_next_command` to render the suggestion: + +``` +✅ Proposal saved and committed: sdd/proposals/<slug>.proposal.md + + FEAT-ID: FEAT-XXX + Mode: investigation | enrichment + Confidence: medium + Audit: sdd/state/FEAT-XXX/ + +Recommended next step: + → /sdd-spec FEAT-XXX + Rationale: <synthesis.recommended_next_command.rationale> + +Alternatives: + → /sdd-brainstorm FEAT-XXX (if you want to explore alternative architectures) + → /sdd-task FEAT-XXX (if the fix is trivial — single file) + → manual review (research was truncated; review state.json) +``` + +### Step R — Resume an Interrupted Run + +If `--resume <FEAT-ID>` is set: + +1. Read `sdd/state/<FEAT-ID>/state.json`. +2. Inspect `phase` and `resume_hint.next_phase`. +3. Re-enter the corresponding step: + - `source_resolved` → Step 3 (plan generation) + - `plan_approved` → Step 4 (research execution) + - `research_complete` → Step 5 (synthesis) + - `synthesis_complete` → Step 6 (review gate) + - `qa_complete` → Step 8 (render) + - `proposal_drafted` → Step 9 (commit) +4. If a phase is in `running` status (orphaned from a crash), restart **that + phase** from scratch (don't try to recover partial state for it). + +Print: +``` +↻ Resuming FEAT-XXX from phase: <phase> + Last activity: <updated_at> + Next action: <resume_hint.next_action> +``` + +## Output + +``` +✅ Proposal saved and committed: sdd/proposals/<slug>.proposal.md + + FEAT-ID: FEAT-XXX + Mode: investigation + Overall confidence: medium + Findings: 11 files read, 9 grep, 3 git + Synthesis: 4 high-confidence claims, 3 medium, 1 low + Unknowns resolved: 2 / 2 + Projects: <list> Tags: <list> + +Next steps: + → /sdd-spec FEAT-XXX (recommended — high-confidence localization) + → /sdd-brainstorm FEAT-XXX (alternative — explore architectural options) +``` + +## How `/sdd-spec` and `/sdd-brainstorm` Consume This Document + +The enriched proposal is designed as a drop-in replacement for either a +brainstorm or a free-form proposal in the existing pipeline. Mappings: + +| Proposal section | → `/sdd-spec` consumes as | +|-------------------------------|----------------------------------------| +| §0 Origin | Spec Metadata (Jira key, source) | +| §1 Synthesis Summary | Spec §1 Motivation | +| §2.1 Localization | Spec §6 Codebase Contract (verified) | +| §2.2 Constraints | Spec §5 Acceptance Criteria + §7 Patterns | +| §2.3 Recent History | Spec §6 Codebase Contract (Recent Activity) | +| §3 Hypothesis / Scope | Spec §2 Architectural Design | +| §4 Confidence Map | Spec §8 Open Questions (low-confidence items) | +| §5 Open Questions (resolved) | Spec §8 Open Questions (`[x]`) | +| §5 Open Questions (unresolved)| Spec §8 Open Questions (`[ ]`) | + +`/sdd-spec` reads the proposal's frontmatter `research_state` to access the +full state for re-verification of the codebase contract. + +## Differences from `/sdd-brainstorm` and `/sdd-fromjira` + +| Aspect | `/sdd-brainstorm` | `/sdd-fromjira` | `/sdd-proposal` (this) | +|-------------------------|--------------------------|----------------------------|--------------------------------| +| Order | Q&A → research | Jira fetch → Q&A → research| Research → synthesis → Q&A | +| When to use | Greenfield features | Jira-seeded brainstorm | Sparse tickets / bugs | +| Output | Brainstorm w/ 3+ options | Brainstorm w/ Jira context | Single-hypothesis proposal | +| Hallucination defense | Code Context section | Code Context section | Lint + finding ID grounding | +| Confidence reporting | Implicit | Implicit | Explicit confidence map | +| Resumability | None | None | Full state.json checkpoints | +| Budget control | None | None | Hard limits per profile | +| Routes to | `/sdd-spec` | `/sdd-spec` | `/sdd-spec` OR `/sdd-brainstorm` (decided by confidence) | + +**When to pick which**: +- **`/sdd-proposal`** — the source is a thin Jira ticket, a bug report, or any + request where the codebase has more context than the requester provided. + This is the new default entry point for tickets. +- **`/sdd-brainstorm`** — truly greenfield features with no existing code to + investigate (e.g., "add a billing module from scratch"). +- **`/sdd-fromjira`** — kept for backward compatibility; superseded by + `/sdd-proposal NAV-XXXX` for most uses. + +## Reference + +- Proposal template: `sdd/templates/proposal.md` +- State schema: `sdd/templates/state.schema.json` +- Synthesis prompt: `sdd/templates/synthesis.prompt.md` +- Research-plan prompt: `sdd/templates/research_plan.prompt.md` +- Finding digest template: `sdd/templates/finding.md` +- SDD methodology: `sdd/WORKFLOW.md` +- Worktree policy: `CLAUDE.md` (section "Worktree Policy") diff --git a/.claude/commands/sdd-spec.md b/.claude/commands/sdd-spec.md new file mode 100644 index 00000000..7e117d8d --- /dev/null +++ b/.claude/commands/sdd-spec.md @@ -0,0 +1,755 @@ +# /sdd-spec — Scaffold a Feature Specification + +Scaffold a new Feature Specification using the SDD methodology. + + +## Usage +``` +/sdd-spec [<feature-slug>] [--type feature|hotfix] [--base-branch <branch>] [--interview | --no-interview] [--resume [<staging-dir>]] [--research full|light|none] [--no-gate] [--budget tight|default|loose] [-- <notes>] +``` + +`--type` / `--base-branch` are explicit overrides for the flow resolved in +§2d — they win over any brainstorm/proposal frontmatter and over the +`WorkKind` mapping (FEAT-466). Omit both to let §2d resolve the flow from +the exploration doc (or default to `feature`/`dev` when none exists). + +With no brainstorm/proposal and no notes (or with `--interview`), run intake +mode by following `sdd/templates/intake.procedure.md`; never in a non-interactive run. +These intake flags are ignored (with a notice) on the carry-forward path. + +## Guardrails +- Always use the official template at `sdd/templates/spec.md`. +- Do NOT write implementation bodies in the spec — but every §3 module MUST + carry an **Interface Skeleton** (signatures, docstrings, `verified:` anchors; + see §4 item 6). Bodies belong to task Implementation Blueprints (`/sdd-task`). +- **Feature IDs are unique by construction**: new `FEAT-<NNN>` numbers are + reserved via `scripts/sdd/reserve_ids.py` (FEAT-387) — a git-native + compare-and-swap ledger, not a manual "check existing specs" scan — so + two `/sdd-spec` runs racing each other cannot silently collide on the + same number. See §5 below. The one documented exception is intentional + `FEAT-<NNN>` reuse across a deliberate multi-spec split of one + initiative (e.g. FEAT-380 across `sandbox-hardening`, + `shelltool-hardening`, `tool-result-compression`) — declared explicitly + via a `reuse_feature_id` frontmatter field, never a silent fallback. +- If a `.brainstorm.md` exists for this feature in `sdd/proposals/`, use it as input. +- **NEVER re-ask a question that the brainstorm already answered.** Resolved + answers must be carried forward verbatim, not re-opened. See §2 for the + resolved-question convention and §3 for what you MAY ask. +- **Always commit the spec file to the current branch** so worktrees can see it. + +## Steps + +### 1. Parse Input +- **feature-name**: slug-friendly kebab-case. Optional in intake mode. +- **free-form notes**: anything after `--`, used as Problem Statement seed. + +When no exploration doc exists AND no `--` notes were given, intake mode starts +automatically. If the user cannot be asked questions, behave as `--no-interview` +even without the flag. Cannot ask ⇒ `--no-interview`. + +### 1.5 Intake Mode (interactive only — FEAT-577) + +Runs only when §1's trigger rule selects it. Follow +`sdd/templates/intake.procedure.md` §0–§8 end to end. It stages everything +under `sdd/state/.intake/<slug>-<RUN_ID>/` (git-ignored; pruned after 10 days +by a daily git hook), validated by `sdd/templates/intake.schema.json`, and returns +one of two outcomes: + +- **handed off** (`phase: handed_off`) — the user chose `/sdd-brainstorm`; stop + here: no spec, no FEAT-ID. +- **ready** (`phase: rounds_complete`) — continue at §2d with `doc_path=None` + and the Round 0 flow as `--type`/`--base-branch` overrides; §3b uses the + intake brief sources; §3 is skipped; §4 is seeded by the synthesis; §5 + reserves the FEAT-ID unchanged; §6 promotes the staging dir. + +### 2. Check for Prior Exploration (and carry it forward) + +Look for prior exploration documents in `sdd/proposals/`: +- `<feature-name>.brainstorm.md` → structured options analysis with a Recommended Option. +- `<feature-name>.proposal.md` → discussion output. + +If neither exists, proceed to §1.5 when intake mode is active, otherwise §3. + +**If a brainstorm exists, you MUST treat it as the authoritative input.** +Do the following in order before writing anything or asking the user anything: + +#### 2a. Map the brainstorm into the spec + +Carry each brainstorm section into the spec per this mapping. This is not +optional — every non-empty brainstorm section below has a target in the spec: + +| Brainstorm section | Spec target | +|---|---| +| Problem Statement | §1 Motivation — Problem Statement (verbatim, condensed only if needed) | +| Constraints & Requirements | §1 Goals + §5 Acceptance Criteria (every hard constraint becomes a checkable criterion) | +| Recommendation + Recommended Option body | §2 Architectural Design — Overview | +| Feature Description → User-Facing Behavior | §2 Overview | +| Feature Description → Internal Behavior | §2 Component Diagram + Integration Points | +| Feature Description → Edge Cases & Error Handling | §7 Known Risks / Gotchas | +| Capabilities (New + Modified) | §3 Module Breakdown (one module per capability as a starting point) | +| Impact & Integration table | §2 Integration Points | +| Code Context (entire section) | §6 Codebase Contract (re-verify every reference — code may have shifted) | +| Libraries / Tools table | §7 External Dependencies | +| Parallelism Assessment | Worktree Strategy section | +| Frontmatter `projects` / `tags` (FEAT-576) | Spec frontmatter `projects` / `tags` — verbatim; extend `projects` only with a distribution §4 research shows the feature touches | +| Open Questions (see 2b) | §8 Open Questions (with resolved/unresolved state preserved) | + +Rejected options from the brainstorm are NOT carried into the spec body. +They may be referenced in one line inside §1 Non-Goals if the rejection +excludes something a reader might expect (e.g. *"Runtime fallback-on-failure +was rejected in brainstorm — see proposals/<name>.brainstorm.md Option A."*). + +#### 2b. Parse the Open Questions section — resolved vs. unresolved + +The brainstorm's Open Questions use this convention: + +``` +- [ ] Unresolved question — *Owner: name* +- [x] Resolved question — *Owner: name*: <answer text> +``` + +- A **`[x]`** checkbox means the user has already answered the question. + The answer is the text after the final `:` on the same line (or the + indented lines immediately below, if any). +- A **`[ ]`** checkbox means the question is still open. + +**Rules for resolved (`[x]`) questions — this is the heart of the fix:** + +1. **Do NOT re-ask the user.** Never include a resolved brainstorm question + in the clarifying-question batch in §3. +2. **Route the answer into the spec body where the decision actually + applies** — not just into §8. For example: + - "Default backend when unset → sqlite" → state this in §2 Overview and + add an acceptance criterion in §5. Do not leave it as an open question. + - "Binary overflow path declared in `project/conf.py`" → add the config + key to §6 Configuration References and mention the path in §7 + Patterns to Follow. Do not describe the design as "mingled" or any + alternative that contradicts the resolved answer. + - "No TTL in SQL backends" → reflect this in the schema DDL (no + `expires_at` predicate) and in §7 Known Risks. +3. **Also echo the resolution in §8** as a resolved item so readers can + audit the decision trail: + ``` + - [x] <Question restated> — *Resolved in brainstorm*: <answer verbatim> + ``` + This keeps §8 honest about what was decided and by whom. +4. **If a resolved answer conflicts with your own instinct for the spec**, + the brainstorm wins. Do not silently override it. If you believe the + answer is wrong, surface the conflict to the user as a *new* question + in §3 — do not rewrite the answer. + +**Rules for unresolved (`[ ]`) questions:** + +- Carry them forward into §8 of the spec as `[ ]` items. +- They are fair game for §3 clarifying questions, but only if they + genuinely block the spec (ones that can be decided during + implementation should stay as `[ ]` in §8 and not be asked now). + +#### 2c. Show the user the carry-forward summary before asking anything + +Before the clarifying-question round in §3, print: + +``` +Loaded brainstorm: sdd/proposals/<feature-name>.brainstorm.md + Recommended Option: <X — name> + Resolved questions carried forward (N): <one-line list> + Unresolved questions remaining (M): <one-line list> + Clarifying questions I still need to ask (K): <one-line list or "none"> +``` + +If K is zero, proceed directly to §3b without asking anything. + +#### 2d. Sync the Base Branch (FEAT-145, resolver added FEAT-466) + +Resolve `(TYPE, BASE_BRANCH)` deterministically via +`scripts.sdd.sdd_meta.resolve_flow()` — explicit `--type`/`--base-branch` +flags win over the brainstorm/proposal frontmatter, which wins over the +default (`feature`/`dev`). Unlike a bare `parse()` call, `resolve_flow()` +does NOT raise `FileNotFoundError` when no exploration doc exists — that is +exactly the dev-loop bug path's normal situation (no brainstorm, no +proposal), and it is why this step no longer calls `parse()` directly: + +```bash +META=$(python -c " +from pathlib import Path +from scripts.sdd.sdd_meta import resolve_flow +m = resolve_flow( + doc_path=Path('<brainstorm-or-proposal-path>') if '<exploration-doc-exists>' else None, + type_override='<--type value, or empty string if not passed>' or None, + base_branch_override='<--base-branch value, or empty string if not passed>' or None, +) +print(m.type, m.base_branch)") +TYPE=$(echo "$META" | awk '{print $1}') +BASE_BRANCH=$(echo "$META" | awk '{print $2}') +``` + +**Validation (hotfix/main):** `resolve_flow()` enforces `type='hotfix' ⇒ +base_branch='main'` internally (it returns a `FlowMeta`, whose own +cross-field validator raises `ValueError` — this is NOT re-derived as a +separate bash check). The `python -c` call above therefore exits non-zero +and prints a traceback when a `--type hotfix --base-branch dev`-style +combination (or hotfix frontmatter with a non-`main` base) is resolved. On +that failure, abort with the same message as before: +``` +⚠️ type='hotfix' requires base_branch='main' (got base_branch='<value>'). + Fix the brainstorm/proposal frontmatter (or --base-branch flag) and + re-run /sdd-spec. +``` + +**Validation (feature/not-main):** `FlowMeta` does NOT reject +`type='feature', base_branch='main'` at the schema layer (that refusal is +intentionally a command-layer guard, FEAT-187) — so this check stays a +manual bash condition after the resolver returns successfully. If +`TYPE == "feature"` and `BASE_BRANCH == "main"`, abort: +``` +⚠️ type='feature' cannot base on 'main'. Features land on dev (default) + or staging (during a release freeze). For changes that must base on + main, set type='hotfix' in the document frontmatter. +``` + +Note: `staging` is a valid `base_branch` for `type: feature` during a release freeze +(FEAT-187). Use `base_branch: staging` when applying stabilization fixes to a +frozen release candidate. + +**Sync:** before scaffolding, switch to the base branch and pull: +```bash +git checkout "$BASE_BRANCH" +git pull --ff-only origin "$BASE_BRANCH" +``` + +If the working tree is dirty, abort with: +``` +⚠️ Cannot sync <BASE_BRANCH>: working tree has uncommitted changes. + Stash or commit first, then re-run /sdd-spec. +``` + +If `--ff-only` fails, abort with: +``` +⚠️ Cannot fast-forward <BASE_BRANCH>. Reconcile manually + (git pull --rebase or merge), then re-run /sdd-spec. +``` + +Carry `TYPE` and `BASE_BRANCH` forward into the spec's frontmatter at §5. + +### 3b. Collaborative Design Research (codex seat — optional, NEVER blocking) + +An independent design opinion over the **accepted exploration document**, taken +*before* you draft §2/§6 so it cannot become a ratification of your own design +(FEAT-545). This step is optional: every failure below is recorded as a skip +reason for spec §9 and the command continues. **This step must never abort +`/sdd-spec`** — `sdd-planner` runs this command unattended. + +**Preconditions (any false ⇒ skip):** +- §2 found `<exploration-doc>` and its status is `accepted` (brainstorm + `**Status**: accepted`, or proposal frontmatter `status: accepted`). +- `command -v codex` succeeds. +- **or** §1.5 ran and `intake.json.phase` is `rounds_complete` + +**Rules (identical to the Adversarial Cross-Check in `.claude/agents/code-reviewer.md`):** +- **Never feed the reviewer your reasoning, draft, or preferred conclusion.** + The brief carries ONLY the exploration document and verified code anchors. +- **Run it in the background** — a call takes 30 s to 10 min. Do not call it + per edit. +- **Treat the output as advisory.** Every suggestion gets a disposition: + `CONFIRM` (fold into §2/§3/§7), `REJECT` (record why), `ESCALATE` (becomes + a `[ ]` item in §8). +- **Never silently concede and never silently drop** a suggestion. +- **Verify the reviewer's evidence.** Every `affected_paths` entry is checked + for repository containment, then with `test -e`; a path resolving outside + the repo ⇒ `REJECT` "path outside repository"; an unverifiable path ⇒ + `REJECT` "path not found". + +> **`agy` (Google Gemini / Antigravity) MUST NOT be used for this seat** — same +> ban and same reason as for code review (`CLAUDE.md`, "Adversarial Second +> Opinion"). With no `codex`, skip; do not substitute another external CLI. + +#### 3b.1 Detect and probe +```bash +REPO_ROOT="$(pwd)" # /sdd-spec always runs from the repo root (§2d) +MODEL="${SDD_DESIGN_RESEARCH_MODEL:-gpt-5.6-luna}" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +DR="sdd/state/.design_research/<feature-name>-${RUN_ID}" # id-independent, run-scoped staging: FEAT-ID is reserved only in §5 +mkdir -p "$DR"; SKIP_REASON="" +if ! command -v codex >/dev/null 2>&1; then SKIP_REASON="codex CLI not installed"; fi +if [ -z "$SKIP_REASON" ]; then + # stdin MUST be redirected: without a TTY `codex exec` prints "Reading additional + # input from stdin..." and blocks until the timeout (rc=124) even though the + # prompt is passed as an argument (observed 2026-09-10, codex-cli 0.153/0.154). + timeout 120 codex exec --ephemeral --sandbox read-only -m "$MODEL" \ + -c model_reasoning_effort=high --ignore-user-config \ + -o "$DR/probe.txt" "Reply with exactly the single word OK." < /dev/null >/dev/null 2>&1 \ + || SKIP_REASON="model probe failed for $MODEL (rc=$?)" +fi +if [ -z "$SKIP_REASON" ]; then + PROBE_TEXT="$(cat "$DR/probe.txt" 2>/dev/null | tr -d '[:space:]')" + [ "$PROBE_TEXT" = "OK" ] || SKIP_REASON="model probe returned unexpected output for $MODEL" +fi +if command -v codex >/dev/null 2>&1; then + CODEX_VERSION="$(codex --version 2>/dev/null | awk '{print $2}')" + PROBE_OUTPUT="$(cat "$DR/probe.txt" 2>/dev/null || echo "")" + python -c " +import json, sys +json.dump({ + 'model': sys.argv[1], + 'codex_cli_version': sys.argv[2], + 'reasoning_effort': 'high', + 'timeout_s': 600, + 'probe_output': sys.argv[3], +}, open(sys.argv[4], 'w'), indent=2) +" "$MODEL" "$CODEX_VERSION" "$PROBE_OUTPUT" "$DR/run.json" +fi +``` + +#### 3b.2 Render the neutral brief (skipped when `SKIP_REASON` is already set) +Write each extracted value below to its own file under `$DR` — `problem_statement.txt`, +`constraints_and_goals.txt`, `recommended_option_or_scope.txt`, `code_context_paths.txt`, +`open_questions.txt`, `question.txt` (plain UTF-8 text, no code fences) — **before** running +the renderer, sourced from: +- `problem_statement.txt` ← brainstorm "## Problem Statement" | proposal "## 1. Synthesis Summary" + §0 Origin quote +- `constraints_and_goals.txt` ← brainstorm "## Constraints & Requirements" | proposal "### 2.2 Constraints Discovered" +- `recommended_option_or_scope.txt` ← brainstorm "## Recommendation" + Recommended Option body | proposal "## 3. Probable Scope" (or "## 3. Hypothesis") +- `code_context_paths.txt` ← the **paths only** (one per line) from brainstorm "## Code Context" | proposal "### 2.1 Localization" +- `open_questions.txt` ← the `[ ]` items of the exploration doc (or "none") +- `question.txt` ← "Given this accepted design intent and these verified code anchors, how would you build it? What is missing, risky, or better done another way?" + +In intake mode, sources come from `synthesis.json` (FEAT-577): +- `problem_statement.txt` ← synthesis `"problem_statement"` +- `constraints_and_goals.txt` ← synthesis `"constraints"` + `"goals"` +- `recommended_option_or_scope.txt` ← synthesis `"scope"` + `"approach"` +- `code_context_paths.txt` ← synthesis `"code_context_paths"` (one per line) +- `open_questions.txt` ← synthesis `"open_questions"` (or "none") +- `question.txt` ← same as above + +```bash +if [ -z "$SKIP_REASON" ]; then + python - "$DR" <<'PY' || SKIP_REASON="brief rendering failed" +import sys +from pathlib import Path + +dr = Path(sys.argv[1]) +template = Path("sdd/templates/design_research.prompt.md").read_text(encoding="utf-8") +names = [ + "problem_statement", "constraints_and_goals", "recommended_option_or_scope", + "code_context_paths", "open_questions", "question", +] +for name in names: + value = (dr / f"{name}.txt").read_text(encoding="utf-8").strip() + template = template.replace("{{" + name + "}}", value) +assert "{{" not in template, "unfilled placeholder remains" +(dr / "brief.md").write_text(template, encoding="utf-8") +PY +fi +``` +FORBIDDEN in the brief: anything you have written for this spec, your +reasoning, this command's text, or a preferred answer. If in doubt, leave it out. +Note: the template's own header comment intentionally spells placeholder names WITHOUT +`{{ }}` braces, precisely so this whole-document `str.replace()` cannot also rewrite the +comment (verified by TASK-3099's dry run, which caught this exact corruption before the fix). + +#### 3b.3 Run codex (capped, synchronous — NOT a background job) +```bash +if [ -z "$SKIP_REASON" ]; then + STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" + timeout 600 codex exec --ephemeral --sandbox read-only --cd "$REPO_ROOT" \ + -m "$MODEL" -c model_reasoning_effort=high --ignore-user-config \ + --output-schema sdd/templates/design_research.schema.json \ + -o "$DR/suggestions.json" - < "$DR/brief.md" > "$DR/codex.log" 2>&1 + rc=$? + [ "$rc" -eq 124 ] && SKIP_REASON="codex timed out after 600s" + [ "$rc" -ne 0 ] && [ -z "$SKIP_REASON" ] && SKIP_REASON="codex exited $rc (see $DR/codex.log)" + ENDED_AT="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" + if [ -f "$DR/run.json" ]; then + python -c " +import json, sys +d = json.load(open(sys.argv[1])) +d['started_at'] = sys.argv[2] +d['ended_at'] = sys.argv[3] +d['exit_code'] = int(sys.argv[4]) +json.dump(d, open(sys.argv[1], 'w'), indent=2) +" "$DR/run.json" "$STARTED_AT" "$ENDED_AT" "$rc" + fi +fi +``` +This call blocks for up to 600s (`timeout 600`, foreground). There is no background/job-control +mechanism here — if you want to do other useful work (e.g. start §4 codebase research) while +waiting, run this step as a separate shell invocation and poll/join it yourself; do not assume +concurrency is provided for you. + +#### 3b.4 Validate and triage +```bash +if [ -z "$SKIP_REASON" ]; then + python -c " +import json, sys, jsonschema +s = json.load(open('sdd/templates/design_research.schema.json')) +d = json.load(open('$DR/suggestions.json')) +jsonschema.Draft202012Validator(s).validate(d) +print(len(d['suggestions']), 'suggestions')" || SKIP_REASON="suggestions.json failed schema validation" +fi +``` +For each suggestion (when not skipped), for every `affected_paths` entry: +1. **Containment check first**: resolve the path against `$REPO_ROOT` and confirm it + stays inside it — + ```bash + python -c " +import os, sys +p = os.path.realpath(sys.argv[1]) +root = os.path.realpath('$REPO_ROOT') +sys.exit(0 if p == root or p.startswith(root + os.sep) else 1)" "<path>" \ + || REASON="REJECT — path outside repository: <path>" + ``` + A path that fails containment is `REJECT — path outside repository: <path>` and is + NOT passed to `test -e` at all. +2. **Existence check** (only for paths that passed containment): `test -e <path>` — + unverifiable ⇒ `REJECT — path not found: <path>`. +3. Read the cited spots for paths that pass both checks; decide **CONFIRM / REJECT / + ESCALATE** with a one-sentence reason; write `$DR/triage.md` using the §9 table + shape from `sdd/templates/spec.md`. + +#### 3b.5 Fold and record +- `CONFIRM` → apply while drafting §2 Overview / §3 modules / §7 notes; the + §9 row's "Landed in" names the section. +- `ESCALATE` → add a `[ ]` question to §8 (owner: the user); "Landed in" = `§8 Q<N>`. +- `REJECT` → row only. +- Fill spec **§9 Design Research Cross-Check** from `$DR/triage.md`, with + `Model: <MODEL>` and `Status: completed` — or, when skipped, a single + line `Status: skipped (<SKIP_REASON>)` and an empty table. +- §6 moves `$DR` to `sdd/state/<FEAT-ID>/design_research/` and commits it + with the spec. + +### 3. Ask Clarifying Questions (only what is genuinely missing) + +Skipped in intake mode — §1.5's adaptive rounds replace it. + +After §2c, you may ask the user **only** for gaps the brainstorm/proposal did +not cover. Typical legitimate gaps: + +- Spec-level fields that don't exist in a brainstorm (Target version, Author + attribution if unclear, Status lifecycle preference). +- New `[ ]` open questions that genuinely block the design (not ones that + can be deferred to implementation). +- Ambiguities discovered during codebase research in §4 (e.g., two plausible + integration points — which one to use). + +**Forbidden in this step:** +- Re-asking anything that already appeared in the brainstorm's Open Questions + as `[x]` resolved. +- Re-asking Problem Statement / Constraints / Recommended Option — the + brainstorm is authoritative on those. +- Asking the user to restate the feature goals in their own words when the + brainstorm already states them. + +Ask in a single batch so the user answers once and you proceed. If there is +nothing to ask, skip this step silently. + +### 4. Research the Codebase & Build Codebase Contract +Before writing the spec: +- Read existing specs in `sdd/specs/` directory. +- Identify related existing components (AbstractClient, AgentCrew, BaseLoader, etc.). +- Note what can be reused vs. what must be created. + +**CRITICAL — Codebase Contract Construction:** +This step prevents AI hallucinations during implementation. You MUST: + +1. **If a brainstorm exists**: carry forward its entire `## Code Context` section + into the spec's `## 6. Codebase Contract` section. Re-verify each reference + is still accurate (code may have changed since brainstorm). +2. **For every class/module referenced in the spec**: `read` the actual source file + and record exact class signatures, method signatures (with parameter types and + return types), and key attributes — with file paths and line numbers. +3. **Verify all imports**: confirm that all referenced imports resolve by + checking `__init__.py` exports and module structure. Do not assume. +4. **Record what does NOT exist**: if you searched for a plausible module, class, + or method and it does not exist, add it to the "Does NOT Exist" subsection. + This is the most effective anti-hallucination measure — it explicitly tells + implementing agents what NOT to reference. +5. **Include user-provided code**: if the user or brainstorm provided code snippets, + preserve them as verified references in the contract. +6. **Interface Skeletons (FEAT-545)**: for every §3 module write the public + signatures and docstrings of what the module adds or changes — no bodies — + each line that touches existing code carrying `# verified: path:NN`. These + skeletons are what `/sdd-task` turns into per-task Implementation Blueprints, + so a name fixed here is not renegotiable later. +7. **Edit Sites (§6, Blueprint Anchors)**: for every file the §3 modules will + MODIFY, record the verbatim anchor line the change attaches to, its + `path:NN`, and its occurrence count (`grep -c '<anchor>' <path>`); for every + file they CREATE, record just the path. Record the base commit the table was + verified against. `/sdd-task` builds one Implementation Blueprint block per + row, so verifying the anchor once here replaces re-deriving it in each of the + feature's 15–30 tasks. Two rules keep the table from becoming a liability: + an occurrence count `> 1` means the anchor is ambiguous and must carry 2–3 + lines of surrounding context, and the table lists only files the modules + actually touch — it is re-read on every downstream turn, so prose and + speculative rows are pure cost. + +#### Identify delegation-eligible modules + +While writing §3 Module Breakdown, fill the "Delegation-eligible modules" +sub-table: for each module state whether its design is complete enough that +implementing it is mechanical, and record the decided patterns and exact +contracts (signatures, error codes, file layout). Architecture decisions +stay with the thinking model — eligibility never delegates a design choice. + +### 5. Scaffold the Spec +1. Read the template at `sdd/templates/spec.md`. The template already contains + a YAML frontmatter block at the top (FEAT-145). +2. Create `sdd/specs/<feature-name>.spec.md` filled in with: + - **Frontmatter** at the very top: set `type` and `base_branch` to the + values resolved in §2d. Do NOT strip the frontmatter. The block must + match this shape exactly: + ```yaml + --- + type: feature # or: hotfix + base_branch: dev # or: main (mandatory for hotfix) + projects: [ai-parrot] # FEAT-576 — parts of the codebase; carried from the brainstorm/proposal + tags: [memory] # FEAT-576 — 2–6 kebab-case keywords; carried from the brainstorm/proposal + # reuse_feature_id: FEAT-<NNN> # OPTIONAL — only for an intentional + # multi-spec split of one initiative (see Guardrails); when present, + # skip the reserve_ids.py call below and use this ID verbatim. + --- + ``` + - **projects / tags** (FEAT-576): copy from the exploration doc; with no + exploration doc, derive projects from §4 research (mapping in + `/sdd-brainstorm` §10) and propose 2–6 tags. Vocabulary: `KNOWN_PROJECTS` + in `scripts/sdd/sdd_meta.py` (unknown values warn). + - **Feature ID — SKIP ENTIRELY when `TYPE == "hotfix"` (FEAT-466).** A + bugfix is not a feature and reserves no `FEAT-<NNN>`: ledger ids exist + for features and the brainstorm → spec → task SDD flow, and a hotfix is + identified by its **Jira issue key** instead. Do NOT call + `reserve_ids.py --kind feature` on the hotfix path — write no + `FEAT-<NNN>` anywhere in the spec, and use the Jira key as the + document's identity line (`**Jira**: <KEY>` in place of `**Feature + ID**: FEAT-<NNN>`). This is not an oversight to fix later: it is the + whole point — the allocator's "current branch must equal + `--base-branch`" precondition (`reserve_ids.py`, `_assert_safe_to_reserve`) + would otherwise be + unreachable for a hotfix that has to reserve on `main` while another + worktree already has `main` checked out. Skipping reservation removes + the problem rather than solving it. + + For `TYPE == "feature"`, **first check whether this slug already owns + an ID**. A slug has ONE feature ID for its lifetime: re-running + `/sdd-spec` over an existing spec must REUSE that number, never + reserve a second one. A second reservation forks the feature's + identity — the spec and the task index move to the new number while + the task files, the branch and the worktree keep the old one, and + `DevelopmentNode._find_feature_slug` (which matches strictly on the + index header's `feature_id` **inside the worktree**) then finds no + index at all and silently degrades a whole multi-agent pool to a + single agent. This is not hypothetical: it happened on 2026-09-01 to + `formfield-content-type` (FEAT-488 → FEAT-489), and cost a full run. + + ```bash + EXISTING=$(python -c " + from pathlib import Path + from scripts.sdd.reserve_ids import existing_feature_id + found = existing_feature_id(Path('.'), '<feature-name>') + print(found[0] if found else '')") + ``` + + If `$EXISTING` is non-empty, **use it verbatim as this spec's Feature + ID and skip the reservation entirely** — say so in the §7 output + ("reusing FEAT-<NNN>, already owned by this slug"). Only when it is + empty do you reserve: + ```bash + FEAT_ID=$(python -m scripts.sdd.reserve_ids --kind feature --count 1 \ + --base-branch "$BASE_BRANCH" --label <feature-name>) + ``` + `reserve_ids.py` enforces the same rule itself and exits non-zero + naming the owned ID, so forgetting this check is a hard failure, not + a silently burned number. + On success this prints exactly one `FEAT-<NNN>` line; use it verbatim + as this spec's Feature ID. `reserve_ids.py` pushes its own ledger-only + commit to `origin/$BASE_BRANCH` as part of this call (retrying + internally on a non-fast-forward rejection) and refuses to run if + tracked files have uncommitted changes besides the ledger file. It + builds that commit on `origin/$BASE_BRANCH` with git plumbing, so it + never publishes or discards local-only commits on your branch. If the command exits non-zero, **STOP** and report the error — + do NOT fall back to hand-computing a number. + + **Escape hatch — intentional FEAT-ID reuse**: if this spec is a + deliberate split of an existing initiative across multiple specs (the + FEAT-380-style pattern — `sandbox-hardening.spec.md`, + `shelltool-hardening.spec.md`, and `tool-result-compression.spec.md` + all intentionally share `FEAT-380`), do NOT call `reserve_ids.py`. + Instead, set `reuse_feature_id: FEAT-<NNN>` in the frontmatter above, + state the reused ID explicitly, and skip the reservation call + entirely — this documents the intentional reuse for auditability + rather than silently reusing a stale number. + - Today's date. + - Answers from user (or prior exploration documents). + - Architectural patterns from your codebase research. +3. When populating §8 Open Questions, apply the resolved/unresolved + partition from §2b. Resolved items use `[x]` and the carried-forward + answer; unresolved items use `[ ]`. +4. Before finishing, sanity-check the spec against the brainstorm: + for every `[x]` resolved question in the brainstorm, search the spec + body for a passage that reflects the resolution. If you cannot find + one, you have failed to carry the decision forward — fix the spec + before committing. +5. Confirm the spec frontmatter carries the exploration doc's `projects` and + `tags` (FEAT-576) — `python -c "from pathlib import Path; from scripts.sdd.sdd_meta import parse_taxonomy; print(parse_taxonomy(Path('<spec>')))"` + must list at least the carried values. + +**Worktree hint (new section in spec):** +Include a `## Worktree Strategy` section in the spec with: +- Isolation: always ONE feature worktree per spec; the `sdd-coder` engine gives + each task its own sub-worktree inside it. Do not write "tasks run + sequentially" — that is not an isolation choice, and `/sdd-task` must not + turn it into a dependency chain. +- Module dependency graph: which modules need which (M2 → M1 because M2 imports + M1's `<symbol>`), with the evidence. Modules without an edge between them + are expected to run concurrently. +- Shared files: files more than one module modifies (their tasks get serialized). +- Exclusive resources: steps that mutate shared state outside a module's own + files (extension rebuild, lockfile, migration) — their tasks become + `parallel: false`. +- Cross-feature dependencies: list any specs that must be merged first. + +**E2E plan hint (new section in spec, FEAT-581 — optional):** +Only add this when the feature exercises the deterministic E2E gate +(`parrot e2e run` / `parrot e2e verify`); otherwise omit both the +frontmatter `e2e` key and the `### E2E Scenarios` subsection entirely — an +absent `e2e` key defaults the later-generated plan's policy to `optional` +(`parrot.e2e.plan.load_plan`). When it does apply: +- Frontmatter `e2e: {policy, scenario_ids}` — `policy` MUST be exactly + `required`, `optional` or `none`; never write, coerce, or silently repair + an invalid value — `load_plan` fails plan loading closed on it instead. +- Add `### E2E Scenarios` under §4 Test Specification: declare stable + `ScenarioSpec.id` values per scenario, its tier (`deterministic`, `live` + or `exploratory`) and whether it is `required`. A `required` policy + demands at least one required, codified (non-exploratory, node-bearing) + scenario — an empty or all-exploratory scenario list under `required` is + a spec defect, not something the plan loader defaults around. +- Keep live and exploratory scenarios in separate rows from deterministic + ones and never describe them as "deterministic" — live scenarios need + explicit `PARROT_TEST_REAL_LLM=1` opt-in and a request budget; + exploratory scenarios carry no node IDs and can never be `required`. +- The complete `e2e-plan.md` frontmatter (`schema_version`, `feature_id`, + `spec_path`, `policy`, `targets`, `scenarios`, `budget`, `run_timeout_s`) + is generated later, during task decomposition, once pytest node IDs are + frozen — the spec declares policy/scenario intent only, never fabricated + node IDs. + +### 6. Commit the Spec + +> **CRITICAL — Worktrees branch from the current state of the repo.** +> If the spec is not committed, any worktree created later will NOT see it, +> and the `sdd-worker` agent will fail with "no spec found". + +> **CRITICAL — Only commit the spec file. NEVER commit unrelated changes.** +> Other files may be modified or unstaged in the working directory — do NOT +> touch them. Follow the exact sequence below. + +```bash +# 1. Unstage everything first to ensure a clean staging area +git reset HEAD + +# 2. Stage ONLY the spec file (+ the design-research transcript when §3b ran) — NEVER "git add ." / "-A" +git add sdd/specs/<feature-name>.spec.md +if [ -d "$DR" ]; then + STAGE_TMP="sdd/state/.design_research/.promote-<FEAT-ID>-${RUN_ID}" + PROMOTED="sdd/state/<FEAT-ID>/design_research" + if [ -e "$PROMOTED" ]; then + echo "⚠️ $PROMOTED already exists — leaving $DR in place for manual review (run-id ${RUN_ID}); not overwriting existing design research." + else + mkdir -p "sdd/state/<FEAT-ID>" "$STAGE_TMP" && cp -a "$DR"/. "$STAGE_TMP"/ \ + && mv "$STAGE_TMP" "$PROMOTED" \ + && rm -rf "$DR" \ + && git add "$PROMOTED/" \ + || { echo "⚠️ Promotion of $DR failed — left in place for inspection (run-id ${RUN_ID})." ; rm -rf "$STAGE_TMP"; } + fi +fi + +# Intake mode promotion (FEAT-577) +if [ -d "$STAGE" ]; then + # Set feat_id and phase: committed in intake.json BEFORE staging + python -c " +import json, sys +d = json.load(open(sys.argv[1])) +d['feat_id'] = sys.argv[2] +d['phase'] = 'committed' +d['updated_at'] = __import__('datetime').datetime.utcnow().isoformat() + '+00:00' +json.dump(d, open(sys.argv[1], 'w'), indent=2) +" "$STAGE/intake.json" "$FEAT_ID" + + INTAKE_PROMOTED="sdd/state/$FEAT_ID/intake" + if [ -e "$INTAKE_PROMOTED" ]; then + echo "⚠️ $INTAKE_PROMOTED already exists — leaving intake staging in place for manual review (run-id ${RUN_ID}); not overwriting existing intake data." + else + mkdir -p "sdd/state/$FEAT_ID" && cp -a "$STAGE"/. "$INTAKE_PROMOTED"/ \ + && rm -rf "$STAGE" \ + && git add "$INTAKE_PROMOTED/" \ + || { echo "⚠️ Promotion of $STAGE failed — left in place for inspection (run-id ${RUN_ID})." ; } + fi +fi + +# 3. Verify ONLY those paths are staged +git diff --cached --name-only +# Expected: sdd/specs/<feature-name>.spec.md [+ sdd/state/<FEAT-ID>/design_research/*] [+ sdd/state/<FEAT-ID>/intake/*] +# If ANY other files appear, run "git reset HEAD" and start over + +# 4. Commit +git commit -m "sdd: add spec for FEAT-<ID> — <feature-name>" +``` + +### 7. Output + +**`TYPE == "feature"`:** +``` +✅ Spec created and committed: sdd/specs/<feature-name>.spec.md + + Feature ID: FEAT-<ID> + Module graph: <M> modules, <E> dependency edges, exclusive: <modules or "none"> + Design research: <N> suggestions — <C> confirmed / <R> rejected / <E> escalated (model <MODEL>) + # or: Design research: skipped (<SKIP_REASON>) + Intake: research <depth> (confidence <c> | degraded | skipped), rounds <n>, open questions <m>, Jira <KEY | created KEY | none> + (intake mode only; after the output, run `/sdd-tojira` when Jira = create) + + To create a worktree for this feature after task decomposition: + git worktree add -b feat-<FEAT-ID>-<feature-name> \ + .claude/worktrees/feat-<FEAT-ID>-<feature-name> HEAD + +Next: + 1. Review the spec — check Acceptance Criteria and Architectural Design. + 2. Mark status: approved when ready. + 3. Run /sdd-task sdd/specs/<feature-name>.spec.md +``` + +**`TYPE == "hotfix"` (FEAT-466 — no id reserved):** +``` +✅ Spec created and committed: sdd/specs/<feature-name>.spec.md + + Identity: Jira <KEY> (no FEAT-<NNN> reserved — a bugfix is not a feature) + Base branch: main + + To create the worktree (origin/main, never HEAD/dev): + git worktree add -b hotfix-<KEY>-<feature-name> \ + .claude/worktrees/hotfix-<KEY>-<feature-name> origin/main + +Next: + 1. Review the spec — check Acceptance Criteria and Architectural Design. + 2. Mark status: approved when ready. + 3. Normally: skip /sdd-task and dispatch straight to development (the + single-agent path handles a one-or-two-commit hotfix directly from + the spec). Only run /sdd-task if this hotfix genuinely needs + decomposition — it will not reserve TASK-<NNN> ids either. +``` + +## Reference +- Template: `sdd/templates/spec.md` +- Existing specs: `sdd/specs/` +- SDD methodology: `sdd/WORKFLOW.md` +- Worktree policy: `CLAUDE.md` (section "Worktree Policy") +- Design-research brief: `sdd/templates/design_research.prompt.md` (FEAT-545) +- Design-research schema: `sdd/templates/design_research.schema.json` (FEAT-545) +- Intake procedure: `sdd/templates/intake.procedure.md` (FEAT-577) +- Intake schema: `sdd/templates/intake.schema.json` (FEAT-577) + +## Anti-Hallucination Policy + +The `## 6. Codebase Contract` section in the spec is **mandatory** for any spec +that references existing codebase components. A spec without a codebase contract +will produce tasks that hallucinate imports and attributes. + +**Quality bar**: Every entry in the contract must include a file path and line number. +Entries without verification evidence must be marked as `(unverified — check before use)`. \ No newline at end of file diff --git a/.claude/commands/sdd-start.md b/.claude/commands/sdd-start.md new file mode 100644 index 00000000..32ec9274 --- /dev/null +++ b/.claude/commands/sdd-start.md @@ -0,0 +1,321 @@ +--- +model: sonnet +description: /sdd-start — Start an SDD Task +# Implementation executor: resolves the task, checks deps, ensures the worktree and +# writes code. Pinned so it never inherits an Opus/Fable session model. +--- + +# /sdd-start — Start an SDD Task + +Pick up a task from the SDD task index by ID or slug, validate it is ready, mark it in-progress, +and begin implementation following the task's instructions. + +## Usage +``` +/sdd-start TASK-004 +/sdd-start lyria-music-tests +``` +Accept either the full ID (`TASK-NNN`) or the slug. If nothing is provided, run `/sdd-next` logic and ask the user to pick one. + +## Guardrails +- Do NOT start a task whose dependencies are not all `"done"`. +- Do NOT start a task that is already `"in-progress"` or `"done"` unless the user explicitly confirms. +- **Code AND per-spec index live together in the worktree (FEAT-145).** + Each feature owns its own `sdd/tasks/index/<feature>.json`, so parallel + worktrees never collide. The merge in `/sdd-done` brings the index file + to `base_branch` alongside the code. + +## Steps + +### 1. Resolve the Task +1. Glob `sdd/tasks/index/*.json` (excluding `_orphans.json`) and find the + per-spec index whose `tasks[]` array contains the requested ID or slug. + ```bash + for f in sdd/tasks/index/*.json; do + [[ "$(basename "$f")" == "_orphans.json" ]] && continue + if jq -e --arg q "<TASK-NNN-or-slug>" '.tasks[] | select(.id == $q or .slug == $q)' "$f" > /dev/null; then + INDEX="$f" + break + fi + done + ``` +2. Resolve `feature_id`, `feature` slug, and `spec` from the per-spec index header. +3. If no match is found, print available tasks (aggregate across all per-spec indexes) and ask the user to pick one. + +### 2. Validate Readiness +Check: +- **Status** must be `"pending"`. If `"in-progress"`, warn and ask to confirm resume; if `"done"`, abort. +- **Dependencies** — every task in `depends_on` must have status `"done"`. + If any dependency is not done, print: + ``` + ❌ TASK-<NNN> is blocked. + Waiting on: TASK-<X> (<status>), TASK-<Y> (<status>) + Resolve those first or run /sdd-status to see the full board. + ``` + and STOP. + +### 3. Ensure the Worktree + +The worktree is created by whoever is about to write code in it — not at +planning time (FEAT-552). `/sdd-task` no longer creates one, so this step +provisions it, idempotently: already inside the right worktree, it is a no-op +that prints the path you are already in. + +Everything the step needs is in the per-spec index header resolved in §1 +(`feature_id`, `feature`, `spec`, `type`, `base_branch`): + +```bash +WT=$(python -m scripts.sdd.ensure_worktree \ + --slug "<feature-slug>" \ + --feature-id "<FEAT-ID>" \ + --spec "<spec-path>" \ + --index "sdd/tasks/index/<feature-slug>.json") +cd "$WT" +``` + +For a hotfix (`type: hotfix` in the index header) pass `--jira-key <KEY>` +instead of `--feature-id`; the CLI applies the FEAT-466 naming and branches +from `origin/main`. + +If the command exits non-zero, **STOP** and show its message verbatim. Do NOT +fall back to implementing on `<base_branch>` — an un-isolated implementation is +exactly what this step exists to prevent. The two messages you are most likely +to see are a leftover branch with no worktree, and task artifacts missing from +the base you branched off (fetch and re-run). + +With per-spec indexes (FEAT-145), commits then land in the worktree's own +branch. Each feature owns its own index file, so parallel worktrees never +collide on shared mutable state. + +### 4. Mark In-Progress (in place) + +Update the per-spec index file directly in the current branch — no +directory switching needed (FEAT-145). + +```bash +INDEX="sdd/tasks/index/<feature-slug>.json" +NOW=$(date -u +%Y-%m-%dT%H:%M:%S+00:00) + +jq --arg id "<TASK-NNN>" --arg now "$NOW" ' + (.tasks[] | select(.id == $id) | .status) = "in-progress" | + (.tasks[] | select(.id == $id) | .started_at) = $now +' "$INDEX" > "$INDEX.tmp" && mv "$INDEX.tmp" "$INDEX" + +# CRITICAL: Unstage everything first — NEVER commit unrelated changes +git reset HEAD +# Stage ONLY the per-spec index — NEVER use "git add ." or "git add -A" +git add "$INDEX" +# Verify +git diff --cached --name-only +# If ANY other files appear, run "git reset HEAD" and start over + +git commit -m "sdd: start TASK-<NNN> — <title>" +``` + +The commit lives on the current branch. The merge in `/sdd-done` brings it +to `base_branch` alongside the code commit — atomically, with no conflict +surface (other features touch other per-spec index files). + +**Ledger `task.started` (FEAT-566, best-effort):** immediately after the +commit above, record the start in the shared work ledger — never blocking +on failure (missing ledger package, unwritable shared root, ...): + +```bash +python3 - "<TASK-NNN>" "<feature-slug>" <<'PYEOF' || true +import sys +from pathlib import Path + +task_id, feature_slug = sys.argv[1:3] +try: + from parrot.knowledge.wiki.ledger.events import LedgerEvent + from parrot.knowledge.wiki.ledger.log import LedgerLog + from parrot.knowledge.wiki.project import find_shared_root + + shared_root = find_shared_root(Path.cwd()) or Path.cwd() + ledger_dir = shared_root / ".parrot" / "ledger" + ledger_dir.mkdir(parents=True, exist_ok=True) + event = LedgerEvent( + kind="task.started", subject=f"task:{task_id}", + actor="agent:sdd-start", payload={"feature": feature_slug}, + ) + LedgerLog(str(ledger_dir / "events.jsonl")).append(event) +except Exception as exc: # noqa: BLE001 — ledger emission never blocks /sdd-start + print(f"⚠️ task.started ledger emission skipped: {exc}", file=sys.stderr) +PYEOF +``` + +Log-only append (same durability guarantee as `close_task.sh`'s +`task.closed` — spec §2: "never takes a database lock") — there is no +SQLite writer contention to handle here. + +### 5. Read Context +1. Read the **task file** at the path from the index. +2. Read the **spec file** referenced in the task header. +3. Extract: + - Scope and implementation notes + - Files to create/modify + - Acceptance criteria + - Test specification + +### Prime with Ledger Context (FEAT-566, best-effort) + +Before implementing, surface open ledger issues/insights that intersect the +task's declared file/symbol scope — never fatal, never blocking on a busy or +unbuilt ledger: + +```bash +wikitoolkit ledger context <file-1> <file-2> ... 2>/dev/null || true +``` + +Fold any non-empty output into the context you carry into Step 7 — it is +informational (known related issues, prior insights), not a gate. + +### 6. Print Kickoff Summary +Output: +``` +🚀 Starting TASK-<NNN>: <title> + Feature: <feature> + Branch: <current branch name> + Priority: <priority> | Effort: <effort> + Depends-on: <deps or "none"> + +📋 Scope: + - <scope item 1> + - <scope item 2> + +📂 Files: + - <file1> (CREATE) + - <file2> (MODIFY) + +✅ Acceptance Criteria: + - <criterion 1> + - <criterion 2> +``` + +> **Do NOT stop here.** The kickoff summary is informational only. Proceed immediately to Step 7. + +### 7. Begin Implementation (in the worktree) + +> **CRITICAL — THIS IS THE CORE PURPOSE OF `/sdd-start`.** +> Do NOT stop after printing the kickoff summary. +> You MUST proceed to actually implement the task code NOW. +> The kickoff summary is just informational; the real work starts here. + +Follow the **Agent Instructions** section in the task file: + +1. Read the spec for full context. +2. **Verify the Codebase Contract (Anti-Hallucination Check):** + Before writing ANY code, verify every entry in the task's `## Codebase Contract`: + - `grep` or `read` each file listed in "Verified Imports" to confirm the imports exist. + - `read` each file in "Existing Signatures" to confirm class/method signatures are accurate. + - Check the "Does NOT Exist" section — do NOT reference anything listed there. + - If any entry is stale (file moved, method renamed), update the contract in the + task file FIRST, then proceed with implementation using the corrected references. + - **NEVER guess an import or attribute. If unsure, verify with `grep` or `read` first.** +3. **Actually write the code** — create/modify the files listed in the task scope. + Use ONLY the imports and signatures from the verified Codebase Contract. +4. Run linting and fix any issues. +5. Run the acceptance-criteria tests from the task. +6. Verify **all** acceptance criteria are met. +6. **Commit code in the worktree:** + ```bash + # CRITICAL: Unstage everything first — NEVER commit unrelated changes + git reset HEAD + # Stage ONLY the files created/modified by this task — NEVER use "git add ." or "git add -A" + git add <task-scoped-files-only> + # Verify ONLY task files are staged + git diff --cached --name-only + # If ANY unrelated files appear, run "git reset HEAD" and start over + git commit -m "feat(<feature-slug>): TASK-<NNN> — <title>" + ``` + +**⚠ STOP condition**: Only stop (ask the user) if: +- A dependency is missing or broken. +- The spec is ambiguous and you need clarification. +- Tests are failing and you cannot determine the fix. + +Otherwise, keep going until the task is **done**. + + +#### Delegated implementation (only when the task has `## Delegation Contract`) + +Use this branch ONLY when the task file contains a `## Delegation Contract` +section AND the `parrot-targeted-writer` MCP server is available. Otherwise +implement the task yourself — the normal route is the default. + +1. Call MCP tool `writer_generate` (server `parrot-targeted-writer`) with `task_path`. +2. On `status: error` with a contract code (`stale_target`, `missing_block`, + `placeholder_code`, `underspecified_create`, …): fix the packet in the task file + (refresh hashes with `sha256sum`, complete the design) and retry once, or implement + the task yourself. The workflow **never silently invokes another coder** — no other + coding tool is substituted when delegation fails. +3. On `ok`: read `data.patch_path` with `source_read` in ranges of at most 350 lines and + review EVERY hunk against the task's Codebase Contract. Never apply a patch you have + not fully read. If a hunk is wrong, do not apply: fix the packet/blocks and regenerate + at most once more, else implement normally. +4. Call `writer_apply` with `artifact_id` and `reviewed_sha256 = data.patch_sha256` + (verify it equals `sha256sum artifacts/tool-optimizations/<id>/patch.diff`). +5. Run the task's acceptance tests yourself. The writer never runs tests; a model's claim + that tests passed is not execution evidence. +6. Continue with the normal validate → commit → SDD state steps. SDD files + (`sdd/tasks/index/*.json`, task files) are never edited by the writer. + +### 8. Mark Done (in place) + +## Deterministic task inspection and closure (FEAT-584) +Keep wiki-first and read the complete task contract through bounded references. +Prefer task-context inspection when the engine is present; fallback keeps explicit checks. +Use the existing declared test selector, environment protection and semantic delivery review. +Finalize only with structured green evidence and the exact implementation HEAD; call +python -m scripts.sdd.finalize_task, inspect returned staged paths and commit explicitly. +Reject stale evidence and divergent active/completed twins; never reset unrelated staging. +Do not compact for every task. Feature handoff uses a durable checkpoint and fresh reviewer; +Codex/Antigravity without a verified adapter report unsupported_host, never call Claude /compact. + +After the code is committed, update the per-spec index in the same branch +— no `cd` to the main repo (FEAT-145). + +> **CRITICAL — use the script, do NOT hand-roll the move.** Closing a task +> means *moving* its file from `active/` to `completed/`. Agents that paraphrase +> this as a `Write`/copy leave the `active/` file behind; when the feature +> branch merges, both copies land on the base branch as a "stalled" orphan. +> `scripts/sdd/close_task.sh` does the move with `git mv` and HARD-VERIFIES that +> no `active/` copy survives (exit 3 if it does). Always call it verbatim. + +```bash +# Move active → completed, stamp the index (status/completed_at/verification/file), +# stage the change, and assert active/ is clean. Idempotent. +scripts/sdd/close_task.sh TASK-<NNN> <feature-slug> verified + +# Fill in the Completion Note section of the moved file (now in completed/). +# Then commit ONLY the staged SDD state — never "git add ." / "git add -A". +git diff --cached --name-only # sanity-check: only index + task files +git commit -m "sdd: complete TASK-<NNN> — <title>" +``` + +### 9. Post-Completion Hint +After marking the task done, suggest next steps: +``` +✅ TASK-<NNN> completed. + Code + per-spec index committed on branch: <current branch> + +Next in this feature: + → /sdd-start TASK-<NEXT> (<title>) + +Or see all unblocked work: + → /sdd-next +``` + +If this was the **last task** for the feature: +``` +✅ TASK-<NNN> completed — all tasks for FEAT-<ID> are done! + +Next: + - Run /sdd-done FEAT-<ID> to verify, push, and cleanup +``` + +## Reference +- Per-spec index files: `sdd/tasks/index/<feature>.json` +- Task template: `sdd/templates/task.md` +- SDD methodology: `sdd/WORKFLOW.md` +- Frontmatter parser: `scripts/sdd/sdd_meta.py` diff --git a/.claude/commands/sdd-status.md b/.claude/commands/sdd-status.md new file mode 100644 index 00000000..ccb2d65e --- /dev/null +++ b/.claude/commands/sdd-status.md @@ -0,0 +1,204 @@ +--- +model: haiku +--- + +# /sdd-status — SDD Task Board + +Aggregate task state across all per-spec indexes (`sdd/tasks/index/*.json`) +and print a human-friendly status report. + +## Usage +``` +/sdd-status +/sdd-status <feature-name> +/sdd-status --project <project> [--project …] [--tag <tag> …] +``` + +## Guardrails +- If `sdd/tasks/index/` is empty or does not exist, inform the user and suggest running `/sdd-task` first. +- Read-only — do not modify any files. +- Show orphans (tasks rescued by the migration script with no feature attribution) in a dedicated panel — they are tracked but never suggested as work by `/sdd-next`. + +## Steps + +### 1. Read All Per-Spec Indexes (FEAT-145) + +Glob `sdd/tasks/index/*.json` and load each per-spec index. The header +fields (`feature`, `feature_id`, `spec`, `type`, `base_branch`, +`completed_at`) drive the per-feature panel; the `tasks[]` array drives +the task lines. + +```bash +ALL=$(jq -s '.' sdd/tasks/index/*.json) +``` + +If a `<feature-name>` filter is provided, show only the index whose +`feature` slug matches (substring) or whose `feature_id` matches exactly. + +If `--project` / `--tag` is given (FEAT-576), resolve the matching specs from +their frontmatter — the index header does not carry taxonomy — and keep only +indexes whose `spec` is in the list (AND across flags, OR within a repeated +flag; `_orphans.json` is excluded because it has no spec): + +```bash +SPECS=$(python -m scripts.sdd.doc_taxonomy --kind spec --paths-only --project <p> --tag <t>) +ALL=$(jq -s --arg specs "$SPECS" '[.[] | select(.spec as $s | ($specs | split("\n")) | index($s))]' sdd/tasks/index/*.json) +``` + +### 1.5. Reconcile With Worktree State (FEAT-582) + +A feature's tasks can be ahead **in the worktree** (`sdd-worker` updates the +worktree's copy of the index; `dev` only learns about it when `/sdd-done` +merges), and they can be ahead **on dev** (the worktree was branched before +`/sdd-task`/`/sdd-start` stamped the index, or it was left behind after the +feature merged). So do **not** prefer one side — reconcile them: + +```bash +FEATURES=$(python -m scripts.sdd.worktree_status --reconcile --json 2>/dev/null || echo "[]") +WT_REPORTS=$(python -m scripts.sdd.worktree_status --json 2>/dev/null || echo "[]") # health, for §5 +``` + +`--reconcile` merges every per-spec index on this branch with its worktree's +index and returns one `ReconciledFeature` per feature. The merge rule is +deterministic and **monotonic — a worktree may only advance a task, never roll +it back**: + +- The worktree status wins only when it ranks strictly higher + (`pending` < `in-progress` < `done` = `done-with-issues`). +- `done` and `done-with-issues` tie, and a tie keeps dev's value — the + merged record is authoritative about which terminal state a task reached. +- Tasks that exist only in the worktree index are appended (they were + generated in-worktree and never merged). +- A worktree with `index_found: false` contributes nothing. + +Use `FEATURES` for the task lines in §2 (its `tasks[]` is already merged; +each task carries `source: "dev" | "worktree"`), and label the feature header +from the flags: + +- `worktree_ahead: true` → `(worktree ahead: <worktree_branch>)` +- `worktree_only: true` → `(worktree only: <worktree_branch>)` — the spec and + tasks exist only inside the worktree, nothing on this branch yet +- `worktree_stale: true` → `(stale worktree: <worktree_branch> — dev is ahead)`; + when `dev_closed` is also true the feature is finished and the worktree is a + leftover that `/remove-worktree` can drop +- no flag → no label; the dev index and the worktree agree + +Never let a `worktree_stale` feature print as pending/not-started: that is the +FEAT-561 failure this rule exists to prevent — a worktree left over from a +feature that merged on 2026-09-16 reported all 10 of its tasks as pending. + +### 2. Group and Display + +The task `status` field has **exactly four** valid values — match each +task's status literally against this set; never fall through to a +default bucket for a status string you don't recognize: + +- `"pending"` → ⏳ Pending +- `"in-progress"` → 🔄 In-Progress +- `"done"` → ✅ Done +- `"done-with-issues"` → ⚠️ Done (with issues) — a task `sdd-worker` + completed but flagged after being unable to satisfy every acceptance + criterion within scope (see its Completion Note for details). This is + a completed state, NOT pending — never group it under ⏳ Pending. + +Group tasks by status (in-progress → pending → done-with-issues → done) +and by feature. Print: + +``` +📊 SDD Task Board +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Feature: <feature> +Spec: sdd/specs/<feature>.spec.md +Projects: <a, b> · Tags: <x, y> + + 🔄 In-Progress + TASK-<NNN> — <title> [<priority>/<effort>] assigned: <who> + + ⏳ Pending + TASK-<NNN> — <title> [<priority>/<effort>] blocked-by: <deps or —> + + ⚠️ Done (with issues) + TASK-<NNN> — <title> — see Completion Note in the task file + + ✅ Done + TASK-<NNN> — <title> + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Summary: <N> done / <N> done-with-issues / <N> in-progress / <N> pending / <N> total | <W> worktrees (<R> ready for /sdd-done) +``` + +### 3. Highlight Blockers +If any pending tasks are blocked (deps not done), add a blockers section: +``` +⚠ Blockers: + TASK-<NNN> waiting on TASK-<X> (<status>) +``` + +### 4. Show Orphan Tasks (FEAT-145) + +If `sdd/tasks/index/_orphans.json` exists and has any tasks, append a +final panel after the main board: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠ Unowned tasks (no feature attribution): + + TASK-<NNN> — <title> [<status>] + +These were rescued by the FEAT-145 migration but lack a feature link. +Consider relocating them via /sdd-task or removing them. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +If `_orphans.json` does not exist or has an empty `tasks[]` array, skip this panel silently. + +### 5. Show Worktree Summary (FEAT-582) + +After the orphan panel, show a worktree health panel for all SDD worktrees +from the `WT_REPORTS` data: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🌳 Worktrees (N active): + + feat-FEAT-550-token-budget-bedrock + Branch: feat-FEAT-550-token-budget-bedrock + Tasks: 14/14 done | Health: clean | ✅ Ready for /sdd-done + + feat-FEAT-582-sdd-status-worktrees + Branch: feat-FEAT-582-sdd-status-worktrees + Tasks: 2/5 done | Health: 3 dirty, 1 unpushed | 🔄 In progress + + chore-ruff-config (non-SDD) + Branch: chore-ruff-config + Health: clean +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +Health flags: +- `clean` = dirty_count == 0 AND unpushed_count == 0 +- `N dirty` = dirty_count > 0 +- `N unpushed` = unpushed_count > 0 +- `N live processes` = live_process_count > 0 + +Ready-for-done flag: +- `✅ Ready for /sdd-done` when `ready_for_done: true` +- `🔄 In progress` when tasks are not all done +- `⚠️ Done but needs cleanup` when all done but dirty/unpushed +- `🧹 Stale — dev is ahead` when the feature's reconciled entry (§1.5) has + `worktree_stale: true`; add `— feature already closed, /remove-worktree` + when `dev_closed` is also true + +Non-SDD worktrees (those with no parsed feature_id) show health only, no task +counts. + +Update the Summary line to include: +``` +Summary: <N> done / ... / <N> total | <W> worktrees (<R> ready for /sdd-done) +``` + +## Reference +- Worktree reconciliation: `scripts/sdd/worktree_status.py` (`--reconcile`) +- Per-spec index files: `sdd/tasks/index/*.json` +- Orphans file: `sdd/tasks/index/_orphans.json` (only present if migration found unattributable tasks) +- SDD methodology: `sdd/WORKFLOW.md` diff --git a/.claude/commands/sdd-task.md b/.claude/commands/sdd-task.md new file mode 100644 index 00000000..76413fe5 --- /dev/null +++ b/.claude/commands/sdd-task.md @@ -0,0 +1,495 @@ +--- +model: opus +description: /sdd-task — Decompose a Spec into SDD Tasks +# Per-task Codebase Contract, Complexity/Delegation Contract and Implementation +# Blueprint. Pinned to opus so it neither inherits Fable 5.1 (2x the rate) nor +# drops to sonnet for the per-edit-site verification work. +--- + +# /sdd-task — Decompose a Spec into SDD Tasks + +Decompose an approved Feature Specification into atomic, assignable implementation tasks. + + +## Usage +``` +/sdd-task sdd/specs/<feature-name>.spec.md +/sdd-task sdd/specs/<feature-name>.spec.md --from-issue <issue-id> +``` + +`--from-issue <issue-id>` (FEAT-566, optional): seed one generated task from +an open ledger issue (`wikitoolkit ledger ready` / `/sdd-next`'s "Ready +ledger issues" section) instead of writing its Context/Scope from scratch. +Promotion is always explicit — no ledger issue is ever auto-promoted. + +## Guardrails +- Only decompose specs with `status: approved`. +- Each task must be independently implementable and testable. +- Check `sdd/tasks/index/<feature>.json` for existing tasks to avoid duplication. +- Do NOT write the full implementation — but every task MUST carry an + **Implementation Blueprint** (executor-ready per-file code blocks + why + + `FILL IN` checklist, see §3). Blueprints stop at the mechanical parts; + branches, edge cases and test bodies stay as `FILL IN` stubs. +- Build the task graph by §3's **Task graph rules** — `depends_on` is the only + ordering, `parallel: false` means *exclusive* — and run + `scripts/sdd/check_task_graph.py` on the index before committing (§4b). +- **`TASK-<NNN>` numbers are reserved via `scripts/sdd/reserve_ids.py` + (FEAT-387), never hand-computed by scanning existing files for the + highest number in use.** That scan-and-increment approach has no lock + and no re-check against `origin/<base_branch>` immediately before + committing, so two `/sdd-task` runs racing each other (e.g. concurrent + dev-loop planner dispatches) can silently allocate the same number to + different features. See §4 below. +- **Must run on the spec's `base_branch`** (read from frontmatter — `dev` for features, `main` for hotfixes). Not inside a worktree. +- **Always commit task files and per-spec index to `base_branch`** — they are + versioned artifacts, and the machine that implements the feature pulls them + from there. This command creates no worktree (FEAT-552). + +## Steps + +### 1. Sync the Base Branch (FEAT-145) + +Read the spec's frontmatter to determine the base branch, switch to it, and pull from origin: + +```bash +META=$(python -c "from pathlib import Path; from scripts.sdd.sdd_meta import parse; m = parse(Path('<spec-path>')); print(m.type, m.base_branch)") +TYPE=$(echo "$META" | awk '{print $1}') +BASE=$(echo "$META" | awk '{print $2}') + +git checkout "$BASE" +git pull --ff-only origin "$BASE" +``` + +For `type: hotfix`, `BASE` MUST be `main`. For `type: feature`, `BASE` defaults +to `dev` and may be `staging` (during a release freeze) or any non-main branch +(sub-features extend a parent feature branch — see `CLAUDE.md`). + +**A hotfix normally does not reach this command at all (FEAT-466).** +`sdd-research.md` skips `/sdd-task` entirely for `type: hotfix` runs — a +one-or-two-commit bugfix is handled directly by the dev-loop's single-agent +path (spec §3 Module 2's "Interaction with Module 7": no per-spec task index +⇒ `DevelopmentNode._build_scheduler` returns `None` ⇒ single-agent dispatch, +made to honour the operator's declared dev agent by TASK-2506). If a human +invokes `/sdd-task` directly against a `type: hotfix` spec anyway (e.g. an +unusually large hotfix that genuinely benefits from decomposition), proceed +but see §4's hotfix branch below — it does **not** reserve `TASK-<NNN>` ids. +A hotfix's per-spec index header carries `"feature_id": null` (there is no +reserved `FEAT-<NNN>`) and a `"jira_issue_key"` field carrying the Jira key +instead — the identity the rest of the flow labels this run by. + +**Validation:** if `TYPE == "feature"` and `BASE_BRANCH == "main"`, abort: +``` +⚠️ type='feature' cannot base on 'main'. Features land on dev (default) + or staging (during a release freeze). For changes that must base on + main, set type='hotfix' in the document frontmatter. +``` + +Note: `staging` is a valid `base_branch` for `type: feature` during a release freeze +(FEAT-187). Use `base_branch: staging` when decomposing stabilization fixes for a +frozen release candidate. + +**Abort conditions (do NOT stash or auto-resolve):** +- Working tree dirty: `⚠️ Cannot sync <BASE>: working tree has uncommitted changes. Stash or commit first, then re-run /sdd-task.` +- `--ff-only` fails: `⚠️ Cannot fast-forward <BASE>. Reconcile manually (git pull --rebase or merge), then re-run /sdd-task.` + +**Refuse** if the user is currently inside a worktree: +``` +⚠️ /sdd-task must run from the main repo on <BASE>, not inside a worktree. + cd back to the main repo and re-run. +``` + +### 2. Read the Spec +Read the spec file provided by the user (e.g., `sdd/specs/<feature>.spec.md`). +- If spec is not `status: approved`, warn and ask to confirm. +- Extract: Feature ID, title, module breakdown, acceptance criteria, dependencies. + +### 3. Plan Task Decomposition +Analyze the spec and identify atomic tasks: +- One task per module, class, or distinct deliverable. +- Order tasks to respect implementation dependencies. +- Aim for tasks completable in 1–4 hours each. + +**Task graph rules (`depends_on`, `parallel`, `parallelism_notes`):** + +Every task runs in its own sub-worktree, dispatched by the `sdd-coder` engine as +soon as its `depends_on` are done; tasks whose dependencies are met run +**concurrently**. So the graph, not the prose, decides how long a feature takes +(querysource FEAT-147: 18 tasks chained 1→2→…→18 with one copy-pasted +rationale ran strictly in series for ~7 h). + +- **`depends_on` is the ONLY ordering.** Add `B depends_on A` only with evidence: + - B imports, calls, extends or tests a symbol A **creates**; or + - B consumes a file/table/config/fixture A creates; or + - B and A both **modify the same file** (serialize them: the lower id first). + + Never add an edge because tasks share a module, a spec section or a "phase", or + "to be safe". A spec that says tasks run *sequentially* or *per-spec* means ONE + feature worktree — it is NOT a dependency chain. Transitive edges may be + omitted. +- **`parallel` defaults to `true`.** Set `parallel: false` (*exclusive* — never + dispatched alongside any other task, even with its dependencies met) only when + the task mutates shared state **outside its declared files**: rebuilding + compiled extensions (Cython `make build-inplace`, maturin), editing dependency + manifests or lockfiles (`pyproject.toml`, `uv.lock`), running DDL/migrations + against a shared database, regenerating shared generated code, or changing a + `conftest.py` other tasks' tests load. +- **`parallelism_notes` is per task and names its evidence**: for each + `depends_on` edge, the dependency id plus the symbol/file it needs + (`"imports TenantRegistry from TASK-716 (querysource/tenants.py)"`); for an + exclusive task, the shared resource. Identical notes on several tasks is a + defect. +- Write `"parallel_semantics": "exclusive"` in the index header (see schema) — + without it the engine ignores every `parallel` flag. + +**CRITICAL — Codebase Contract per Task (Anti-Hallucination):** +For EACH task, you MUST populate its `## Codebase Contract` section: + +1. **Extract from the spec's Section 6 (Codebase Contract)**: copy the verified imports, + signatures, and "Does NOT Exist" entries that are relevant to THIS specific task. +2. **Verify freshness**: `read` or `grep` each referenced file to confirm the signatures + are still accurate. Code may have changed since the spec was written. +3. **Add task-specific references**: if the task touches files not covered by the spec's + contract, read those files now and add their signatures. +4. **Be precise about scope**: only include imports/signatures the task actually needs. + A task that modifies one module does not need signatures from unrelated modules. +5. **Include the "Does NOT Exist" section**: this is the strongest anti-hallucination + measure. List plausible-sounding things that an agent might assume exist but don't. + +**Quality bar**: A task without a populated Codebase Contract section is incomplete. +The implementing agent (often Sonnet or Haiku) WILL hallucinate if not given +explicit, verified code anchors. + +**CRITICAL — Implementation Blueprint per Task (Executor Readiness, FEAT-545):** +For EACH task, you MUST populate its `## Implementation Blueprint` section so a +non-thinking executor (Haiku) can write the declared code to disk and complete +only the marked gaps: + +1. **One block per file** listed in "Files to Create / Modify" — CREATE blocks + are whole-file starting points; MODIFY blocks quote the verified anchor line + they attach to (`# AFTER — insert below \`<anchor>\` (verified: path:NN)`). + **MODIFY blocks MUST state the anchor's occurrence count** + (`# occurrences: <N> (verified: grep -c '<anchor>' path)`); if `<N>` is `> 1`, + the block is `# FILL IN: disambiguate — quote enough surrounding context (2–3 + lines) to make the anchor unique` instead of a bare one-line anchor. +2. **Mechanical code is complete**: imports, class/function signatures, + docstrings, `self.logger` calls, registration/wiring, return types. +3. **Judgement calls are `FILL IN` stubs**: `# FILL IN: <decision> — bounded by + <constraint | AC-N>`. Never leave a gap without the constraint that bounds it. +4. **Every import comes from the task's Verified Imports** — the blueprint may + not introduce a symbol the Codebase Contract does not list. +5. **Derive from the spec's Interface Skeletons** (spec §3) and re-verify the + anchors now; signatures fixed by the skeleton are not renegotiable. + **Start from the spec's §6 Edit Sites table** when it is populated: it already + carries the verbatim anchor, its `path:NN` and its occurrence count for every + file the modules touch, so do not search for the anchor again — but the table + was verified at spec time and code moves, so for every row you use, re-run + `grep -c '<anchor>' <path>` and use the fresh count. A count that no longer + matches the table means the anchor moved: re-locate it and correct the row's + `path:NN` in the task's blueprint (the spec is not rewritten at task time). + A count of `0` means the anchor is gone — stop and report the drift rather + than inventing a new attachment point. If §6 has no Edit Sites table (spec + predates it), derive the anchors yourself as in the rest of this step. +6. **Size cap**: no block over ~80 lines. If a file needs more, split the task. +7. **Explain-for-executor rule**: every non-trivial decision is written as an + imperative instruction *plus its reason* ("do X — because Y"), in the + Steps list and in the **Why** paragraph under each block. Do not rely on + the executor to infer intent. +8. **Steps (in order)** and the **FILL IN checklist** are mandatory even when + a task has a single file. + +**Quality bar**: A task without a populated Implementation Blueprint section is +incomplete — same bar as the Codebase Contract. If the blueprint would be the +full implementation, the task is too small; if it needs more than ~80 lines per +file, the task is too big. + +### 4. Generate Tasks +1. Ensure `sdd/tasks/active/` directory exists (create if needed). +2. Read the task template at `sdd/templates/task.md`. +3. **Reserve task IDs — the mechanism depends on `TYPE` (FEAT-466):** + + **`TYPE == "feature"`** — reserve `TASK-<NNN>` IDs via the git-native + compare-and-swap ledger (FEAT-387), never scan existing files and + increment by hand: + ```bash + TASK_IDS=$(python -m scripts.sdd.reserve_ids --kind task --count <N> \ + --base-branch "$BASE" --label <feature-slug>) + ``` + Where `<N>` is the total number of tasks about to be generated for this + spec. On success this prints exactly `<N>` lines, one `TASK-<NNN>` per + line, e.g.: + ``` + TASK-1968 + TASK-1969 + TASK-1970 + ``` + `reserve_ids.py` pushes its own ledger-only commit to `origin/<BASE>` as + part of this call (retrying internally on a non-fast-forward rejection); + it refuses to run if tracked files have any uncommitted changes besides + the ledger file. The commit is built on `origin/<BASE>` with git + plumbing and pushed by sha, so local-only commits on `<BASE>` are + neither published nor discarded by a lost race. If the command exits + non-zero (retries exhausted, or the working tree wasn't clean), **STOP** + and report the error to the user — do NOT fall back to hand-computing a + number. + + **`TYPE == "hotfix"`** — do **NOT** call + `reserve_ids.py --kind task`. A hotfix is not a feature and reserves no + ledger id at all (same rationale as `/sdd-spec` §5's `FEAT-<NNN>` skip). + Number tasks **locally within this spec only**, as + `HOTFIX-<JIRA-KEY>-1`, `HOTFIX-<JIRA-KEY>-2`, … — these are literal + string ids scoped to this hotfix's own index file, never compared + against or drawn from `sdd/tasks/.id_ledger.json`'s `TASK-<NNN>` + namespace. (Note: this is the defensive path for the rare case a human + runs `/sdd-task` directly against a hotfix spec — the *normal* hotfix + flow skips `/sdd-task` entirely; see §1 above.) +4. For each task, create `sdd/tasks/active/<id>-<slug>.md` using the + template (`<id>` is `TASK-<NNN>` for a feature, `HOTFIX-<JIRA-KEY>-N` + for a hotfix) — consume the reserved/assigned ids in order, one per + task. Use each id verbatim for both the filename and every `id` field + in the per-spec index; never invent, recompute, or reuse a `TASK-<NNN>` + number outside of what `reserve_ids.py` returned. + + **`--from-issue <issue-id>` (FEAT-566):** the promoted task still gets a + normally-reserved `TASK-<NNN>` from `reserve_ids.py` above — a ledger + issue id is never used as (or in place of) a task id. Seed the task's + Context/Scope from the selected issue and its file context. When called by + `/sdd-fix`, preserve its selected FixPlan/claimed issue as the handoff; do not + re-query ready work to recover an issue that is now claimed. For standalone + promotion, locate the issue in `wikitoolkit ledger plan-fix --json` first. + Use `wikitoolkit ledger context <issue.files...>` with repo-relative file + paths, not the issue ID. Include `discovered_from: <issue-id>` in the task. + Promotion never runs `ledger close`: filing a task is not resolution. + Close separately only after implementation and validation, with the + two-key evidence required by `/sdd-fix`. + **Deprecated (FEAT-572)**: `--from-issue` remains for one deprecation cycle but is no + longer the ledger entry point — it can only append a task to an *existing* spec, which + for a finished feature (per-spec index `completed_at` set) is wrong. Use `/sdd-fix + <issue-id>` instead: it plans the issue's group, routes it to the Fast lane (branch → PR) + or the SDD lane (reuse the open parent spec, else mint a fresh `FEAT-<NNN>`), and closes + by evidence. + Fill the template's `## Implementation Blueprint` section for every task + per §3's rules; a task without one is incomplete. + +**CRITICAL — Task file header must include the Feature ID:** +The `**Feature**:` line at the top of every task file MUST combine the formal +Feature ID and the human-readable feature title, separated by an em-dash: +``` +**Feature**: FEAT-<NNN> — <Feature Title> +``` +Example: `**Feature**: FEAT-015 — PlaywrightDriver` + +Do NOT use the kebab-case slug alone (e.g., `**Feature**: playwrightdriver`) — +this loses the ability to trace which formal feature the task belongs to. +The slug is already captured in the `feature` field of `.index.json`; the +task header must surface the Feature ID for humans scanning the file. + +Create or update the **per-spec index** at `sdd/tasks/index/<feature>.json` +(NOT the legacy monolith — that file is preserved as a historical artifact +and ignored by all FEAT-145 commands). Schema: + +```json +{ + "feature": "<feature-slug>", + "feature_id": "FEAT-<NNN>", + "spec": "sdd/specs/<feature-slug>.spec.md", + "type": "feature", + "base_branch": "dev", + "created_at": "<ISO-8601>", + "completed_at": null, + "parallel_semantics": "exclusive", + "validation_contract": "required", + "tasks": [ + { + "id": "TASK-<NNN>", + "slug": "<slug>", + "title": "<title>", + "feature_id": "FEAT-<NNN>", + "feature": "<feature-slug>", + "spec": "sdd/specs/<feature>.spec.md", + "status": "pending", + "priority": "<high|medium|low>", + "effort": "<S|M|L|XL>", + "depends_on": [], + "parallel": true, + "parallelism_notes": "<per-task evidence: 'needs <symbol> from TASK-<X> (<file>)' | exclusive resource>", + "assigned_to": null, + "started_at": null, + "completed_at": null, + "file": "sdd/tasks/active/TASK-<NNN>-<slug>.md" + } + ] +} +``` + +**Header fields (`type`, `base_branch`)** are populated from the spec's +frontmatter (resolved in §1 above). If `sdd/tasks/index/<feature>.json` +already exists (created by the migration script for older specs), append +the new tasks to its `tasks[]` array — do NOT overwrite the header. Do not add +`parallel_semantics` to an existing header whose tasks were written before these +rules: their `parallel: false` meant something else and would serialize them. + +**Index location helper:** +```bash +INDEX="sdd/tasks/index/<feature-slug>.json" +mkdir -p "$(dirname "$INDEX")" +``` + +**Field clarification:** +- `feature_id`: Formal Feature ID from the spec (e.g., `"FEAT-014"`). + **`null` for a hotfix** (FEAT-466 — no id reserved); use `jira_issue_key` + as the identity instead. +- `feature`: Kebab-case slug (e.g., `"videoreel-visual-changes"`). + +#### Delegation Contract (optional, per task) + +Emit a `## Delegation Contract` packet ONLY for a task whose design is +complete. `design_complete: true` is a declaration the task author signs. + +- List every target file with its `action` (`create`/`modify`), and give each + `modify` target a REAL `expected_sha256` — compute it, never guess: + `sha256sum <path>` or + `python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" <path>`. +- Every `create` target needs a block tagged `path=<target>` holding the new + file's full content; every referenced block id must exist in the task file. +- Never leave placeholders (`...`, `TODO`, `FIXME`, `XXX`, `<angle>`, + `raise NotImplementedError`) in an implementation block — the validator + rejects them and the packet is not delegated. +- Hashes are re-validated at execution time, after dependencies land. If they + are stale then, the executor refreshes the packet in the task file FIRST and + only then re-runs `writer_generate`. +- Omit the section entirely when the task is not eligible. Most tasks are not, + and that is the normal, expected route. + +#### Validation Commands (mandatory, per task — FEAT-563) + +Every generated task MUST carry a `## Validation Commands` section placed right +after `## Acceptance Criteria`: one bullet per command, each a backticked +`pytest` invocation whose operands are **test files or node ids** — never a +directory, never `tests/` or `packages/<dist>/tests`, never a bare `pytest`. + +```markdown +## Validation Commands +- `pytest packages/ai-parrot/tests/flows/dev_loop/test_scope/test_mirror.py -q` +- `pytest tests/sdd_scripts/test_check_task_graph.py::test_validation_contract_findings -q` +``` + +This is what the sdd-coder guard rewrites a broad pytest to. New per-spec index +headers MUST include `"validation_contract": "required"`; `check_task_graph` +then reports `missing-validation-commands`, `broad-validation-command` and +`directory-validation-target` as errors (`validation-path-unknown` warns). + +#### Complexity Contract (mandatory, per task) + +Every generated task MUST carry a `## Complexity Contract` section containing +a JSON block with `schema_version: 1`. +- **`targets`**: A list of objects with `path` (repo-relative path) and + `action` (`"CREATE"` or `"MODIFY"`, uppercase) matching the "Files to + Create / Modify" table exactly. +- **`contract_symbols`**: A list of exact symbol IDs referenced by the + Codebase Contract (e.g., + `"sym:packages/ai-parrot/src/parrot/flows/dev_loop/sdd_coder/models.py#RosterConfig"`). + If there are no existing symbol references, use an empty list `[]` (do not + omit the field — an absent list means legacy/unknown coverage). +- Legacy tasks without this section default to unknown complexity. + Natural-language assurances cannot downgrade this — the deterministic + evaluator, not the task author's prose, decides classification. + +Example: +```json +{ + "schema_version": 1, + "targets": [ + { + "path": "packages/ai-parrot/src/parrot/flows/dev_loop/sdd_coder/complexity.py", + "action": "MODIFY" + } + ], + "contract_symbols": [] +} +``` + +### 4b. Validate the Task Graph + +Run the deterministic graph check on the index you just wrote: + +```bash +python -m scripts.sdd.check_task_graph sdd/tasks/index/<feature-slug>.json +``` + +- **Errors** (exit 1) block the commit: `file-overlap` (two tasks declare the + same file with no dependency path between them — they would run concurrently + and conflict), `unknown-dependency`, `cycle`. Fix the graph, re-run. +- **Every warning is resolved, not ignored**: `unjustified-edge` → remove the + edge, or write its evidence in `parallelism_notes` (naming the dependency id); + `possible-missing-dependency` → add the edge or confirm the reference is not + a use; `duplicate-notes` / `exclusive-without-notes` → write per-task notes. +- FEAT-563 validation-contract codes: `missing-validation-commands`, + `broad-validation-command`, `directory-validation-target` (errors when the + header requires the contract) and `validation-path-unknown` (warning) — add + or fix the task's `## Validation Commands` section. +- Copy the report's first line (`<N> tasks, <W> waves, max width <M>`) into the + §6 output. A width of 1 on a multi-task feature needs a one-line justification. + +### 5. Commit Tasks and Per-Spec Index to `<BASE>` + +> **CRITICAL — Only commit the per-spec index and the new task files. NEVER +> commit unrelated changes.** Other files may be modified or unstaged in the +> working directory — do NOT touch them. Follow the exact sequence below. + +> **CRITICAL — Only commit task files and the index. NEVER commit unrelated changes.** +> Other files may be modified or unstaged in the working directory — do NOT +> touch them. Follow the exact sequence below. + +```bash +# 1. Unstage everything first to ensure a clean staging area +git reset HEAD + +# 2. Stage ONLY the per-spec index and new task files — NEVER use "git add ." or "git add -A" +git add sdd/tasks/index/<feature-slug>.json +git add sdd/tasks/active/TASK-* + +# 3. Verify ONLY task files are staged (nothing else) +git diff --cached --name-only +# Expected: sdd/tasks/index/<feature-slug>.json and sdd/tasks/active/TASK-*.md only +# If ANY other files appear, run "git reset HEAD" and start over + +# 4. Commit +git commit -m "sdd: add <N> tasks for FEAT-<ID> — <feature-name>" +``` + +### 6. Output + +Before printing the summary, count the delegation-eligible tasks. This is the +number the targeted writer will actually receive when the worker runs, so it +is worth seeing up front: + +```bash +grep -l '^## Delegation Contract' sdd/tasks/active/TASK-*.md | wc -l +``` + +``` +✅ Generated and committed <N> tasks for FEAT-<ID> — <feature-name> + (hotfix: for Jira <KEY> — <feature-name>, no FEAT-<NNN>/TASK-<NNN> reserved) + +Tasks created: + TASK-<NNN> — <title> [<priority>/<effort>] # feature + HOTFIX-<JIRA-KEY>-<N> — <title> [<priority>/<effort>] # hotfix + +Blueprints: <N>/<N> tasks carry an Implementation Blueprint +Graph: <N> tasks, <W> waves, max width <M>; exclusive: <ids or "none"> +Delegated: <D>/<N> tasks carry a Delegation Contract (targeted writer) + TASK-<NNN>, TASK-<NNN> # list them, or "none" + +Worktree: not created. /sdd-task produces versioned artifacts only — the + worktree is created by whoever implements, on the machine that + implements (FEAT-552). + +Next: + /sdd-start <task-id> # creates the worktree, then begins the task + # or, unattended: claude --agent sdd-worker --model sonnet --verbose +``` + +## Reference +- Task template: `sdd/templates/task.md` +- Index schema: `sdd/WORKFLOW.md` (section "Task Index Schema") +- Completed tasks go to: `sdd/tasks/completed/` diff --git a/.claude/commands/sdd-tojira.md b/.claude/commands/sdd-tojira.md new file mode 100644 index 00000000..e3f264e7 --- /dev/null +++ b/.claude/commands/sdd-tojira.md @@ -0,0 +1,447 @@ +--- +description: Export an SDD Specification to a Jira Story (and optionally subtasks). Creates or updates a ticket, updates the spec with the Jira key, and commits the change. +--- + +# /sdd-tojira — Export Specification to Jira + +Export the content of a formal specification file (`sdd/specs/*.spec.md`) to a new +or existing Jira ticket. Optionally creates subtasks from decomposed SDD tasks. + +``` +/sdd-spec → /sdd-task → /sdd-tojira → Jira Story + Subtasks +``` + +## Usage +``` +/sdd-tojira sdd/specs/jira-oauth.spec.md +/sdd-tojira sdd/specs/jira-oauth.spec.md --ticket NAV-8036 # link to existing ticket +/sdd-tojira sdd/specs/jira-oauth.spec.md --with-subtasks # also create subtasks from tasks +/sdd-tojira sdd/specs/jira-oauth.spec.md --project=NAVAI # override project key +/sdd-tojira FEAT-071 # resolve by Feature ID +/sdd-tojira FEAT-071 --ticket NAV-8036 --with-subtasks # full combo +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `<spec_path \| FEAT-ID>` | yes | Path to `.spec.md` or Feature ID to resolve | +| `--ticket <JIRA_KEY>` | no | Existing Jira ticket to update instead of creating | +| `--with-subtasks` | no | Create Jira sub-tasks from the feature's per-spec index `sdd/tasks/index/<feature-slug>.json` | +| `--project <KEY>` | no | Override default project key (default: `NAV`) | + +## Guardrails +- The input must be a valid path to an existing `.spec.md` file, or a Feature ID. +- Do NOT create duplicate tickets — resolve existing ones first. +- Default target: Project `NAV`, Component `Nav-AI`, Issue Type `Story`. +- **Always commit the spec update** (with Jira key) so worktrees can see it. +- Do NOT modify existing Jira tickets unless the user explicitly requests an update + OR the ticket was resolved via `--ticket` / spec metadata. + +## Jira Access Strategy + +Use **mcp-atlassian** if available, falling back to **curl** if not. + +### Detect available method +```bash +# Check if mcp-atlassian tools are available +# If jira_create_issue tool exists → use MCP +# Otherwise → use curl with env vars +``` + +### MCP path (preferred) +``` +jira_create_issue(project_key="NAV", summary="...", ...) +jira_search(jql="...", ...) +``` + +### curl fallback +```bash +# Requires env vars loaded via navconfig (env/.env): +# JIRA_INSTANCE — e.g. https://trocglobal.atlassian.net/ +# JIRA_USERNAME — email for Jira Cloud +# JIRA_API_TOKEN — API token (Personal Access Token) +# +# Load them: +# eval "$(python -c "from navconfig import config; import os; [print(f'export {k}={v}') for k,v in os.environ.items() if k.startswith('JIRA_')]")" + +JIRA_INSTANCE="${JIRA_INSTANCE%/}" +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$JIRA_INSTANCE/rest/api/3/issue" \ + -d '{ ... }' +``` + +## Steps + +### 1. Resolve Spec File and Determine Mode + +#### 1a. Resolve the spec + +If the user passes a Feature ID instead of a path: +```bash +grep -rl "FEAT-071" sdd/specs/ | head -1 +``` + +Read the spec file and extract all fields (see table in Step 2). + +#### 1b. Resolve the Jira ticket (tri-mode resolution) + +Determine whether to CREATE a new ticket or UPDATE an existing one. +Evaluate these sources in priority order — **first match wins**: + +``` +Priority 1: --ticket argument + User passed --ticket NAV-8036 + → MODE = UPDATE, JIRA_KEY = NAV-8036 + +Priority 2: Spec metadata + Spec frontmatter contains `jira: NAV-8036` + OR spec body contains `**Jira**: [NAV-8036](...)` + → MODE = UPDATE, JIRA_KEY = NAV-8036 + +Priority 3: Search by Feature ID + jira_search(jql="project = NAV AND summary ~ \"FEAT-071\"") + → If found: + ⚠️ Existing ticket found: NAV-8036 — "[FEAT-071] jira-oauth" + Status: In Progress | Assignee: jleon + + Options: + 1. Update — sync description and AC from spec (recommended) + 2. Skip — do nothing, just link spec to this ticket + 3. Create new — create a separate ticket (not recommended) + + Wait for user choice. Default: update. + → MODE = UPDATE | SKIP | CREATE based on choice + +Priority 4: No match + → MODE = CREATE +``` + +#### 1c. Announce mode + +``` +📋 /sdd-tojira: FEAT-071 — jira-oauth + + Spec: sdd/specs/jira-oauth.spec.md + Mode: UPDATE existing NAV-8036 | CREATE new ticket + Project: NAV +``` + +### 2. Extract Spec Content + +| Field | Source in Spec | Maps to Jira | +|-------|---------------|--------------| +| Feature ID | Metadata header | Summary prefix: `[FEAT-NNN]` | +| Feature Name | `# <title>` | Summary | +| Section 1 | Motivation & Business Requirements | Description | +| Section 5 | Acceptance Criteria | AC custom field | +| Components | Module Breakdown / Impact | Jira components | +| Labels (FEAT-576) | Frontmatter `projects` ∪ `tags` (sorted, deduped) | Jira labels | +| Effort | Worktree Strategy or task index | Original estimate | + +**Labels**: Compute the labels from the spec's frontmatter using: +```bash +python -c "from pathlib import Path; from scripts.sdd.sdd_meta import parse_taxonomy; t = parse_taxonomy(Path('<spec>')); print(' '.join(sorted(set(t.projects + t.tags))))" +``` + +**Description format:** +```markdown +## Motivation + +<Section 1 content> + +## Architectural Overview + +<Section 2 summary — first 2-3 paragraphs only, not full design> + +## Acceptance Criteria + +<Section 5 content> + +--- +_Exported from SDD spec: sdd/specs/<feature-name>.spec.md_ +_Feature ID: FEAT-<ID>_ +``` + +**Acceptance Criteria format** for the AC custom field: +``` +# User can authenticate via OAuth 2.0 +# Tokens are stored securely in Redis +# Token refresh happens automatically +``` + +**Estimate** from the per-spec index (if `--with-subtasks`). The index file +is already feature-scoped, so read it directly — no cross-feature filtering +needed: +```bash +INDEX="sdd/tasks/index/<feature-slug>.json" +TOTAL_SECONDS=$(jq '[.tasks[] | + if .effort=="S" then 14400 + elif .effort=="M" then 28800 + elif .effort=="L" then 57600 + elif .effort=="XL" then 115200 + else 28800 end] | add' "$INDEX") +``` +Default: `28800` (8h = 1 day) if the index has no tasks. + +### 3. Execute: CREATE or UPDATE + +#### If MODE = CREATE + +**MCP path:** +``` +jira_create_issue( + project_key="NAV", + summary="[FEAT-071] jira-oauth — OAuth 2.0 support for JiraToolkit", + issue_type="Story", + description="<formatted description>", + components="Nav-AI", + additional_fields='{"labels": ["ai-parrot", "memory"], "timeoriginalestimate": "<TOTAL_SECONDS>"}' +) +``` + +**curl fallback:** +```bash +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$JIRA_INSTANCE/rest/api/3/issue" \ + -d '{ + "fields": { + "project": {"key": "NAV"}, + "summary": "[FEAT-071] jira-oauth — OAuth 2.0 support for JiraToolkit", + "issuetype": {"name": "Story"}, + "description": {"type": "doc", "version": 1, "content": [...]}, + "components": [{"name": "Nav-AI"}], + "labels": ["ai-parrot", "memory"], + "timeoriginalestimate": 28800 + } + }' +``` + +Extract the created ticket key: `JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key')` + +#### If MODE = UPDATE + +Update description and estimate on the existing ticket. + +**Important**: In UPDATE mode, do NOT overwrite the summary — the user may have +customized it in Jira. Only update description, AC, estimate, and components, and add labels (never remove). + +**MCP path:** +``` +jira_update_issue( + issue_key="NAV-8036", + description="<formatted description>", + additional_fields='{"timeoriginalestimate": "<TOTAL_SECONDS>"}', + update_fields='{"labels": [{"add": "ai-parrot"}, {"add": "memory"}]}' +) +``` + +**curl fallback:** +```bash +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -X PUT "$JIRA_INSTANCE/rest/api/3/issue/$JIRA_KEY" \ + -d '{ + "fields": { + "description": {"type": "doc", "version": 1, "content": [...]}, + "timeoriginalestimate": 28800 + }, + "update": {"labels": [{"add": "ai-parrot"}, {"add": "memory"}]} + }' +``` + +### 4. Set Acceptance Criteria + +After creating or updating the ticket, set the AC custom field: + +**MCP path:** +``` +jira_update_issue(issue_key="<JIRA_KEY>", additional_fields='{"customfield_10021": "<formatted AC>"}') +``` + +**curl fallback:** +```bash +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -X PUT "$JIRA_INSTANCE/rest/api/3/issue/$JIRA_KEY" \ + -d '{"fields": {"customfield_10021": "<formatted AC>"}}' +``` + +Try fields in order: `customfield_10021`, `customfield_10022`, `customfield_10035`. +Log which field worked for future reference. + +### 5. Create Subtasks (if --with-subtasks) + +If tasks exist in the feature's per-spec index `sdd/tasks/index/<feature-slug>.json`: + +**Pre-check in UPDATE mode**: Check if subtasks already exist on the ticket. +If they do, only create the missing ones: +``` +⚠️ NAV-8036 already has 2 subtasks: + NAV-8037 — [TASK-001] OAuth callback handler + NAV-8038 — [TASK-002] CredentialResolver abstraction + + Missing from Jira (will create): + TASK-003 — JiraToolkit OAuth integration + TASK-004 — Redis token storage + + Proceed? (y/N) +``` + +Match tasks by their stored Jira key and the existing subtask's `[TASK-NNN]` +summary. Reuse existing keys even when a previous run created a subtask but +failed before saving the index. Stop on ambiguous matches instead of creating +duplicates. This check also applies after a partially completed CREATE run. + +For each task without a Jira subtask: + +**MCP path:** +``` +jira_create_issue( + project_key="NAV", + summary="[TASK-001] OAuth callback handler", + issue_type="Sub-task", + parent="<JIRA_KEY>", + description="<task description + scope>", + additional_fields='{"timeoriginalestimate": "<effort_seconds>"}' +) +``` + +**curl fallback:** +```bash +curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST "$JIRA_INSTANCE/rest/api/3/issue" \ + -d '{ + "fields": { + "project": {"key": "NAV"}, + "parent": {"key": "<JIRA_KEY>"}, + "summary": "[TASK-001] OAuth callback handler", + "issuetype": {"name": "Sub-task"}, + "description": {"type": "doc", "version": 1, "content": [...]}, + "timeoriginalestimate": 14400 + } + }' +``` + +Map effort to seconds: S=4h(14400), M=8h(28800), L=16h(57600), XL=32h(115200). + +### 6. Update Spec with Jira Key + +Ensure the spec file has the Jira key. Skip if already present: + +```bash +grep -q "^jira:" sdd/specs/<feature>.spec.md && echo "Already linked" +grep -q "^\*\*Jira\*\*:" sdd/specs/<feature>.spec.md && echo "Already linked" +``` + +If not present, add after the title: +```markdown +# FEAT-071 — OAuth 2.0 support for JiraToolkit + +**Jira**: [NAV-8036](https://trocglobal.atlassian.net/browse/NAV-8036) +**Status**: approved +``` + +Or in YAML frontmatter: add `jira: NAV-8036`. + +### 7. Update Task Index (if --with-subtasks) + +Add Jira keys to each task entry in the per-spec index +`sdd/tasks/index/<feature-slug>.json`: + +```json +{ + "id": "TASK-001", + "feature_id": "FEAT-071", + "jira_key": "NAV-8037", + "jira_parent": "NAV-8036" +} +``` + +### 8. Commit Changes + +```bash +# CRITICAL: Unstage everything first — NEVER commit unrelated changes +git reset HEAD +# Stage ONLY the modified SDD files — NEVER use "git add ." or "git add -A" +git add sdd/specs/<feature-name>.spec.md +# If subtasks were created: +git add sdd/tasks/index/<feature-slug>.json +# Verify ONLY the expected files are staged +git diff --cached --name-only +# If ANY unrelated files appear, run "git reset HEAD" and start over +git commit -m "sdd: export FEAT-<ID> to Jira <JIRA_KEY>" +``` + +Skip commit if nothing changed (spec already had jira key, no new subtasks). + +### 9. Output + +#### CREATE mode +``` +✅ Spec exported to Jira: NAV-8036 (created) + https://trocglobal.atlassian.net/browse/NAV-8036 + + Project: NAV | Component: Nav-AI | Type: Story + Estimate: 3d (24h across 4 tasks) + AC: 3 criteria exported + + Subtasks created: + NAV-8037 — [TASK-001] OAuth callback handler [S/4h] + NAV-8038 — [TASK-002] CredentialResolver abstraction [M/8h] + NAV-8039 — [TASK-003] JiraToolkit OAuth integration [M/8h] + NAV-8040 — [TASK-004] Redis token storage [S/4h] + + Spec updated and committed. + +Next steps: + 1. Review the ticket in Jira. + 2. Assign and prioritize in your sprint. + 3. To implement: /sdd-start or use sdd-autopilot. +``` + +#### UPDATE mode +``` +✅ Spec synced to Jira: NAV-8036 (updated) + https://trocglobal.atlassian.net/browse/NAV-8036 + + Updated: description, AC, estimate + Subtasks: 2 existing + 2 created + + Spec already linked — no commit needed. +``` + +## Reverse Linking + +The `jira:` metadata in the spec enables: +- `/pr-review` to auto-detect the Jira key from the spec +- `sdd-autopilot` to post completion comments back to Jira +- `/sdd-done` to optionally transition the Jira ticket to "Done" +- `/sdd-tojira` itself to detect UPDATE mode on re-runs (idempotent) + +## Edge Cases + +- **Spec not approved**: Warn and ask for confirmation. +- **No AC in spec**: Create ticket without AC. Warn. +- **mcp-atlassian not configured**: Fall back to curl. If env vars missing, error with setup instructions. +- **--ticket points to wrong project**: Warn about mismatch. Proceed but note it. +- **Subtask type not available**: Fall back to linked Tasks: + ``` + jira_create_issue(issue_type="Task", ...) + jira_link_issues(inward="NAV-8036", outward="NAV-8037", link_type="is parent of") + ``` +- **ADF vs Markdown**: Jira Cloud v3 requires ADF. Use v2 with markdown, or construct ADF JSON. + mcp-atlassian handles conversion internally. +- **Custom field IDs differ**: AC auto-detection tries 10021 → 10022 → 10035. If all fail, skip AC. +- **Idempotent re-runs**: Second run detects existing key (Priority 2) → UPDATE mode. No duplicates. + +## Reference +- Jira tool (MCP): `mcp_mcp-atlassian_jira_create_issue` +- Jira tool: `JiraToolkit.jira_create_issue()` +- Spec template: `sdd/templates/spec.md` +- Per-spec task index: `sdd/tasks/index/<feature-slug>.json` +- SDD methodology: `sdd/WORKFLOW.md` +- Auto-commit rule: `CLAUDE.md` (section "SDD Auto-Commit Rule") diff --git a/.claude/hooks/sdd-worker-format.sh b/.claude/hooks/sdd-worker-format.sh new file mode 100755 index 00000000..951b95dd --- /dev/null +++ b/.claude/hooks/sdd-worker-format.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# SubagentStop / Stop hook — run black + pylint after an sdd-worker finishes. +# +# Registered in .claude/settings.json for both events: +# * SubagentStop fires when sdd-worker runs as a delegated subagent; +# * Stop fires when it was launched standalone +# (`claude --agent sdd-worker`), where it IS the main agent. +# Both payloads carry `agent_type`, so one script covers both and no-ops for +# every other agent. +# +# Per-edit formatting is deliberately NOT used: sdd-worker commits after each +# task, and reformatting mid-task would interleave style churn with the task's +# own diff. Running once at the end produces a single, reviewable style commit. +# +# Never blocks: always exits 0, so a formatting problem cannot trap the worker. +# Skip with PARROT_SKIP_SDD_FORMAT=1. +set -uo pipefail + +INPUT=$(cat) +[[ "${PARROT_SKIP_SDD_FORMAT:-}" == "1" ]] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 + +AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // empty') +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') + +# Only sdd-worker. Any other agent (or a plain session) is none of our business. +[[ "$AGENT_TYPE" == "sdd-worker" ]] || exit 0 +[[ -n "$CWD" && -d "$CWD" ]] || exit 0 +cd "$CWD" || exit 0 +git rev-parse --git-dir >/dev/null 2>&1 || exit 0 + +note() { echo "[sdd-worker-format] $*" >&2; } + +# --- Never format on top of an unfinished git operation -------------------- +GIT_DIR=$(git rev-parse --git-dir) +if [[ -d "$GIT_DIR/rebase-merge" || -d "$GIT_DIR/rebase-apply" \ + || -f "$GIT_DIR/MERGE_HEAD" || -f "$GIT_DIR/CHERRY_PICK_HEAD" ]]; then + note "merge/rebase in progress — skipping." + exit 0 +fi + +BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) +case "$BRANCH" in + main|master|dev|staging) + note "on protected branch '$BRANCH' — skipping (workers run on feature branches)." + exit 0 + ;; +esac + +# --- Collect the Python files this worker actually touched ----------------- +# Branch diff against the base plus anything still uncommitted. `dev` is the +# base for feature flows; fall back to the merge-base with origin/dev, and to +# HEAD~1 for a branch with no shared ancestor recorded. +BASE=$(git merge-base HEAD origin/dev 2>/dev/null \ + || git merge-base HEAD dev 2>/dev/null || echo "") +if [[ -n "$BASE" ]]; then + CHANGED=$(git diff --name-only --diff-filter=ACMR "$BASE"...HEAD 2>/dev/null) +else + CHANGED=$(git diff --name-only --diff-filter=ACMR HEAD~1 2>/dev/null) +fi +UNCOMMITTED=$(git status --porcelain | awk '{print $NF}') + +mapfile -t FILES < <(printf '%s\n%s\n' "$CHANGED" "$UNCOMMITTED" \ + | grep -E '\.py$' \ + | grep -vE '(^|/)(\.venv|build|dist|node_modules|__pycache__)/' \ + | sort -u \ + | while read -r f; do [[ -n "$f" && -f "$f" ]] && echo "$f"; done) + +if (( ${#FILES[@]} == 0 )); then + note "no changed Python files — nothing to format." + exit 0 +fi + +# --- Resolve the venv tools (the worktree has no .venv of its own) --------- +VENV_BIN="" +for candidate in "$CWD/.venv/bin" "${CLAUDE_PROJECT_DIR:-}/.venv/bin" \ + "$(git rev-parse --git-common-dir 2>/dev/null)/../.venv/bin"; do + if [[ -n "$candidate" && -x "$candidate/black" ]]; then + VENV_BIN=$(cd "$candidate" && pwd) + break + fi +done +BLACK="${VENV_BIN:+$VENV_BIN/}black" +PYLINT="${VENV_BIN:+$VENV_BIN/}pylint" +command -v "$BLACK" >/dev/null 2>&1 || { note "black not found — skipping."; exit 0; } + +# --- black (line-length comes from the root pyproject: 120) ---------------- +BLACK_OUT=$("$BLACK" --quiet "${FILES[@]}" 2>&1) +mapfile -t REFORMATTED < <(git diff --name-only -- "${FILES[@]}" 2>/dev/null) + +SUMMARY="" +if (( ${#REFORMATTED[@]} > 0 )); then + note "black reformatted ${#REFORMATTED[@]} file(s):" + printf ' %s\n' "${REFORMATTED[@]}" >&2 + + # Stage ONLY the files black rewrote. Anything else in the tree belongs to + # the worker or to a concurrent process and must not be swept into this + # commit. + git add -- "${REFORMATTED[@]}" + if git diff --cached --quiet; then + note "nothing staged after all — no commit." + else + MSG="style: apply black formatting (post sdd-worker) + +Reformatted ${#REFORMATTED[@]} file(s) touched by the sdd-worker run on +${BRANCH}. Style only — no behavioral change. + +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" + if git commit -q -m "$MSG"; then + SHA=$(git rev-parse --short HEAD) + note "committed $SHA" + SUMMARY="black reformatted ${#REFORMATTED[@]} file(s), committed as ${SHA} on ${BRANCH} (NOT pushed)." + else + note "commit failed — files left staged." + SUMMARY="black reformatted ${#REFORMATTED[@]} file(s); the commit failed, they are left staged." + fi + fi +else + note "black: ${#FILES[@]} file(s) already formatted." + SUMMARY="black: ${#FILES[@]} file(s) already clean." +fi +[[ -n "$BLACK_OUT" ]] && note "black: $BLACK_OUT" + +# --- pylint (informational — never blocks, never edits) -------------------- +PYLINT_SUMMARY="" +if command -v "$PYLINT" >/dev/null 2>&1; then + # Cap the file count: pylint imports what it lints, and a very wide feature + # branch would otherwise stall the Stop event for minutes. + LINT_FILES=("${FILES[@]:0:40}") + (( ${#FILES[@]} > 40 )) && note "pylint: limiting to the first 40 of ${#FILES[@]} files." + + RC_ARG=() + [[ -f "$CWD/.pylintrc" ]] && RC_ARG=(--rcfile "$CWD/.pylintrc") + PYLINT_OUT=$(timeout 150 "$PYLINT" "${RC_ARG[@]}" --jobs=4 --score=y \ + --output-format=text "${LINT_FILES[@]}" 2>&1) + PYLINT_RC=$? + + if (( PYLINT_RC == 124 )); then + PYLINT_SUMMARY="pylint timed out after 150s — run it manually." + note "$PYLINT_SUMMARY" + else + SCORE=$(echo "$PYLINT_OUT" | grep -oE 'rated at [-0-9.]+' | tail -1 | awk '{print $3}') + ERRORS=$(echo "$PYLINT_OUT" | grep -cE '^[^ ]+:[0-9]+:[0-9]+: E[0-9]{4}') + WARNS=$(echo "$PYLINT_OUT" | grep -cE '^[^ ]+:[0-9]+:[0-9]+: [WC][0-9]{4}') + PYLINT_SUMMARY="pylint ${SCORE:-n/a}/10 — ${ERRORS} error(s), ${WARNS} warning(s) across ${#LINT_FILES[@]} file(s)." + note "$PYLINT_SUMMARY" + if (( ERRORS > 0 )); then + note "pylint errors (E-class) — these are real defects, not style:" + echo "$PYLINT_OUT" | grep -E '^[^ ]+:[0-9]+:[0-9]+: E[0-9]{4}' | head -20 >&2 + fi + fi +else + note "pylint not found — skipped." +fi + +# Surface the outcome to the agent/user rather than burying it in the log. +jq -cn --arg msg "sdd-worker post-run format: ${SUMMARY} ${PYLINT_SUMMARY}" \ + '{systemMessage: $msg}' 2>/dev/null || true + +exit 0 diff --git a/.claude/rules/aws-cost-optimization.md b/.claude/rules/aws-cost-optimization.md new file mode 100644 index 00000000..665f5ac4 --- /dev/null +++ b/.claude/rules/aws-cost-optimization.md @@ -0,0 +1,303 @@ +--- +description: Evaluate AWS costs and generate actionable optimization suggestions. Covers EC2, S3, RDS, Lambda, ECS/EKS, and data transfer. Use when analyzing AWS spending, right-sizing resources, choosing pricing models (RI/Savings Plans/Spot), implementing tagging strategies, setting budget alerts, or auditing infrastructure for cost waste. +globs: + - "**/*.tf" + - "**/infrastructure/**" + - "**/terraform/**" + - "**/cloudformation/**" +--- + +# AWS Cost Optimization + +Evaluate current AWS costs and produce actionable optimization suggestions across compute, storage, networking, and managed services. + +## When NOT to use + +- The task targets Azure, GCP, or a non-AWS provider +- The task is about general application performance without a cost dimension + +## Instructions + +1. Identify the AWS services in scope (EC2, S3, RDS, Lambda, etc.). +2. Apply the **Cost Optimization Framework** top-to-bottom: Visibility → Right-Sizing → Pricing → Architecture. +3. For each service, check the **Service-Specific Quick Reference** below. +4. Generate a prioritized list of suggestions with estimated savings. + +--- + +## Cost Optimization Framework + +### 1. Visibility — Know What You Spend + +| Action | Tool / Service | +|--------|---------------| +| Cost allocation tags on every resource | AWS Tag Editor, Tag Policies | +| Monthly/daily spend dashboards | Cost Explorer, QuickSight | +| Budget alerts at 50 %, 80 %, 100 % | AWS Budgets | +| Anomaly detection | AWS Cost Anomaly Detection | +| Per-team / per-env cost breakdown | Cost Categories, Linked Accounts | + +### 2. Right-Sizing — Stop Over-Provisioning + +| Signal | Action | +|--------|--------| +| CPU < 20 % sustained | Downsize instance family or switch to Graviton | +| Memory < 30 % sustained | Use memory-optimized → general purpose | +| EBS IOPS < provisioned | Switch gp3 / reduce provisioned IOPS | +| Idle resource (EIP, ELB, NAT, EBS) | Delete or stop | +| Lambda memory > 2× needed | Run `aws lambda get-function-configuration` and tune | + +Use **AWS Compute Optimizer** and **Trusted Advisor** for automated right-sizing recommendations. + +### 3. Pricing Models — Pay Less Per Unit + +| Model | Savings | Best For | Commitment | +|-------|---------|----------|------------| +| On-Demand | 0 % | Spiky, unpredictable | None | +| Savings Plans (Compute) | up to 66 % | Steady compute (EC2, Fargate, Lambda) | 1 or 3 yr | +| Savings Plans (EC2 Instance) | up to 72 % | Known instance family & region | 1 or 3 yr | +| Reserved Instances (Standard) | up to 72 % | Steady-state, known type | 1 or 3 yr | +| Reserved Instances (Convertible) | up to 54 % | Steady-state, flexible type | 1 or 3 yr | +| Spot Instances | up to 90 % | Fault-tolerant batch, CI/CD, HPC | None (2-min notice) | + +**Decision heuristic:** +1. Steady 24/7 → Savings Plan or RI +2. Batch / stateless → Spot with On-Demand fallback +3. Unknown workload → start On-Demand, analyze with Cost Explorer, then commit + +### 4. Architecture — Spend Smarter + +| Pattern | Why | +|---------|-----| +| Serverless First | Zero idle cost; Lambda, Step Functions, EventBridge | +| Graviton (ARM) instances | 20-40 % cheaper at same performance | +| Multi-tier S3 storage | Auto-transition hot → IA → Glacier → Deep Archive | +| Caching (ElastiCache, CloudFront) | Reduce origin hits and data transfer | +| VPC Endpoints | Eliminate NAT Gateway data processing charges | +| Regional consolidation | Reduce cross-region transfer costs | + +--- + +## Service-Specific Quick Reference + +### EC2 + +- Use **Graviton** (`c7g`, `m7g`, `r7g`) for 20-40 % cost reduction. +- Mix **Spot + On-Demand** via Capacity-Optimized allocation in ASGs. +- Enable **auto-scaling** with target tracking (CPU 60-70 %). +- Schedule dev/staging instances off-hours with Instance Scheduler. + +### S3 + +| Storage Class | Use Case | vs Standard | +|---------------|----------|-------------| +| Standard | Frequently accessed | baseline | +| Standard-IA | Accessed < 1×/month | –45 % | +| One Zone-IA | Non-critical, infrequent | –60 % | +| Glacier Instant Retrieval | Quarterly access, ms retrieval | –68 % | +| Glacier Flexible Retrieval | Annual access, hours retrieval | –78 % | +| Deep Archive | Compliance / 7-yr retention | –95 % | + +Implement **S3 Intelligent-Tiering** when access patterns are unpredictable. + +### RDS / Aurora + +| Environment | Recommended Tier | +|-------------|-----------------| +| Development | `db.t4g.micro` – `db.t4g.small` | +| Staging | `db.t4g.medium` – `db.t4g.large` | +| Production | `db.r7g.xlarge` + read replicas | + +- Use **Aurora Serverless v2** for variable traffic. +- Enable **RDS Reserved Instances** for production. +- Use **Aurora I/O-Optimized** if I/O costs > 25 % of total DB cost. + +### Lambda + +- Right-size memory with **AWS Lambda Power Tuning**. +- Use **Graviton2** (`arm64` architecture) for ~34 % cost reduction. +- Enable **Provisioned Concurrency** only when cold-start SLA < 100 ms. +- Prefer **Step Functions** over chained Lambdas to avoid idle billing. + +### ECS / EKS + +- Use **Fargate Spot** for fault-tolerant tasks (up to 70 % savings). +- Use **Compute Savings Plans** for steady Fargate workloads. +- For EKS: enable **Karpenter** for intelligent node provisioning. +- Right-size task/pod CPU and memory with Container Insights. + +### Data Transfer + +| Path | Cost | Mitigation | +|------|------|------------| +| Same AZ | Free | Co-locate services | +| Cross-AZ | $0.01/GB each way | Use AZ-aware routing | +| Internet egress | $0.09/GB first 10 TB | CloudFront, S3 Transfer Acceleration | +| Cross-region | $0.02/GB | Consolidate regions | +| NAT Gateway processing | $0.045/GB | VPC Endpoints for S3/DynamoDB | + +--- + +## Tagging Strategy (Mandatory) + +Every AWS resource MUST have these tags: + +| Tag Key | Example | Purpose | +|---------|---------|---------| +| `Environment` | `production` | Filter by env | +| `Project` | `navigator` | Cost allocation | +| `CostCenter` | `engineering` | Chargeback | +| `Owner` | `team@example.com` | Accountability | +| `ManagedBy` | `terraform` | Audit | + +Enforce via **AWS Organizations Tag Policies** and **SCP** deny rules. + +--- + +## Cost Evaluation Workflow + +When asked to evaluate costs, follow this sequence: + +1. **Inventory**: List services, instance types, storage volumes, and data flows. +2. **Tag audit**: Check for missing cost-allocation tags. +3. **Utilization check**: Review CloudWatch metrics (CPU, memory, IOPS, network). +4. **Pricing check**: Compare current pricing model vs optimal (RI/SP/Spot). +5. **Architecture review**: Identify unnecessary data transfer, missing caching, idle resources. +6. **Report**: Produce a table of findings with: + - Resource / Service + - Current monthly cost (estimated or from Cost Explorer) + - Suggested action + - Estimated savings (% and $) + - Effort (low / medium / high) + - Risk (low / medium / high) + +--- + +## Key Terraform Examples + +### S3 Lifecycle Policy + +```hcl +resource "aws_s3_bucket_lifecycle_configuration" "cost_optimized" { + bucket = aws_s3_bucket.main.id + + rule { + id = "tiered-storage" + status = "Enabled" + + transition { + days = 30 + storage_class = "STANDARD_IA" + } + + transition { + days = 90 + storage_class = "GLACIER" + } + + transition { + days = 180 + storage_class = "DEEP_ARCHIVE" + } + + expiration { + days = 730 + } + } +} +``` + +### Budget Alerts + +```hcl +resource "aws_budgets_budget" "monthly" { + name = "${var.project}-monthly-budget" + budget_type = "COST" + limit_amount = var.monthly_budget_usd + limit_unit = "USD" + time_period_start = "2024-01-01_00:00" + time_unit = "MONTHLY" + + notification { + comparison_operator = "GREATER_THAN" + threshold = 80 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = var.alert_emails + } +} +``` + +### Auto-Scaling with Spot + Graviton + +```hcl +resource "aws_autoscaling_group" "app" { + name = "${var.project}-asg" + min_size = var.asg_min + max_size = var.asg_max + vpc_zone_identifier = var.private_subnet_ids + + mixed_instances_policy { + launch_template { + launch_template_specification { + launch_template_id = aws_launch_template.app.id + version = "$Latest" + } + override { instance_type = "m7g.large" } + override { instance_type = "m6g.large" } + } + instances_distribution { + on_demand_base_capacity = 1 + on_demand_percentage_above_base_capacity = 25 + spot_allocation_strategy = "capacity-optimized" + } + } +} +``` + +### VPC Endpoints (eliminate NAT charges) + +```hcl +resource "aws_vpc_endpoint" "s3" { + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = var.private_route_table_ids +} +``` + +### Cost Explorer Query (boto3) + +```python +import datetime, boto3 + +def get_monthly_cost_by_service() -> list[dict]: + """Return cost breakdown by AWS service for the last 30 days.""" + client = boto3.client("ce") + end = datetime.date.today() + start = end - datetime.timedelta(days=30) + response = client.get_cost_and_usage( + TimePeriod={"Start": start.isoformat(), "End": end.isoformat()}, + Granularity="MONTHLY", + Metrics=["UnblendedCost"], + GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], + ) + results = [] + for group in response["ResultsByTime"][0]["Groups"]: + service = group["Keys"][0] + amount = float(group["Metrics"]["UnblendedCost"]["Amount"]) + if amount > 0.01: + results.append({"service": service, "cost_usd": round(amount, 2)}) + return sorted(results, key=lambda x: x["cost_usd"], reverse=True) +``` + +--- + +## Tools + +- **AWS Cost Explorer** — Spend trends, forecasting, RI/SP recommendations +- **AWS Compute Optimizer** — EC2, EBS, Lambda right-sizing +- **AWS Trusted Advisor** — Idle resources, under-utilized instances +- **AWS Cost Anomaly Detection** — ML-based spend anomaly alerts +- **AWS Budgets** — Threshold alerts and auto-actions +- **Kubecost** — Kubernetes cost allocation (EKS) diff --git a/.claude/rules/cython-development.md b/.claude/rules/cython-development.md new file mode 100644 index 00000000..e7fa0076 --- /dev/null +++ b/.claude/rules/cython-development.md @@ -0,0 +1,26 @@ +--- +trigger: glob +globs: "**/*.pyx, **/*.pxd, **/*.pxi" +description: Rules and best practices for Cython development +--- + +You are an expert in Cython development. + +**Key Guidelines:** + +1. **Imports**: + - **Prefer `cimport`**: Always use `cimport` (C-style import) over `import` when accessing modules, classes, or functions that are exposed via `.pxd` files. This bypasses the Python interpreter overhead. + - Example: Use `from libc.math cimport sqrt` instead of `from math import sqrt`. + +2. **Coding Style & Syntax**: + - **Prefer Cython Syntax**: Use standard Cython syntax (`cdef`, `cpdef`, `struct`, etc.) instead of pure Python syntax decorated with decorators, unless strictly maintaining compatibility with a pure Python interpreter. + - **PEP 8 Compliance**: Adhere to PEP 8 standards for formatting and naming conventions, even within Cython constructs (e.g., variable names, indentation). + +3. **Performance & Types**: + - **Static Typing**: Aggressively use `cdef` to statically type variables, functions, and class attributes. + - **Early Binding**: Use early binding by declaring variables with their specific C types. + - **Direct C Calls**: Call C functions directly rather than their Python wrappers when possible. + +4. **Best Practices**: + - Use `setup.py` or proper build systems (like `meson` or `scikit-build`) configured for Cython. + - Ensure `.pxd` files are provided for reusable Cython modules to enable `cimport` by other modules. diff --git a/.claude/rules/python-development.md b/.claude/rules/python-development.md new file mode 100644 index 00000000..753e541e --- /dev/null +++ b/.claude/rules/python-development.md @@ -0,0 +1,71 @@ +--- +trigger: always_on +--- + +You are an expert in Python, AI, and Machine Learning development. + +**CRITICAL ENVIRONMENT RULES:** +1. **Package Manager**: You MUST use **`uv`** for all package management (e.g., `uv pip install`, `uv pip list`, `uv add`). +2. **Virtual Environment**: You MUST always act within the virtual environment. + - **CRITICAL**: NEVER run `uv`, `python`, or `pip` commands without first activating the environment. + - **ALWAYS** run `source .venv/bin/activate` before any python-related command. +3. **Dependencies**: All dependencies must be managed via `pyproject.toml`. + +Key Principles: +- Write clean, efficient, and well-documented code +- Follow PEP 8 style guidelines +- Use type hints for better code clarity +- Implement proper error handling +- Write modular and reusable code + +Python Best Practices: +- Follow naming conventions (snake_case for functions/variables) +- Use list comprehensions and generator expressions +- Use context managers (with statement) +- Implement proper logging + +Machine Learning: +- Use scikit-learn for traditional ML +- Use PyTorch or TensorFlow for deep learning +- Implement proper data preprocessing +- Use cross-validation for model evaluation +- Track experiments with MLflow or Weights & Biases +- Version control datasets and models + +Data Processing: +- Use pandas for data manipulation +- Use numpy for numerical computations +- Use matplotlib/seaborn for visualization +- Implement data validation +- Handle missing data appropriately +- Use efficient data structures + +Deep Learning: +- Use PyTorch or TensorFlow/Keras +- Implement proper model architecture +- Use data augmentation +- Implement early stopping and checkpointing +- Use GPU acceleration when available +- Monitor training with TensorBoard + +Model Deployment: +- Use aiohttp for serving models +- Implement model versioning +- Use Docker for containerization +- Implement proper API documentation +- Add input validation and error handling +- Monitor model performance in production + +Testing: +- Write unit tests with pytest and pytest-asyncio +- Test data pipelines +- Test model predictions +- Use fixtures for test data +- Implement integration tests + +Performance: +- Use vectorization with numpy +- Use multiprocessing for CPU-bound tasks +- Use async/await for I/O-bound tasks +- Profile code to identify bottlenecks +- Use Cython or numba for optimization \ No newline at end of file diff --git a/.claude/rules/rust-development.md b/.claude/rules/rust-development.md new file mode 100644 index 00000000..6c3c28b7 --- /dev/null +++ b/.claude/rules/rust-development.md @@ -0,0 +1,51 @@ +--- +trigger: glob +globs: "**/*.rs, **/*.toml" +description: When decided to work with Rust cargo packages or create rust files +--- + +You are an expert in Rust-Python FFI, specializing in PyO3 and Maturin. + +Core Integration Principles: +- Use PyO3 to write Rust code that compiles to a native Python extension module +- Use Maturin as the build backend and dependency manager +- Respect the GIL (Global Interpreter Lock); acquire it only when necessary +- Prioritize "Zero-Cost Abstractions" by avoiding unnecessary cloning between languages +- Use the modern PyO3 `Bound<'py, T>` API (v0.21+) over deprecated reference patterns + +Project Structure & Maturin: +- Configure `Cargo.toml` with `crate-type = ["cdylib"]` +- Use `pyproject.toml` to define build-system (requires `maturin>=1.0`) +- Run `maturin develop` for local testing (installs directly into venv) +- Run `maturin build --release` for optimizing production wheels + +PyO3 Macros & Exposure: +- Use `#[pymodule]` to define the entry point module +- Use `#[pyfunction]` to expose standalone Rust functions +- Use `#[pyclass]` to expose Rust structs as Python classes +- Use `#[pymethods]` to define instance/static methods and getters/setters (`#[getter]`/`#[setter]`) +- Use `#[new]` inside `#[pymethods]` to define the `__init__` constructor + +Type Conversion & Memory: +- Return `PyResult<T>` for functions that might raise Python exceptions +- Use `Bound<'py, PyAny>` for generic Python objects managed by the GIL +- Implement `FromPyObject` for Rust types accepting Python data +- Implement `IntoPy<PyObject>` for returning Rust data to Python +- Primitive types (i64, f64, String, Vec) convert automatically + +The GIL & Concurrency: +- Rust code releases the GIL automatically when doing pure Rust work (unless explicitly held) +- Use `Python::with_gil` to acquire the GIL for Python interactions +- Use `py.allow_threads` to release the GIL during long-running CPU-bound Rust operations +- Don't block the async runtime with blocking Python calls + +Error Handling: +- Convert Rust `Result::Err` into `PyErr` to raise Python exceptions +- Use `PyValueError`, `PyTypeError`, or create custom exceptions via `create_exception!` +- Use `.map_err(|e| PyRuntimeError::new_err(e.to_string()))` for quick conversions + +Best Practices: +- Keep the boundary thin: do heavy lifting in pure Rust, use PyO3 only for the interface +- Use `#[pyo3(get, set)]` on struct fields for automatic property generation +- Document Python signatures using `#[pyo3(signature = (...))]` to support default arguments +- Use `cargo test` for internal logic and `pytest` for the exposed API \ No newline at end of file diff --git a/.claude/rules/using-git-worktrees.md b/.claude/rules/using-git-worktrees.md new file mode 100644 index 00000000..c9bfb9ad --- /dev/null +++ b/.claude/rules/using-git-worktrees.md @@ -0,0 +1,212 @@ +--- +description: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification +globs: + - ".gitignore" + - ".worktrees/**" + - "worktrees/**" +--- + +# Using Git Worktrees + +## Overview + +Git worktrees create isolated workspaces sharing the same repository, allowing work on multiple branches simultaneously without switching. + +**Core principle:** Systematic directory selection + safety verification = reliable isolation. + +## Directory Selection Process + +Follow this priority order: + +### 1. Check Existing Directories + +```bash +# Check in priority order +ls -d .worktrees 2>/dev/null # Preferred (hidden) +ls -d worktrees 2>/dev/null # Alternative +``` + +**If found:** Use that directory. If both exist, `.worktrees` wins. + +### 2. Check CLAUDE.md + +```bash +grep -i "worktree.*director" CLAUDE.md 2>/dev/null +``` + +**If preference specified:** Use it without asking. + +### 3. Ask User + +If no directory exists and no CLAUDE.md preference: + +``` +No worktree directory found. Where should I create worktrees? + +1. .worktrees/ (project-local, hidden) +2. ~/.config/superpowers/worktrees/<project-name>/ (global location) + +Which would you prefer? +``` + +## Safety Verification + +### For Project-Local Directories (.worktrees or worktrees) + +**MUST verify directory is ignored before creating worktree:** + +```bash +# Check if directory is ignored (respects local, global, and system gitignore) +git check-ignore -q .worktrees 2>/dev/null || git check-ignore -q worktrees 2>/dev/null +``` + +**If NOT ignored:** + +1. Add appropriate line to .gitignore +2. Commit the change +3. Proceed with worktree creation + +**Why critical:** Prevents accidentally committing worktree contents to repository. + +### For Global Directory (~/.config/superpowers/worktrees) + +No .gitignore verification needed - outside project entirely. + +## Creation Steps + +### 1. Detect Project Name + +```bash +project=$(basename "$(git rev-parse --show-toplevel)") +``` + +### 2. Create Worktree + +```bash +# Determine full path +case $LOCATION in + .worktrees|worktrees) + path="$LOCATION/$BRANCH_NAME" + ;; + ~/.config/superpowers/worktrees/*) + path="~/.config/superpowers/worktrees/$project/$BRANCH_NAME" + ;; +esac + +# Create worktree with new branch +git worktree add "$path" -b "$BRANCH_NAME" +cd "$path" +``` + +### 3. Run Project Setup + +Auto-detect and run appropriate setup: + +```bash +# Node.js +if [ -f package.json ]; then npm install; fi + +# Rust +if [ -f Cargo.toml ]; then cargo build; fi + +# Python +if [ -f requirements.txt ]; then pip install -r requirements.txt; fi +if [ -f pyproject.toml ]; then poetry install; fi + +# Go +if [ -f go.mod ]; then go mod download; fi +``` + +### 4. Verify Clean Baseline + +Run tests to ensure worktree starts clean: + +```bash +# Examples - use project-appropriate command +npm test +cargo test +pytest +go test ./... +``` + +**If tests fail:** Report failures, ask whether to proceed or investigate. + +**If tests pass:** Report ready. + +### 5. Report Location + +``` +Worktree ready at <full-path> +Tests passing (<N> tests, 0 failures) +Ready to implement <feature-name> +``` + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| `.worktrees/` exists | Use it (verify ignored) | +| `worktrees/` exists | Use it (verify ignored) | +| Both exist | Use `.worktrees/` | +| Neither exists | Check CLAUDE.md → Ask user | +| Directory not ignored | Add to .gitignore + commit | +| Tests fail during baseline | Report failures + ask | +| No package.json/Cargo.toml | Skip dependency install | + +## Common Mistakes + +### Skipping ignore verification + +- **Problem:** Worktree contents get tracked, pollute git status +- **Fix:** Always use `git check-ignore` before creating project-local worktree + +### Assuming directory location + +- **Problem:** Creates inconsistency, violates project conventions +- **Fix:** Follow priority: existing > CLAUDE.md > ask + +### Proceeding with failing tests + +- **Problem:** Can't distinguish new bugs from pre-existing issues +- **Fix:** Report failures, get explicit permission to proceed + +### Hardcoding setup commands + +- **Problem:** Breaks on projects using different tools +- **Fix:** Auto-detect from project files (package.json, etc.) + +## Example Workflow + +``` +[Check .worktrees/ - exists] +[Verify ignored - git check-ignore confirms .worktrees/ is ignored] +[Create worktree: git worktree add .worktrees/auth -b feature/auth] +[Run npm install] +[Run npm test - 47 passing] + +Worktree ready at /Users/jesse/myproject/.worktrees/auth +Tests passing (47 tests, 0 failures) +Ready to implement auth feature +``` + +## Red Flags + +**Never:** +- Create worktree without verifying it's ignored (project-local) +- Skip baseline test verification +- Proceed with failing tests without asking +- Assume directory location when ambiguous +- Skip CLAUDE.md check + +**Always:** +- Follow directory priority: existing > CLAUDE.md > ask +- Verify directory is ignored for project-local +- Auto-detect and run project setup +- Verify clean test baseline + +## Integration + +**Called by:** +- `/sdd-brainstorm` (Phase 4) — when design is approved and implementation follows +- `/sdd-start` — before executing any tasks +- Any workflow needing an isolated workspace diff --git a/.claude/rules/worktree-management.md b/.claude/rules/worktree-management.md new file mode 100644 index 00000000..4602cbd6 --- /dev/null +++ b/.claude/rules/worktree-management.md @@ -0,0 +1,71 @@ +--- +description: Single source of truth for git worktrees in this repo — where they live, how they are created, how to work inside one, how to finish and clean up. Replaces using-git-worktrees, worktree-start-feature, worktree-status and worktree-pr-and-clean. +--- + +# Worktree Management + +Worktrees isolate **features** from each other: one worktree per feature (or +hotfix); the tasks of a feature run sequentially inside that same worktree. +Branch *policy* (which base each flow type uses, sync-down, `/sdd-done` never +targeting `main`) lives in `CLAUDE.md` § Git Configuration — this file is the +how-to. + +## 1. Where they live, how they are named + +- Always under `.claude/worktrees/` (git-ignored). Never `.worktrees/`, never a global directory. +- Names come from `scripts.sdd.sdd_meta.plan_worktree`, never invented: + - feature → `feat-<FEAT-ID>-<slug>` (the doubled `feat-FEAT-` prefix is intentional — `/sdd-done` greps on it) + - hotfix → `hotfix-<JIRA-KEY>-<slug>` +- Base ref is **always `origin/<base_branch>`** — `dev` (or `staging` during a freeze) for features, `main` for hotfixes — never `HEAD`, so a worktree can never inherit an unpushed local commit (FEAT-466). + +## 2. Create (idempotent) + +```bash +source .venv/bin/activate +python -m scripts.sdd.ensure_worktree --slug <slug> --feature-id FEAT-<NNN> # feature → origin/dev +python -m scripts.sdd.ensure_worktree --slug <slug> --jira-key <JIRA-KEY> # hotfix → origin/main +``` + +- Created by whoever implements: `/sdd-start`, `sdd-worker`, and the dev-loop orchestrators (`sdd-planner`, `sdd-research`, `sdd-autopilot`). `/sdd-task` creates none (FEAT-552). +- **Never** `claude --worktree` (it branches from `main`, which has no SDD artifacts) and never hand-build `git worktree add` for SDD work. +- Skip the worktree entirely for docs-only changes, single-commit fixes, single-task specs and brainstorms — work on a plain branch. + +## 3. Inspect + +```bash +git worktree list # primary checkout + every worktree +git -C <path> rev-parse --abbrev-ref HEAD # branch +git -C <path> status --porcelain # empty = clean +``` + +The active worktree is the one whose branch matches the feature at hand; with +several candidates prefer the newest `feat-*` / `hotfix-*` entry. Report +**path, branch, clean / not clean**, and remind: *all feature edits happen +inside `<path>`* — never in the primary checkout. + +## 4. Work inside + +- Run git as `git -C <path> ...` (or `cd` in). Small commits, one task per commit, and **push early** (`git push -u origin <branch>`): other sessions may merge or `reset --hard` the shared tree, and only pushed commits are safe. +- The shared `.venv` is editable-installed against the **main checkout**, so a bare `pytest` inside a worktree imports the wrong branch. Prefix with `PYTHONPATH=packages/ai-parrot/src` (one entry per changed package). Never `uv sync` inside a worktree — it repoints the shared venv's `.pth` and breaks `import parrot` for every session once the worktree is removed. +- Shared environments are read/execute-only for worktree agents. Use installed tools directly or `uv run --no-sync`; `uv add --no-sync` may update scoped dependency declarations without installing. Dependency installation requires a real task-local environment with an explicit interpreter target, or a controlled installation by the main-checkout operator. Agents must not switch to the main checkout to perform that installation themselves. +- SDD in-process commands and native Claude Bash hooks use Bubblewrap filesystem protection. The checkout, Git metadata, the primary checkout's `.claude/worktrees/` (so `/sdd-done` can add the ledger-snapshot worktree and remove the feature worktree from inside it) and the primary checkout's `.parrot/ledger/` (so `wikitoolkit ledger open/claim/close` work from inside a worktree) are writable; the rest of the primary checkout and shared environments are mounted read-only, with private temporary storage — except Claude Code's scratchpad root (`/tmp/claude-<uid>`), which is bound back in so the session scratchpad persists across Bash calls. Missing Bubblewrap or denied namespaces must stop execution, never trigger an unsandboxed retry. Other CLI hosts must provide equivalent protection. Command allowlists and prompt rules alone do not guarantee this policy. +- Run the tests your task names; do not start with a full baseline sweep. + +## 5. Finish + +- Working tree clean, branch pushed. +- SDD lane: `/sdd-done <FEAT-ID>` verifies the tasks, stamps the index, pushes, opens a PR feature → `base_branch` (`--merge` merges directly) and cleans up. +- Ad-hoc lane: `gh pr create --base <base_branch> --head <branch> --title "..." --body "..."` — never against `main` for a feature. If `gh` is missing or unauthenticated, hand the user the exact commands instead. + +## 6. Clean up + +```bash +/remove-worktree # list first; refuses worktrees with a live sdd-worker +git worktree remove .claude/worktrees/<name> +git worktree prune # drop stale admin data +``` + +Never remove a worktree with uncommitted changes, never `--force` unless the +user explicitly asks, and look for orphan directories under +`.claude/worktrees/` that `git worktree list` no longer knows about — +`/remove-worktree` handles those; `prune` does not. diff --git a/.claude/rules/worktree-pr-and-clean.md b/.claude/rules/worktree-pr-and-clean.md new file mode 100644 index 00000000..b2cb0ea7 --- /dev/null +++ b/.claude/rules/worktree-pr-and-clean.md @@ -0,0 +1,52 @@ +--- +description: Use ONLY after the user explicitly approves and requests a PR (keywords: open PR, create pull request, ready for main, approved, ship it). Find the active worktree, verify it is clean, push the branch, create a PR to main, and remove the worktree. +globs: + - ".worktrees/**" + - "worktrees/**" +--- + +# Worktree PR and Clean + +## Goal + +After user approval: push branch + open PR to main + clean up the worktree. + +## Safety Rules + +- Do not delete the worktree if there are uncommitted changes. +- Do not use `--force` unless the user explicitly requests it. +- If `gh` is not installed or not authenticated, guide the user to create the PR manually (or provide the exact commands needed). + +## Steps + +### 1. Locate the Active Worktree + +- Read `.worktrees/_active.json` (if present) to get `path`, `branch`, `base`. +- If missing, run `git worktree list` and pick the non-primary worktree (or ask the user which one). + +### 2. Verify State + +- `git -C <path> status --porcelain` must be empty. +- Confirm the current branch is `<branch>`. +- (Optional) run tests one last time. + +### 3. Push to Origin + +- `git -C <path> push -u origin <branch>` + +### 4. Create a PR to Main + +- If GitHub CLI is available and authenticated: + - `gh pr create --base <base> --head <branch> --title "<title>" --body "<summary>"` + +### 5. Clean Up + +- Remove the worktree: `git worktree remove <path>` +- Prune stale metadata: `git worktree prune` +- Delete `.worktrees/_active.json` if there is no active worktree left. + +### 6. Report + +- PR link (if created). +- Commands executed. +- Any remaining manual steps (if auth or tooling was missing). diff --git a/.claude/rules/worktree-start-feature.md b/.claude/rules/worktree-start-feature.md new file mode 100644 index 00000000..41fe419c --- /dev/null +++ b/.claude/rules/worktree-start-feature.md @@ -0,0 +1,60 @@ +--- +description: Use when the user asks to implement a feature in an isolated git worktree / separate branch (keywords: worktree, separate folder, isolated branch, new feature branch). Create a new branch + worktree from main, store the active worktree info, and do ALL work inside the worktree. Do NOT open a PR yet. +globs: + - ".worktrees/**" + - "worktrees/**" +--- + +# Worktree Start Feature + +## Goal + +Implement a feature in a separate working directory (git worktree) on a new branch. + +## Rules + +- Do not edit feature code in the main working directory. +- Run all git commands in the worktree using: `git -C <path> ...` +- Do not create a PR yet. Leave the work ready for review and ask the user for approval. + +## Steps + +### 1. Preflight + +- Confirm this is a git repository. +- Determine the base branch: + - Prefer `main` if it exists, otherwise try `master`. +- If remote `origin` exists, run `git fetch origin`. + +### 2. Naming + +- Branch name: `feat/<slug>` (short slug derived from the feature name). +- Worktree path: `.worktrees/<slug>` (keep worktrees inside the repo). + +### 3. Create the Worktree + New Branch + +- Create `.worktrees/` if it does not exist. +- Create a worktree and new branch from the base ref (prefer `origin/<base>` if available): + - `git worktree add -b <branch> .worktrees/<slug> <base_ref>` + +### 4. Record the Active Worktree + +- Create/update `.worktrees/_active.json` with: + - `branch` + - `base` + - `path` + +### 5. Implement the Feature + +- Make all code changes under `.worktrees/<slug>/...` +- Run tests/lint in the worktree (choose appropriate commands based on the repo: `package.json` → `npm test`; `pyproject`/`pytest` → `pytest`; etc.) +- Make small, clear commits. + +### 6. Finish + +- Ensure the worktree is clean (no uncommitted changes). +- Summarize: + - what changed + - how to run tests + - any risks / follow-ups +- Ask the user for approval to open a PR and clean up the worktree. diff --git a/.claude/rules/worktree-status.md b/.claude/rules/worktree-status.md new file mode 100644 index 00000000..a9f800dd --- /dev/null +++ b/.claude/rules/worktree-status.md @@ -0,0 +1,77 @@ +--- +description: Use to inspect the current git worktree setup, show the active/likely-active worktree, verify cleanliness, and remind the agent to work only inside the worktree (keywords: worktree status, which worktree, where am I, sanity check, active branch). +globs: + - ".worktrees/**" + - "worktrees/**" +--- + +# Worktree Status + +## Goal + +Show the current worktree situation and prevent accidental edits in the primary working directory. + +## Rules + +- Do not make code changes. +- Do not commit, push, or open PRs. +- Only inspect and report. + +## Steps + +### 1. Confirm Git Repo + +- If not in a git repo, report that and stop. + +### 2. Show Worktrees + +- Run: `git worktree list` +- Identify: + - the primary worktree (usually the repo root) + - any additional worktrees (candidates) + +### 3. Determine the Active / Likely-Active Worktree + +- If `.worktrees/_active.json` exists: + - Read and report `branch`, `base`, `path`. + - Verify the path exists. + - Mark this as **Active (recorded)**. +- Else (no `_active.json`): + - From `git worktree list`, collect non-primary worktrees. + - If there are no additional worktrees: + - Report: "No additional worktrees found." + - Skip to Step 5. + - If there is exactly one additional worktree: + - Treat it as **Likely active (only candidate)**. + - If there are multiple additional worktrees: + - Determine "most likely active" using this heuristic order (no filesystem changes): + 1. Prefer a worktree whose path starts with `.worktrees/` (repo-local convention). + 2. Among those, prefer one whose branch name matches common feature patterns: + `feat/*`, `feature/*`, `fix/*`, `bugfix/*`, `chore/*` + 3. If still multiple, pick the one that appears last in `git worktree list` output (often newest). + - Mark it as **Likely active (heuristic)**. + - For the chosen likely-active worktree, set: + - `path` = candidate path + - `branch` = candidate branch + +### 4. Verify Cleanliness (Active or Likely-Active) + +- If an active/likely-active worktree path is known: + - Run: `git -C <path> status --porcelain` + - If non-empty: report "worktree is NOT clean" and show a short summary of modified/untracked. + - If empty: report "worktree is clean". + - Also report the branch: `git -C <path> rev-parse --abbrev-ref HEAD`. + +### 5. Guardrail Reminder + +- If an active/likely-active worktree exists: + - Explicitly state: "Do all feature edits inside: `<path>`." +- If no active/likely-active worktree exists: + - Suggest using the `worktree-start-feature` rule to create one for a new feature. + +## Output Format + +- **Worktrees:** list (path + branch) +- **Active worktree:** branch/path and whether it is recorded or heuristic +- **Cleanliness:** clean / not clean (active/likely-active) +- **Reminder:** where to work diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..b664a17b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,30 @@ +{ + "env": { + "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Grep|Glob|Read|Bash", + "hooks": [ + { + "type": "command", + "command": "/home/jesuslara/proyectos/navconfig/.venv/bin/wikitoolkit claude-hook", + "timeout": 10 + } + ] + } + ] + }, + "enabledPlugins": { + "fast-jev-compaction@fast-jev-compaction": true + }, + "extraKnownMarketplaces": { + "fast-jev-compaction": { + "source": { + "source": "github", + "repo": "tamaratran/fast-jev-compaction" + } + } + } +} diff --git a/.claude/skills/bookstore/SKILL.md b/.claude/skills/bookstore/SKILL.md new file mode 100644 index 00000000..e6653157 --- /dev/null +++ b/.claude/skills/bookstore/SKILL.md @@ -0,0 +1,69 @@ +--- +name: bookstore +description: Research the user's indexed book library with PageIndex and the bookstore MCP tools. Use for questions about what a book says, which books cover a topic, library comparisons, or cited passages; not for general source-code search or buying books. +--- + +# Research the indexed book library + +Use the connected `bookstore` MCP server. Claude Code prefixes its tools as +`mcp__bookstore__bookstore_*`. The server exposes seven read-only tools over +project and global libraries. + +## Choose books, search sections, read evidence + +1. Start with `bookstore_catalog_search(query, top_k=8)` using topic keywords. + This searches catalog cards through SQLite FTS5 without an LLM. Keep the + returned `book_id` values. Use `bookstore_list_books()` for inventory or + when a named book cannot be located by topic search. +2. Inspect `bookstore_get_toc(book_id)` for chapter titles, `node_id` values, + and page ranges. Use `bookstore_get_card(book_id)` when you need the full + summary, authors, or topics to compare candidate books. +3. Use `bookstore_search_book(book_id, query, top_k=8)` for a chosen book. + For a question spanning books, use `bookstore_search(query, book_ids=[...], + max_books=3)`; omit `book_ids` for automatic catalog shortlisting. Keep the + book cap small because configured LLM search can make calls per book. +4. Read relevant matches with `bookstore_read_section(book_id, node_id)`. + Keep each node paired with its book. Retrieve only the sections needed + to answer, rather than loading whole books. + +For “which book covers X?”, catalog cards may be sufficient. For claims about +what a book actually says, read section content; search summaries and scores +are navigation aids. If catalog search is empty, try fewer keywords or a title +lookup before concluding that the library has no relevant book. + +## Cite the evidence + +Cite *Book Title*, “Section Title”, pp. start–end using the titles and +`start_page` / `end_page` returned by the tools. When page numbers are absent +(common for Markdown), cite book and section only. Distinguish books when +comparing their advice. An empty `content` field is missing evidence; do not +invent a passage or present a catalog summary as a quotation. + +## Degraded or disconnected search + +- Without `PARROT_BOOKSTORE_LLM`, in-book/cross-book search is BM25-only and + requires `bm25s`. If that dependency is missing, catalog search, cards, + inventory, ToC, and section reads still work. Select sections from the ToC + and disclose the narrower search when it affects the answer. +- If MCP is disconnected, run `parrot claude install --no-build` in the target repository, + then restart Claude Code in that trusted project. CLI fallbacks are `bookstore search "topic" --catalog-only`, + `bookstore list --json`, `bookstore show <book_id> --json`, + `bookstore toc <book_id>`, and `bookstore search "question" --book <book_id>`. + If the executable is missing, use the Python environment where ai-parrot is installed with + `python -m parrot.knowledge.bookstore.cli` as the command prefix. + The CLI has no section-read command: `show` returns a card, not book text. + Report that limitation if source content cannot be retrieved. + +## Library management + +Indexing and removal are CLI-only. Perform them when requested, not as an +automatic side effect of a research question. `bookstore locations` shows +resolved paths; `bookstore add notes.md --no-llm` indexes deterministic +Markdown, and `bookstore add-folder ./books --dry-run` previews a batch. +PDF and other format ingestion may require an LLM and optional dependencies. + +`PARROT_LIBRARY_DIR` overrides the project library. Otherwise it lives under +the active Git root at `.parrot/library`; the global library is under +`${PARROT_HOME:-~/.parrot}/library`. Project books win ID collisions. Optional +LLM configuration is `PARROT_BOOKSTORE_LLM="provider:model"` plus +`PARROT_BOOKSTORE_LLM_LIGHT` (a model ID from the same provider). diff --git a/.claude/workflows/sdd-insight.js b/.claude/workflows/sdd-insight.js new file mode 100644 index 00000000..93252037 --- /dev/null +++ b/.claude/workflows/sdd-insight.js @@ -0,0 +1,180 @@ +export const meta = { + name: 'sdd-insight', + description: 'Two-layer AI-fluency + SDD analysis: Sonnet 4.6 explores the evidence (four personal AI-fluency competencies + one repo SDD-process lens), Opus 4.8 writes a skill map and an SDD process read grounded in the framework, then verifies both are evidence-grounded.', + whenToUse: 'Run by the /sdd-insight command after scripts/sdd/insight.py emits evidence.json. Args: {evidence, framework} absolute paths.', + phases: [ + { title: 'Explore', detail: 'Sonnet 4.6 — one explorer per 4D competency + one SDD-process explorer' }, + { title: 'Analyze', detail: 'Opus 4.8 — skill map + SDD process read grounded in the framework' }, + { title: 'Verify', detail: 'check the map + SDD read are grounded; repair if not' }, + ], +} + +// Resolve inputs. The /sdd-insight command passes absolute paths via args, but if they don't +// arrive the defaults MUST still point at the exact files the command writes — never a bare +// relative path, which an agent will "helpfully" resolve to a stale copy elsewhere on disk. +const EV = (args && args.evidence) || '~/.claude/sdd-insight/evidence.json' +const FW = (args && args.framework) || 'reference/sdd-insight/ai-fluency-framework.md' + +const COMPETENCIES = [ + { key: 'Delegation', focus: 'What they hand to the agent vs keep, and how they split work: end-to-end hand-offs vs micro-stepping, sub-agents / background jobs / planning, tool breadth (platform & path awareness). Signals: delegation_events, tool_usage, scope of prompts.' }, + { key: 'Description', focus: 'How concretely they brief the agent: do action prompts name a file/error (artifact), carry a constraint, and state a why/acceptance test? Terse offloading vs front-loaded specific briefs. Signals: Direction detail (constraint/artifact/intent rates) + sample prompts.' }, + { key: 'Discernment', focus: 'How they evaluate outputs: verification after edit-bursts (tests/build/run), grounding edits in a prior read, correcting precisely (symptom + rule) vs vague rejection. Signals: Verification, Context, Iteration detail. NOTE agency: verification/grounding are partly Claude-driven — credit the USER moderately.' }, + { key: 'Diligence', focus: 'Responsibility: verifying before things go live, tearing down what was spun up, owning the result rather than blind-shipping. In a coding transcript this overlaps Discernment — weight the responsibility angle. Signals: verification teardown bonus, grounded edits.' }, +] + +const FINDING = { + type: 'object', additionalProperties: false, + required: ['competency', 'level_estimate', 'confidence', 'strengths', 'gaps', 'evidence_quotes'], + properties: { + competency: { type: 'string' }, + level_estimate: { type: 'integer', minimum: 1, maximum: 5 }, + confidence: { type: 'string', enum: ['low', 'medium', 'high'] }, + strengths: { type: 'array', items: { type: 'string' } }, + gaps: { type: 'array', items: { type: 'string' } }, + evidence_quotes: { type: 'array', items: { type: 'string' }, description: 'real quotes/observations from the evidence' }, + notes: { type: 'string' }, + }, +} + +// The SDD-process explorer reasons about the REPO's workflow adherence (evidence.sdd), not +// the individual. Its finding feeds the analyst's sdd_read — framed as "the team/repo". +const SDD_FINDING = { + type: 'object', additionalProperties: false, + required: ['summary', 'strengths', 'gaps', 'evidence_metrics'], + properties: { + summary: { type: 'string' }, + strengths: { type: 'array', items: { type: 'string' } }, + gaps: { type: 'array', items: { type: 'string' }, description: 'concrete process gaps, e.g. low review coverage' }, + top_fix: { type: 'string', description: 'the single highest-leverage process fix for this repo' }, + evidence_metrics: { type: 'array', items: { type: 'string' }, description: 'real numbers cited from evidence.sdd' }, + }, +} + +const SKILL_ENTRY = { + type: 'object', additionalProperties: false, + required: ['competency', 'level', 'level_label', 'summary', 'evidence', 'next_move'], + properties: { + competency: { type: 'string', enum: ['Delegation', 'Description', 'Discernment', 'Diligence'] }, + level: { type: 'integer', minimum: 1, maximum: 5 }, + level_label: { type: 'string', enum: ['Emerging', 'Developing', 'Proficient', 'Advanced', 'Expert'] }, + summary: { type: 'string' }, + evidence: { type: 'array', items: { type: 'string' }, minItems: 1 }, + next_move: { type: 'string' }, + }, +} + +const ANALYSIS = { + type: 'object', additionalProperties: false, + required: ['overall_read', 'skill_map', 'top_growth', 'strengths'], + properties: { + overall_read: { type: 'string' }, + skill_map: { type: 'array', items: SKILL_ENTRY, minItems: 4, maxItems: 4 }, + top_growth: { + type: 'array', + items: { + type: 'object', additionalProperties: false, + required: ['title', 'why', 'how', 'example_before', 'example_after'], + properties: { + title: { type: 'string' }, why: { type: 'string' }, how: { type: 'string' }, + example_before: { type: 'string' }, example_after: { type: 'string' }, + }, + }, + }, + strengths: { type: 'array', items: { type: 'string' } }, + // Layer 2: a written read of the REPO's SDD process discipline, grounded in evidence.sdd. + // Empty string when the evidence carries no sdd block. + sdd_read: { type: 'string', description: 'A grounded paragraph on the repo SDD workflow adherence (the team/repo, not "you"), naming the real sub-scores and the top process fix. Empty string if evidence.sdd is null.' }, + }, +} + +const VERDICT = { + type: 'object', additionalProperties: false, + required: ['is_grounded', 'ungrounded_claims'], + properties: { + is_grounded: { type: 'boolean' }, + ungrounded_claims: { type: 'array', items: { type: 'string' } }, + notes: { type: 'string' }, + }, +} + +const READ = `Using your Read tool, read EXACTLY these two files and use ONLY them — the framework at ` + + `${FW} and the de-contaminated evidence bundle (JSON) at ${EV}. ` + + `Do NOT search for, guess at, or substitute any other path: there may be stale evidence.json ` + + `copies elsewhere on disk — ignore them entirely. If either ` + + `file is missing, STOP and report that rather than reading a different one. (A leading ~ means your ` + + `home directory — expand it to $HOME before reading.) The evidence is real and ` + + `local; ground everything in it — quote real prompts; never invent.` + +// ---- Explore: Sonnet 4.6, one explorer per competency + one SDD-process explorer ----- +phase('Explore') +const findings = await parallel(COMPETENCIES.map(c => () => + agent( + `${READ}\n\nYou are a careful, thorough analyst exploring ONE AI-fluency competency: ` + + `**${c.key}**.\n${c.focus}\n\nUse the level rubric in the framework. Estimate a level (1–5), ` + + `set confidence from how much evidence exists (hedge thin signals), and list concrete strengths, ` + + `gaps, and real evidence quotes. Be specific to THIS person.`, + { label: `explore:${c.key}`, phase: 'Explore', model: 'sonnet', schema: FINDING } + ).then(f => f && { ...f, competency: f.competency || c.key }) +)).then(r => r.filter(Boolean)) + +// The SDD-process lens reads the repo-level evidence.sdd block (deterministic adherence +// metrics). It returns a "no data" summary gracefully if there is no sdd block. +const sddFinding = await agent( + `${READ}\n\nYou are a Spec-Driven-Development process auditor. Look ONLY at the evidence's ` + + `top-level "sdd" object (overall, band, sub_scores: Pipeline / Decomposition / Acceptance / ` + + `Closure / Review, and metrics with real counts). This describes how the REPO/TEAM runs its SDD ` + + `workflow — NOT any individual. If "sdd" is null or missing, return a summary of "no SDD data ` + + `available" and empty lists. Otherwise: summarize the repo's process health, list real strengths ` + + `and concrete gaps (call out the lowest sub-score — e.g. weak review coverage — with its real ` + + `numbers), and name the single highest-leverage process fix. Cite the actual metric values.`, + { label: 'explore:Process', phase: 'Explore', model: 'sonnet', schema: SDD_FINDING } +) + +log(`Explored ${findings.length}/4 competencies + SDD process (Sonnet 4.6)`) + +// ---- Analyze: Opus 4.8 writes the grounded skill map + SDD read -------------------- +phase('Analyze') +const findingsJson = JSON.stringify(findings, null, 2) +const sddFindingJson = JSON.stringify(sddFinding, null, 2) +const analystPrompt = + `${READ}\n\nYou are the senior AI-fluency assessor (write like a kind, exacting teacher). ` + + `Four Sonnet explorers produced these competency findings:\n\n${findingsJson}\n\n` + + `A fifth explorer audited the repo's SDD process discipline:\n\n${sddFindingJson}\n\n` + + `Reconcile the four competencies with the deterministic scores in the evidence bundle and the ` + + `framework's level rubric and "what good looks like". Produce the final assessment per the ` + + `framework's OUTPUT CONTRACT: an overall_read, a skill_map with EXACTLY the four competencies ` + + `(Delegation, Description, Discernment, Diligence) in that order, top_growth, and strengths. ` + + `\n\nThe top_growth section is the heart of the personal report — it MUST be fully custom, never ` + + `generic advice. Produce 3 items (2 only if data is thin). For each: a sharp title; a "why" that ` + + `cites THIS person's own numbers/pattern; a concrete "how"; and before/after where example_before ` + + `is a REAL prompt copied VERBATIM from the evidence's sample_prompts or weak_examples (do not invent ` + + `or paraphrase) and example_after is your tailored rewrite of THAT exact prompt. Name their files, ` + + `tools, projects. No example may repeat. Respect agency (discount Claude-driven habits) and ` + + `confidence (hedge thin signals). ` + + `\n\nFINALLY, set sdd_read: if evidence.sdd is null, set it to an empty string. Otherwise write ONE ` + + `grounded paragraph on the REPO's SDD workflow adherence — say "the repo"/"the team", never "you"; ` + + `name the band and the real sub-scores; call out the weakest dimension with its numbers; and state ` + + `the single highest-leverage process fix. Ground every personal claim AND the sdd_read in the evidence.` +let analysis = await agent(analystPrompt, { label: 'analyze', phase: 'Analyze', model: 'opus', schema: ANALYSIS }) + +// ---- Verify: is the map (and SDD read) actually grounded? repair once if not ------- +phase('Verify') +const verdict = await agent( + `${READ}\n\nAdversarially check this assessment against the evidence. Flag any claim that ` + + `is generic, ungrounded, inflated, or ignores low confidence — including the sdd_read (it must cite ` + + `the real sdd sub-scores and never address an individual as "you"). Default to is_grounded=false ` + + `if unsure.\n\nASSESSMENT:\n${JSON.stringify(analysis, null, 2)}`, + { label: 'verify', phase: 'Verify', model: 'opus', schema: VERDICT } +) +if (verdict && verdict.is_grounded === false && (verdict.ungrounded_claims || []).length) { + log(`Repairing ${verdict.ungrounded_claims.length} ungrounded claim(s)`) + analysis = await agent( + `${READ}\n\nRevise this assessment to fix these grounding problems — replace generic/inflated/ungrounded ` + + `statements with evidence-grounded, appropriately-hedged ones. Keep the same JSON shape.\n\n` + + `PROBLEMS:\n${(verdict.ungrounded_claims || []).map((c, i) => `${i + 1}. ${c}`).join('\n')}\n\n` + + `CURRENT:\n${JSON.stringify(analysis, null, 2)}`, + { label: 'repair', phase: 'Verify', model: 'opus', schema: ANALYSIS } + ) +} + +return analysis diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..c56dc4c7 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,59 @@ +name: Deploy GitHub Pages + +on: + push: + branches: [main] + paths: + - 'site/**' + - 'scripts/check_site.py' + - '.github/workflows/pages.yml' + pull_request: + paths: + - 'site/**' + - 'scripts/check_site.py' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check documentation links and examples + shell: bash + run: | + mkdir -p artifacts/logs + python3 scripts/check_site.py 2>&1 | tee artifacts/logs/site-check.log + + deploy: + needs: validate + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v6 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload site + uses: actions/upload-pages-artifact@v4 + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bffbc44..6c032189 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Python package build and publish +name: Python package build and publish with uv on: release: @@ -6,93 +6,219 @@ on: jobs: build: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest + # No matrix on purpose: the manylinux action below builds *every* interpreter + # listed in `python-versions` inside its own container, so a matrix would just + # rebuild the identical set of wheels N times. Add new versions to + # `python-versions` instead. + steps: + - uses: actions/checkout@v4 + + - name: Build wheels (manylinux) + uses: RalfG/python-wheels-manylinux-build@v0.7.1-manylinux2014_x86_64 + with: + python-versions: 'cp310-cp310 cp311-cp311 cp312-cp312 cp313-cp313 cp314-cp314' + build-requirements: 'uv setuptools>=74.0.0 Cython>=3.1.4 wheel>=0.44.0' + system-packages: 'libmemcached-devel openssl-devel zlib-devel' + # No pre-build-command on purpose. The wheels only need the build + # requirements above (setuptools/Cython/wheel) to cythonize the .pyx + # sources; the runtime dependencies are never imported at build time. + # A `uv sync` here resolved the *whole* dependency tree against + # whichever interpreter uv discovered inside the manylinux image -- + # which is now the free-threaded CPython 3.14t -- and orjson ships no + # free-threaded wheels, so it fell back to its Rust sdist and failed + # with "orjson does not support free-threaded Python". + package-path: '.' + + - name: Upload wheel artifacts + uses: actions/upload-artifact@v4 + with: + name: wheels-linux + path: dist/*.whl + + build-windows: + runs-on: windows-latest strategy: + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install twine + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" - - name: Build wheels on Ubuntu - if: matrix.os == 'ubuntu-latest' - uses: RalfG/python-wheels-manylinux-build@v0.7.1-manylinux2014_x86_64 + - name: Build wheels (Windows) + # Build isolation installs only the requirements in [build-system]. + run: uv build --wheel --python ${{ matrix.python-version }} + + - name: Upload wheel artifacts + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.python-version }} + path: dist/*-win_amd64.whl + if-no-files-found: error + + build-source: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-versions: 'cp39-cp39 cp310-cp310 cp311-cp311 cp312-cp312' - build-requirements: 'cython numpy' + version: "latest" + + - name: Set up Python + run: uv python install 3.11 - - name: Build wheels on Windows - if: matrix.os == 'windows-latest' + - name: Build source distribution run: | - pip install cibuildwheel - cibuildwheel --output-dir dist - # You may need to adjust the above command based on your package's specific requirements + # Pin the interpreter explicitly: left to its own discovery uv can pick + # a free-threaded build (3.13t/3.14t), and some dependencies (orjson) + # publish no free-threaded wheels and refuse to compile there. + uv sync --extra build --no-dev --python 3.11 - - name: Upload wheel artifacts + # Build source distribution + uv build --sdist --python 3.11 + + - name: Upload source artifacts uses: actions/upload-artifact@v4 with: - name: wheels-${{ matrix.os }}-py${{ matrix.python-version }} - path: dist/*.whl + name: source-dist + path: dist/*.tar.gz deploy: - needs: build + needs: [build, build-windows, build-source] runs-on: ubuntu-latest + if: github.event_name == 'release' + environment: + name: pypi + url: https://pypi.org/p/navconfig + permissions: + id-token: write # For trusted publishing to PyPI steps: - - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" - name: Download all artifacts - uses: actions/download-artifact@v4.1.7 + uses: actions/download-artifact@v4 with: - path: dist + path: dist-artifacts - - name: Move wheel files to 'dist' directory + - name: Organize distribution files run: | - find dist -name '*.whl' -exec mv {} dist \; + mkdir -p dist + + # Move all wheel files to dist directory + find dist-artifacts -name '*.whl' -exec mv {} dist/ \; - - name: Check for wheel types - id: check_wheels + # Move source distribution to dist directory + find dist-artifacts -name '*.tar.gz' -exec mv {} dist/ \; + + - name: Check for distribution files + id: check_dist run: | - echo "Checking for wheel types..." + echo "Checking for distribution files..." + + # Check for manylinux wheels if ls dist/*-manylinux*.whl 1> /dev/null 2>&1; then - echo "Found manylinux wheels." + echo "Found manylinux wheels:" + ls -la dist/*-manylinux*.whl echo "HAS_MANYLINUX_WHEELS=true" >> $GITHUB_ENV + else + echo "No manylinux wheels found." + echo "HAS_MANYLINUX_WHEELS=false" >> $GITHUB_ENV fi - if ls dist/*-win_*.whl 1> /dev/null 2>&1; then - echo "Found Windows wheels." + + # Check for Windows wheels + if ls dist/*-win_amd64.whl 1> /dev/null 2>&1; then + echo "Found Windows wheels:" + ls -la dist/*-win_amd64.whl echo "HAS_WINDOWS_WHEELS=true" >> $GITHUB_ENV + else + echo "No Windows wheels found." + echo "HAS_WINDOWS_WHEELS=false" >> $GITHUB_ENV fi - - name: List files in dist - run: ls -l dist - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: '3.x' + # Check for source distribution + if ls dist/*.tar.gz 1> /dev/null 2>&1; then + echo "Found source distribution:" + ls -la dist/*.tar.gz + echo "HAS_SOURCE_DIST=true" >> $GITHUB_ENV + else + echo "No source distribution found." + echo "HAS_SOURCE_DIST=false" >> $GITHUB_ENV + fi - - name: Install twine - run: pip install twine + - name: List all distribution files + run: | + echo "All files in dist directory:" + ls -la dist/ + echo "" + echo "File count: $(ls -1 dist/ | wc -l)" + + # Refuse to publish a partial release: a version that lands on PyPI with + # wheels but no sdist (or vice versa) cannot be corrected, only yanked. + - name: Verify the release is complete + run: | + if [ "$HAS_MANYLINUX_WHEELS" != "true" ] || [ "$HAS_WINDOWS_WHEELS" != "true" ] || [ "$HAS_SOURCE_DIST" != "true" ]; then + echo "::error::Incomplete release - Linux wheels: $HAS_MANYLINUX_WHEELS, Windows wheels: $HAS_WINDOWS_WHEELS, sdist: $HAS_SOURCE_DIST" + exit 1 + fi - - name: Publish manylinux wheels to Production PyPI - if: env.HAS_MANYLINUX_WHEELS == 'true' + # Wheels and sdist go up in a single twine invocation so the release is + # published as one unit. --skip-existing keeps a re-run idempotent instead + # of failing on files that already uploaded before an earlier attempt died. + - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.NAVCONFIG_PYPI_API_TOKEN }} - run: twine upload dist/*-manylinux*.whl + run: | + uv tool install twine + uv tool run twine upload --non-interactive --skip-existing \ + dist/*-manylinux*.whl dist/*-win_amd64.whl dist/*.tar.gz - - name: Publish Windows wheels to Production PyPI - if: env.HAS_WINDOWS_WHEELS == 'true' - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.NAVCONFIG_PYPI_API_TOKEN }} - run: twine upload dist/*-win_*.whl + + # Optional: Test installation job + test-install: + needs: deploy + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + + - name: Wait for PyPI propagation + run: python -c "import time; time.sleep(60)" + + - name: Test installation from PyPI + run: | + # Install into the interpreter selected by setup-python on either OS. + uv pip install --system --python ${{ matrix.python-version }} --only-binary navconfig navconfig + + # Test basic import + python -c "import navconfig; print(f'NavConfig {navconfig.__version__} installed successfully')" + python -c "from navconfig import config; print('Config object created successfully')" + continue-on-error: true # Don't fail the release if tests fail diff --git a/.gitignore b/.gitignore index 5a9ccd67..8332bae7 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,9 @@ file_env client_secrets.json credentials.txt examples/encrypt_env.py +# parrot LLM-wiki state (local retrieval plane) +.parrot/ +.claude/worktrees/ +navconfig.code-workspace +elvis.json +.mcp.json diff --git a/.uvignore b/.uvignore new file mode 100644 index 00000000..3440ce15 --- /dev/null +++ b/.uvignore @@ -0,0 +1,12 @@ +# Similar to .gitignore but for uv +__pycache__/ +*.pyc +*.pyo +*.so +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +build/ +dist/ +*.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 015f0545..5690e765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-08-21 + +Breaking release: the `kardex` CLI is now organised in sub-commands and the +`navconfig` package builds its configuration lazily. + +### Added +* Sub-command groups for `kardex`, replacing the flat command layout: + * `kardex env create` -- scaffold `env/`, `etc/config.ini`, `logs/` and + `templates/` (what `kardex create` used to do). + * `kardex env create --split` -- also create the supplementary + `.env.resources`, `.env.databases`, `.env.api`, `.env.cache` and + `.env.local` files NavConfig loads alongside the base `.env`. + * `kardex env new <name>` -- add an environment from the shared `env/.env` + (replaces `kardex new-env`). + * `kardex vault create` -- write the HashiCorp Vault directives into + `env/<env>/.env`, creating the file when it does not exist yet. + * `kardex vault migrate` -- push the variables of an environment into + Vault, with `--dry-run`, `--include-extra` and `--keep-existing`. + * `kardex vault save VARIABLE:VALUE` -- store one or more variables in + Vault. + * `kardex log enable [--logstash]` -- write the `[logging]` section of + `etc/config.ini`, optionally enabling the Logstash handler. +* `kardex --version`, and a `--force` flag on the scaffolding commands. +* `navconfig.bootstrap()`, the explicit entry point to the initialization + that used to happen at import time. +* Bundled samples for the split `.env` layout and for the Vault block. + +### Changed +* **Initialization order.** `navconfig/__init__.py` no longer instantiates + `Kardex` while the package is being imported; `config`, `BASE_DIR`, + `DEBUG` and the other package-level names are resolved on first access + (PEP 562). Importing `navconfig.cli` therefore no longer requires an + `env/` directory -- the bug that made `kardex create` fail on precisely + the fresh projects it was supposed to bootstrap. Code that relied on + `import navconfig` alone to populate `os.environ` must now call + `navconfig.bootstrap()` (or touch any exported name) explicitly. +* `kardex vault create` writes `VAULT_URL`, `VAULT_MOUNT_POINT` and + `VAULT_VERSION` -- the directives NavConfig actually reads. The previous + `new-env --vault` block advertised `VAULT_ADDR`, `VAULT_ROLE_ID`, + `VAULT_SECRET_ID`, `VAULT_SECRET_PATH` and `VAULT_NAMESPACE`, none of + which are consulted by the loader. +* The Vault block is delimited by `# --- navconfig:vault ---` markers, so + `kardex vault create --force` rewrites it without duplicating directives. +* `kardex log enable` edits `etc/config.ini` line by line, preserving the + comments that document each option. +* The "environment is missing" errors now point at `kardex env create`. + +### Removed +* `kardex create` and `kardex new-env`. Use `kardex env create` and + `kardex env new` instead. There is no compatibility shim. + +## [2.3.0] - 2026-08-07 +### Added +* Official support for Python 3.13 and 3.14: both are now built in the release + wheel matrix (`cp313-cp313`, `cp314-cp314`) and advertised via trove classifiers. +* `VAULT_ENV` to override the vault path segment independently of `ENV`. + +### Changed +* Relaxed `hvac` from the exact pin `==2.3.0` to `>=2.3.0`, matching the + constraint already used by the `all`, `default` and `hvac` extras. + +### Fixed +* The `build` job no longer runs a redundant Python matrix. The manylinux action + builds every interpreter listed in `python-versions` inside its own container, + so the matrix was rebuilding the identical set of wheels once per version. + +### Removed +* Dropped the stale `Programming Language :: Python :: 3.9` classifier, which + contradicted `requires-python = ">=3.10.1"`. + ## [1.0.0] - 2022-10-18 * Added python-datamodel as dependency for build Dataclasses. * replaced rapidjson with orjson. diff --git a/MANIFEST.in b/MANIFEST.in index 515630bf..dbed2912 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,21 +1,45 @@ -include LICENSE -include CHANGES.rst -include CONTRIBUTING.md -include SECURITY.md -include INSTALL.md +# Include all Python source files +include *.py +recursive-include navconfig *.py +recursive-include navconfig *.pyi +recursive-include navconfig *.pyx +recursive-include navconfig *.pxd + +# Include package data +include navconfig/py.typed +recursive-include navconfig *.yaml +recursive-include navconfig *.yml +recursive-include navconfig *.toml +recursive-include navconfig *.sample + +# Include documentation include README.md -include USAGE.md -include Makefile +include LICENSE +include CHANGELOG.md + +# Include configuration files +include pyproject.toml +include setup.py -graft navconfig -graft tests +# Include Cython source files for debugging/development +recursive-include navconfig *.pyx +recursive-include navconfig *.pxd -# Exclude tests, settings, env, examples, and bin folders +# Exclude build artifacts and cache global-exclude *.pyc -prune docs -prune settings +global-exclude *.pyo +global-exclude *.so +global-exclude __pycache__ +global-exclude .pytest_cache +global-exclude *.egg-info +prune build +prune dist +prune .git +prune .venv prune env +prune .tox + +# Exclude test files from distribution +prune tests prune examples -prune bin -recursive-exclude */__pycache__ -prune */__pycache__ +prune docs/_build diff --git a/Makefile b/Makefile index 75f5832f..9b20be41 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,235 @@ +# Navconfig Makefile +# This Makefile provides a set of commands to manage the Navconfig project. + +.PHONY: venv install develop setup dev dev-install editable release format lint test clean distclean lock sync + +# Python version to use +PYTHON_VERSION := 3.11 + +# Auto-detect available tools +HAS_UV := $(shell command -v uv 2> /dev/null) +HAS_PIP := $(shell command -v pip 2> /dev/null) + +# Install uv for faster workflows +install-uv: + curl -LsSf https://astral.sh/uv/install.sh | sh + @echo "uv installed! You may need to restart your shell or run 'source ~/.bashrc'" + @echo "Then re-run make commands to use faster uv workflows" + +# Create virtual environment venv: - python3.11 -m venv .venv - echo 'run `source .venv/bin/activate` to start develop NavConfig' + uv venv --python $(PYTHON_VERSION) .venv + @echo 'run `source .venv/bin/activate` to start develop with Navconfig.' +# Install production dependencies using lock file install: - pip install -e .[default] + uv sync --frozen --no-dev + @echo "Production dependencies installed. Use 'make develop' for development setup." + +# Install with all extras +install-all: + uv sync --extra all + @echo "All dependencies installed. Use 'make develop' for development setup." +# Generate lock files (uv only) +lock: +ifdef HAS_UV + uv lock +else + @echo "Lock files require uv. Install with: pip install uv" +endif + +# Install all dependencies including dev dependencies develop: - pip install -e .[default] - python -m pip install -Ur docs/requirements-dev.txt + uv sync --frozen --extra all --extra dev + +# Alternative: install without lock file (faster for development) +develop-fast: + uv pip install -e .[all,dev] +# Install in development/editable mode (what you're looking for!) +dev-install: build-inplace + @echo "Installing NavConfig in development mode..." + uv pip install -e . + @echo "✅ NavConfig installed in editable mode. Changes to source will be reflected immediately." + +# Alias for dev-install +editable: dev-install + +# Complete development setup: dependencies + Cython + editable install +dev-setup: develop build-inplace dev-install + @echo "🎉 Complete development environment ready!" + @echo " - All dependencies installed" + @echo " - Cython extensions compiled" + @echo " - Package installed in editable mode" + +# Setup development environment from requirements file (if you still have one) +setup: + uv pip install -r requirements/requirements-dev.txt + +# Install in development mode using flit (if you want to keep flit) dev: + uv pip install flit flit install --symlink +# Build and publish release release: lint test clean + uv build + uv publish + +# Alternative release using flit +release-flit: lint test clean flit publish +# Format code format: - python -m black navconfig + uv run black navconfig +# Lint code lint: - python -m pylint --rcfile .pylint navconfig/*.py - python -m black --check navconfig - -setup_test: - pip install pytest - pip install pytest-asyncio - pip install pytest-xdist - pip install pytest-assume + uv run pylint --rcfile .pylint navconfig/*.py + uv run black --check navconfig +# Run tests with coverage test: - python -m coverage run -m navconfig.tests - python -m coverage report - python -m mypy navconfig/*.py + uv run coverage run -m navconfig.tests + uv run coverage report + uv run mypy navconfig/*.py + +# Alternative test command using pytest directly +test-pytest: + uv run pytest -perf: - python -m unittest -v navconfig.tests.perf +# Add new dependency and update lock file +add: + @if [ -z "$(pkg)" ]; then echo "Usage: make add pkg=package-name"; exit 1; fi + uv add $(pkg) +# Add development dependency +add-dev: + @if [ -z "$(pkg)" ]; then echo "Usage: make add-dev pkg=package-name"; exit 1; fi + uv add --dev $(pkg) + +# Remove dependency +remove: + @if [ -z "$(pkg)" ]; then echo "Usage: make remove pkg=package-name"; exit 1; fi + uv remove $(pkg) + +# Compile Cython extensions using setup.py +build-cython: + @echo "Compiling Cython extensions..." + python setup.py build_ext + +# Build Cython extensions in place (for development) +build-inplace: + @echo "Installing build dependencies..." + @uv pip install setuptools==74.0.0 Cython==3.0.11 wheel==0.44.0 + @echo "Building Cython extensions in place..." + python setup.py build_ext --inplace + +# Full build using uv +build: clean + @echo "Building package with uv..." + uv build + +# Update all dependencies +update: + uv lock --upgrade + +# Show project info +info: + uv tree + +# Clean build artifacts +clean: + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + find . -name "*.pyc" -delete + find . -name "*.pyo" -delete + find . -name "*.so" -delete + find . -type d -name __pycache__ -delete + find . -type f -name "*.pyc" -delete + find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + @echo "Clean complete." + +# Remove virtual environment distclean: rm -rf .venv + rm -rf uv.lock + +# Version management +bump-patch: + @python -c "import re; \ + content = open('navconfig/version.py').read(); \ + version = re.search(r'__version__ = \"(.+)\"', content).group(1); \ + parts = version.split('.'); \ + parts[2] = str(int(parts[2]) + 1); \ + new_version = '.'.join(parts); \ + new_content = re.sub(r'__version__ = \".+\"', f'__version__ = \"{new_version}\"', content); \ + open('navconfig/version.py', 'w').write(new_content); \ + print(f'Version bumped to {new_version}')" + +bump-minor: + @python -c "import re; \ + content = open('navconfig/version.py').read(); \ + version = re.search(r'__version__ = \"(.+)\"', content).group(1); \ + parts = version.split('.'); \ + parts[1] = str(int(parts[1]) + 1); \ + parts[2] = '0'; \ + new_version = '.'.join(parts); \ + new_content = re.sub(r'__version__ = \".+\"', f'__version__ = \"{new_version}\"', content); \ + open('navconfig/version.py', 'w').write(new_content); \ + print(f'Version bumped to {new_version}')" + +bump-major: + @python -c "import re; \ + content = open('navconfig/version.py').read(); \ + version = re.search(r'__version__ = \"(.+)\"', content).group(1); \ + parts = version.split('.'); \ + parts[0] = str(int(parts[0]) + 1); \ + parts[1] = '0'; \ + parts[2] = '0'; \ + new_version = '.'.join(parts); \ + new_content = re.sub(r'__version__ = \".+\"', f'__version__ = \"{new_version}\"', content); \ + open('navconfig/version.py', 'w').write(new_content); \ + print(f'Version bumped to {new_version}')" + +# Create wheel +wheel: + uv run python setup.py bdist_wheel + +# Test the development installation +test-dev: + @echo "Testing development installation..." + @python -c "import navconfig; print(f'✅ NavConfig {navconfig.__version__} imported successfully')" + @python -c "from navconfig import config; print(f'✅ Config object created: {config.site_root}')" + @echo "✅ Development installation is working correctly!" + +help: + @echo "Available targets:" + @echo " venv - Create virtual environment" + @echo " install - Install production dependencies" + @echo " develop - Install development dependencies" + @echo " dev-install - Install package in development/editable mode" + @echo " editable - Alias for dev-install" + @echo " dev-setup - Complete development setup (deps + build + install)" + @echo " build - Build package" + @echo " build-inplace - Build Cython extensions in place" + @echo " release - Build and publish package" + @echo " test - Run tests" + @echo " test-dev - Test development installation" + @echo " format - Format code" + @echo " lint - Lint code" + @echo " clean - Clean build artifacts" + @echo " install-uv - Install uv" + @echo " add pkg=name - Add dependency" + @echo " remove pkg=name - Remove dependency" + @echo " bump-patch - Bump patch version" + @echo " bump-minor - Bump minor version" + @echo " bump-major - Bump major version" + @echo "" + @echo "Quick development workflow:" + @echo " make venv && source .venv/bin/activate" + @echo " make dev-setup # Complete setup" + @echo " make test-dev # Verify installation" diff --git a/README.md b/README.md index abce9c78..0762786d 100644 --- a/README.md +++ b/README.md @@ -1,239 +1,418 @@ -# Navigator NavConfig # +# NavConfig -NavConfig is a configuration tool for getting variables from environment and other sources. -Is used by Navigator Framework, but is possible to use in other applications as well. +NavConfig is a configuration management library for Python projects. It is +the default configuration layer of the Navigator Framework, but it works +perfectly as a stand-alone tool for any Python application. -NavConfig can load Configuration directives from different sources (can be mixed): +NavConfig can load configuration directives from multiple sources (and +combine them): -- Environment files (.env) -- pyproject.toml files -- INI files (using configParser) -- TOML/YAML files -- REDIS variables -- Memcached Variables -- Python files (using settings.py) +- Environment files (`.env`) +- INI files (via `configparser`) +- TOML and YAML files +- `pyproject.toml` +- Redis +- HashiCorp Vault +- Python settings modules (`settings/settings.py`) -The main goal of NavConfig is centralize configuration access in a single and -immutable unique point of truth. +The main goal of NavConfig is to centralize configuration access through a +single, immutable point of truth that can be shared across modules. -NavConfig can be shared across several modules. +Documentation: <https://phenobarbital.github.io/navconfig/> -## Motivation ## +Usage guide: <https://phenobarbital.github.io/navconfig/usage.html> — a runnable +Python application, typed accessors, INI sections and troubleshooting. -Any Application requires too many configuration options, some configuration options need to be secrets, credentials, etc, and also, can depend on the environment where the application runs (development, testing, production, etc.). +For local preview and publishing instructions, see [Maintaining the documentation site](docs/github-pages.md). + + +## Motivation + +Applications require many configuration options. Some of those options hold +secrets or credentials and must be kept separate from general settings. +Configuration also varies between environments (development, staging, +production). + +NavConfig addresses this by loading secrets from `.env` files and structured +settings from INI/TOML/YAML files, keeping concerns separated. It also +supports retrieving configuration from external stores such as Redis +or HashiCorp Vault. -Instead of creating Python files, we are using python-dotenv + INI files to separate concerns (secrets vs configuration options), NavConfig also supports getting data instead of INI files from YAML or TOML files (for complex types), pyproject.toml files or REDIS variables. ## Installation + ```bash pip install navconfig ``` -if you're looking for supporting memcache,redis: +To include optional backends: ```bash -pip install navconfig[memcache,redis] +# Redis support +pip install navconfig[redis] + +# HashiCorp Vault support +pip install navconfig[hvac] + +# Logstash logging +pip install navconfig[logstash] + +# All features +pip install navconfig[all] ``` -or adding all features, including logging facility with Logstash Support: + +## The `kardex` CLI + +NavConfig ships a command line tool called `kardex` that bootstraps and +maintains the configuration layout of a project. It is organised in +command groups, each with its own actions: + +| Command | What it does | +| ------- | ------------ | +| `kardex env create` | Create the default project structure. | +| `kardex env create --split` | Same, plus the supplementary `.env.*` files. | +| `kardex env new <name>` | Add an environment from the shared `env/.env`. | +| `kardex vault create` | Write the HashiCorp Vault directives into `env/<env>/.env`. | +| `kardex vault migrate` | Push the variables of an environment into Vault. | +| `kardex vault save VAR:VALUE` | Store one or more variables in Vault. | +| `kardex log enable` | Enable the `[logging]` section of `etc/config.ini`. | + +Run `kardex <group> <action> --help` for the full list of options. + +> **Upgrading from 2.x:** `kardex create` and `kardex new-env` were removed +> in 3.0. Use `kardex env create` and `kardex env new` instead. + + +## Quickstart + +### 1. Create the project structure ```bash -pip install navconfig[all] +kardex env create --env dev ``` -## Quickstart ## +This generates the following structure in the current directory: -First of all, let's create a simple configuration environment. +```text +. +|-- env/ +| |-- .env (shared across environments, no ENV= pin) +| +-- dev/ +| +-- .env (pinned to ENV=dev) +|-- etc/ +| +-- config.ini +|-- logs/ ++-- templates/ +``` -Creates a directory for an .INI file and the environment (.env) file. +- `env/.env` -- values shared by every environment. `kardex env new` uses it + as the template for new environments. +- `env/dev/.env` -- environment variables (secrets, feature flags, paths). +- `etc/config.ini` -- INI-based settings consumed by NavConfig, including a + `[logging]` section. +- `logs/` -- default directory where rotating log files are written. +- `templates/` -- default directory for template files. + +Existing files are never overwritten; pass `--force` when you do want them +replaced. Use `--path` to point at a different project root: ```bash -mkdir {env,etc} +kardex env create --env dev --path /srv/myapp ``` -put a .env file inside of the "env" folder, the first line is the directive to know where the "INI" file lives (even if we can put the . INI file outside of the current dir). +### 2. Split the configuration across several files (optional) -the directory tree is very simple: +NavConfig loads `.env` first and then a set of supplementary files, which +keeps unrelated concerns apart. Pass `--split` to create them all: -```text -|- myapp/ -| |- __init__.py -| |- pyproject.toml -| |- env/ -| | |- .env -| | |- dev/ -| | |- .env -| | |- prod/ -| | |- .env -| |- etc/ -| | |- myconfig.ini -| | ... +```bash +kardex env create --env dev --split ``` ```text -# file: .env -CONFIG_FILE=etc/myconfig.ini # CONFIG FILE reference to INI location. -APP_NAME=My App +env/dev/ +|-- .env base configuration and Vault credentials +|-- .env.resources paths and resource-level directives +|-- .env.databases database connection settings +|-- .env.api HTTP layer settings +|-- .env.cache Redis / cache backend settings ++-- .env.local local overrides, loaded last (keep out of git) ``` -Navconfig exposes a "config" object to retrieve your environment variables inside your application. - -```python -from navconfig import config +Files are loaded in that order, so `.env.local` always wins. Everything ends +up in the same flat namespace, so `config.get("DBHOST")` works regardless of +which file declared it. -APP_NAME = config.get('APP_NAME') -# the result is "My App". +### 3. Add more environments +```bash +kardex env new prod +kardex env new staging --split ``` -but also you can use config as a object: +This copies `env/.env` (or the bundled sample if no shared file exists) into +`env/<name>/.env`, adjusting the `ENV` variable automatically. -```python -from navconfig import config +### 4. Select an environment -APP_NAME = config.APP_NAME -# the result is "My App". +Set the `ENV` variable before starting your application: +```bash +ENV=prod python app.py ``` -## Retrieving values ## +NavConfig loads `env/prod/.env` and any INI file referenced by its +`CONFIG_FILE` directive. -Once an instance of `NavConfig` has been installed, values can be retrieved through: -```python ->>> config.get("APP_NAME") -'My App' +## HashiCorp Vault + +### Configure the connection + +```bash +kardex vault create --env dev \ + --url http://vault.internal:8200 \ + --token "$VAULT_TOKEN" \ + --mount-point myapp ``` -there are options available for retrieving values as strings (`get`), integer (`getint()`), booleans (`getboolean()`), but also lists (`getlist()`) and dictionaries (`getdict()`). +This appends a delimited block to `env/dev/.env` (creating the file if it +does not exist yet) with the directives NavConfig reads: -An optional second argument can be provided to any `get*` which will be returned as default if -the given config key isn't found: +```ini +VAULT_ENABLED=true +VAULT_URL=http://vault.internal:8200 +VAULT_TOKEN=... +VAULT_MOUNT_POINT=myapp +VAULT_VERSION=2 +# VAULT_ENV= +``` -```python ->>> config.get("APP_NAME", "My App") -'My App' +Secrets are then read from `<VAULT_MOUNT_POINT>/<ENV>/`, and merged on top +of the file-based values. Set `VAULT_ENV` to read from a different path +segment than `ENV`; set `NAVCONFIG_FILE_OVERRIDE_ENABLED=true` to let the +`.env` files win over Vault instead. + +Re-running the command updates only the directives you pass on the command +line, which makes token rotation a one-liner. Pass `--force` to rewrite the +whole block. + +### Migrate an existing `.env` into Vault + +```bash +kardex vault migrate --env dev --dry-run # inspect first +kardex vault migrate --env dev ``` -## Configuration directories ## +Two families of variables are deliberately left in the `.env` file, because +NavConfig needs them before it can reach Vault: -By default, `NavConfig` will look for configuration files the base path of project, based on the file type: +- the Vault directives themselves (`VAULT_*`), and +- the bootstrap directives (`ENV`, `CONFIG_FILE`, `SITE_ROOT`, ...), which + can be included anyway with `--include-bootstrap`. - * .env files will be searched on a `env/` directory. - * .yml/.toml files will be searched on `env/` directory. - * pyproject.toml file will be searched on root of project. - * .ini files will be searched on `etc/` directory. +Useful options: `--include-extra` (also migrate the `.env.*` files), +`--keep-existing` (never overwrite a key already stored in Vault), `--file` +(migrate an arbitrary file) and `--yes` (skip the confirmation prompt). -## Working with Environments ## +Values are masked in the output, and nothing is written until you confirm. -NavConfig can load all environment variables (and the .INI files associated within the .env file) from different directories, -every directory works as a new Environment and you can split your configuration for different environments, like this: +### Store single variables +```bash +kardex vault save DB_PASSWORD:s3cr3t +kardex vault save "DSN:postgres://user:pass@host:5432/db" API_KEY:abc123 ``` -env/ -. -├── dev -| |- .env -├── prod -| |- .env -├── staging -| |- .env -└── experimental -| |- .env -``` -Then, you can load your application using the "ENV" environment variable: +Only the first colon separates the name from the value, so values may +contain colons themselves. + + +## Logging + +Enable the logging facility with: ```bash -ENV=dev python app.py +kardex log enable ``` +That writes the `[logging]` section of `etc/config.ini` (creating the file +from the bundled sample when missing) with console and rotating-file output +enabled, and creates the log directory. Comments in the INI file are kept. + +To also forward records to a Logstash server: + +```bash +kardex log enable --logstash \ + --logstash-host logs.internal \ + --logstash-port 5044 \ + --logstash-level INFO +``` -## Configure Logging ## +The Logstash handler requires `pip install navconfig[logstash]`. -NavConfig has owns logging facility, if you load logging_config from Navconfig, you will get -a logging configuration using the Python dictConfig format. +Other options: `--loglevel`, `--logdir`, `--quiet` (no console output), +`--no-file` (no rotating file handler) and `--mailer` (email alerts on +CRITICAL records). -also, if you put an environment variable called "logstash_enabled = True", there is a ready to use Logging facility using Logstash. +Apply the resulting configuration in your application: ```python import logging -from navconfig.logging import ( - logdir, - loglevel, - logging_config -) from logging.config import dictConfig +from navconfig.logging import logging_config + dictConfig(logging_config) + +logger = logging.getLogger("MY_APP") +logger.info("Hello World") +``` + +Console output uses colored formatting by default: + +``` +[INFO] 2024-03-11 19:31:39,408 MY_APP: Hello World ``` -To use just the logger as expected with logging.getLogger(), e.g. + +## Accessing configuration ```python -logger = logging.getLogger('MY_APP_NAME') -logger.info('Hello World') +from navconfig import config + +APP_NAME = config.get("APP_NAME") +# "MyApp" ``` -By default, the current logging configuration make echo to the standard output: -```bash -[INFO] 2022-03-11 19:31:39,408 navigator: Hello World +Attribute-style access also works: + +```python +APP_NAME = config.APP_NAME +``` + +### Typed accessors + +```python +config.get("APP_NAME") # str +config.getint("PORT", fallback=8080) # int +config.getboolean("DEBUG") # bool +config.getlist("ALLOWED_HOSTS") # list (comma-separated) +config.getdict("EXTRA") # dict ``` -## Custom Settings ## -with Navconfig, users can create a python module called "settings.py" on package "settings" to create new configuration options and fine-tune their configuration. +An optional `fallback` argument is returned when the key is not found: + +```python +config.get("MISSING_KEY", fallback="default_value") +``` + +### Initialization + +NavConfig resolves the project layout and loads the environment the first +time one of its package-level names is accessed (`config`, `BASE_DIR`, +`DEBUG`, `ENV`, ...), not while the package is being imported. Call +`navconfig.bootstrap()` when you need the environment loaded into +`os.environ` as a side effect without touching any of those names: + +```python +import navconfig + +navconfig.bootstrap() +``` + + +## Configuration directories + +By default NavConfig looks for files relative to the project root: + +| File type | Default location | +| -------------------- | ------------------------------ | +| `.env` | `env/` (plus ENV subdirectory) | +| `.yml` / `.toml` | `env/` | +| `pyproject.toml` | project root | +| `.ini` | `etc/` | + +A typical project looks like this: ```text -|- myapp/ -| |- settings/ -| |- __init__.py -| |- settings.py +myapp/ +|-- __init__.py +|-- pyproject.toml +|-- env/ +| |-- .env (shared base file) +| |-- dev/ +| | +-- .env +| |-- staging/ +| | +-- .env +| +-- prod/ +| +-- .env +|-- etc/ +| +-- config.ini +|-- logs/ ++-- settings/ + |-- __init__.py + +-- settings.py (optional) ``` -on "settings.py", we can create new variables using python code: + +## Custom settings module + +You can create a Python package called `settings` in your project to define +additional configuration derived from NavConfig values. + +Inside `settings/settings.py`: ```python +import sys from navconfig import config, DEBUG -print('::: LOADING SETTINGS ::: ') - -# we are in local aiohttp development? -LOCAL_DEVELOPMENT = (DEBUG is True and sys.argv[0] == 'run.py') -SEND_NOTIFICATIONS = config.get('SEND_NOTIFICATIONS', fallback=True) +LOCAL_DEVELOPMENT = DEBUG is True and sys.argv[0] == "run.py" +SEND_NOTIFICATIONS = config.get("SEND_NOTIFICATIONS", fallback=True) ``` -And those variables are reachable using "navconfig.conf" module: +Variables defined there are accessible through `navconfig.conf`: ```python from navconfig.conf import LOCAL_DEVELOPMENT -if LOCAL_DEVELOPMENT is True: - print('We are in a Local instance.') - +if LOCAL_DEVELOPMENT: + print("Running in local development mode.") ``` -## Dependencies ## - * Python >= 3.9 - * ConfigParser - * Python-Dotenv - * pytomlpp - * PyYAML - * redis - * pylibmc +## Dependencies + +- Python >= 3.10 +- python-dotenv +- configparser +- PyYAML +- pytomlpp +- orjson +- cryptography / pycryptodomex +- hvac (HashiCorp Vault client) + +Optional: `redis`, `python-logstash-async`, `uvloop`. + +Python 3.14 is supported. Building the Cython extensions requires Cython >= 3.1.4. + +On supported platforms, install `navconfig[uvloop]` to enable uvloop >= 0.22.1. NavConfig +loads and activates it automatically when configuration is first accessed (or +`navconfig.bootstrap()` is called). Importing `navconfig` alone does not load +uvloop. Windows skips uvloop, including when installing `navconfig[uvloop]` or +`navconfig[all]`. When uvloop is absent, the current asyncio event loop policy +is preserved. -### Contribution guidelines ### -Please have a look at the Contribution Guide +## Contribution guidelines -* Writing tests -* Code review -* Other guidelines +Please see the Contribution Guide for details on: -### Who do I talk to? ### +- Writing tests +- Code review process +- Other guidelines -* Repo owner or admin -* Other community or team contact -### License ### +## License -NavConfig is released under MIT license. +NavConfig is released under the MIT License. diff --git a/artifacts/plan_github_pages.md b/artifacts/plan_github_pages.md new file mode 100644 index 00000000..c44eb11e --- /dev/null +++ b/artifacts/plan_github_pages.md @@ -0,0 +1,37 @@ +# GitHub Pages documentation and usage + +## Plan + +1. Extend the existing static site in `site/`, retaining the documentation and + adding a dedicated, runnable usage guide with shared CSS and navigation. +2. Check examples against `navconfig/kardex.py`, the loaders and CLI; correct + inaccurate accessor examples and explain initialization and environment selection. +3. Add a dependency-free site link checker and run it for pull requests and before + the existing GitHub Pages deployment from `main`. +4. Document local preview and Pages setup; point package metadata to the site. +5. Validate local links, Python snippets and the quickstart in an isolated temporary + project. Save validation output under `artifacts/logs/`. + +## Assumptions and risks + +- `site/index.html` and `.github/workflows/pages.yml` already exist; extend them. +- No `CONTEXT.md` or nested `AGENTS.md` was found. +- Keep static HTML/CSS and use Python's standard library for validation and preview; + no additional dependencies or application changes are needed. +- Preserve the existing `main` publishing branch. GitHub Pages must use GitHub Actions + as its source; remote enablement and deployment have not been verified locally. +- Documentation must reflect the implementation, including keyword-only use of + `fallback` in examples and the limits of file override behavior. +- A new branch was offered. Work continues on the existing branch unless requested. + +## Validation results + +- Two HTML pages passed local link, fragment and duplicate ID checks. +- All eight tagged Python snippets passed syntax checks and executed successfully + against the local package in an isolated temporary project. +- CLI scaffolding, expected quickstart output, typed INI results, logging setup and + production environment creation passed. +- Workflow YAML parsing, deployment dependencies, Black formatting, import sorting + and `git diff --check` passed. +- Reviewed the usage page in headless Chrome at 390 × 844 pixels. +- Logs are in `artifacts/logs/site-*.log`. No remote deployment was performed. diff --git a/artifacts/plan_optional_uvloop.md b/artifacts/plan_optional_uvloop.md new file mode 100644 index 00000000..88ac3380 --- /dev/null +++ b/artifacts/plan_optional_uvloop.md @@ -0,0 +1,36 @@ +# Optional uvloop on Windows + +Make uvloop activation lazy and optional while preserving automatic use when +configuration is bootstrapped on supported platforms. + +1. Guard Windows before importing uvloop in `navconfig/utils/uvl.py`; use a + single installation call and retain the missing-dependency fallback. +2. Move automatic activation from package import to `bootstrap()`. +3. Add Windows exclusions to the uvloop extras and tox dependency; refresh + `uv.lock` and document activation timing in the README. +4. Test deferred imports, Windows skipping, missing dependencies, and automatic + activation. Run the existing suite and save logs under `artifacts/logs/`. +5. Include Python 3.14: require uvloop >= 0.22.1 and Cython >= 3.1.4, align + release build requirements, extend tox through 3.14, and validate using + the installed Python 3.14 interpreter in a separate temporary environment. +6. Remove the redundant `return` in `finally` from `Kardex.__getattr__`, which + triggers a Python 3.14 SyntaxWarning; preserve existing deserialized return + values and cover them with regression tests. + +Risk: activation now occurs on first configuration access. Existing running +event loops cannot be replaced. Native Windows execution is unavailable; +validate the platform guard using simulated Windows tests. + +Validation: + +- Python 3.14.3: editable build including Cython extensions succeeded; all 89 + tests passed with SITE_ROOT set to the workspace for the temporary environment. +- Python 3.11.15: all 73 focused uvloop, attribute, and CLI tests passed. The + full suite was interrupted after 70 passing tests while external-service + checks delayed completion. +- Real uvloop activation on Python 3.14 passed; Windows/Linux dependency markers + evaluated correctly; all 40 Python source files compiled with SyntaxWarning + treated as an error. +- Black and import-order checks passed for the new tests and uvloop helper; + the lockfile is current and `git diff --check` passed. +- Native Windows execution remains unverified. diff --git a/artifacts/plan_windows_release_251.md b/artifacts/plan_windows_release_251.md new file mode 100644 index 00000000..0290171c --- /dev/null +++ b/artifacts/plan_windows_release_251.md @@ -0,0 +1,9 @@ +# Windows release support for 2.5.1 + +- Add Windows x64 wheel builds for CPython 3.10–3.14 in `.github/workflows/release.yml` using isolated uv builds. +- Require Windows builds and artifacts before publishing Linux wheels, Windows wheels, and the source distribution together. +- Run installation smoke checks on Linux and Windows using portable commands and the selected Python interpreter. +- Set `navconfig/version.py` to 2.5.1. +- Validate YAML, release artifact handling, shell syntax, and version metadata locally; native Windows compilation requires GitHub Actions. + +Assumptions: Windows support targets x64 and the same standard CPython versions as Linux. No new package dependencies are needed. No CONTEXT.md is present. diff --git a/docs/github-pages.md b/docs/github-pages.md new file mode 100644 index 00000000..1f39a0e6 --- /dev/null +++ b/docs/github-pages.md @@ -0,0 +1,49 @@ +# Maintaining the GitHub Pages site + +The published documentation lives in `site/`: + +- `index.html`: installation, CLI and configuration reference. +- `usage.html`: runnable application examples and troubleshooting. +- `styles.css`: shared responsive styles, including dark mode. + +The site uses static HTML and CSS, with no build dependencies. Use relative links +so navigation also works under the repository's GitHub Pages URL prefix. + +## Preview and validation + +Run from the repository root: + +```bash +python3 -m http.server 8000 --directory site --bind 127.0.0.1 +``` + +Open <http://localhost:8000/> or <http://localhost:8000/usage.html>. + +In another terminal, validate links, duplicate IDs and Python snippet syntax: + +```bash +mkdir -p artifacts/logs +python3 scripts/check_site.py > artifacts/logs/site-check.log 2>&1 +``` + +The checker uses only Python's standard library (Python 3.10+). It does not +check external URLs or execute the snippets. When changing runnable examples, +also run them in a temporary project with NavConfig installed. + +## Publishing + +1. In repository **Settings → Pages → Build and deployment**, select **GitHub Actions** + as the source. +2. Merge the site changes into `main`. The **Deploy GitHub Pages** workflow validates + the site, uploads only `site/` and deploys it to the `github-pages` environment. +3. Open the URL reported by the deployment job. For this repository, the README + uses <https://phenobarbital.github.io/navconfig/>; use the URL GitHub reports if + repository capitalization or Pages configuration differs. + +Pull requests validate the site without deployment permissions. You can also run +the workflow manually from **Actions → Deploy GitHub Pages → Run workflow**; +select `main` to publish. Other branches only run validation. If the repository +uses environment protection rules, `github-pages` must allow deployments from `main`. + +See GitHub's [custom Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) +for repository setup and deployment requirements. diff --git a/docs/loaders.rst b/docs/loaders.rst index 7d61448a..1f662fa0 100644 --- a/docs/loaders.rst +++ b/docs/loaders.rst @@ -7,12 +7,6 @@ Loaders .. autofunction:: navconfig.loaders.base.BaseLoader -``drive`` -------------- - -.. autofunction:: navconfig.loaders.drive.driveLoader - - ``toml`` ------------- diff --git a/docs/quickstart-drive.md b/docs/quickstart-drive.md deleted file mode 100644 index ff7cf7f9..00000000 --- a/docs/quickstart-drive.md +++ /dev/null @@ -1,52 +0,0 @@ -= Start Using NavConfig with Google Drive = - -== Preamble == - -Users can use Google Drive to storing Environment variables, as a text environment file. - -== QuickStart == - -For start using Google Drive for saving secrets, you need to configure Google OAuth2 authentication, Drive API requires OAuth2.0 for authentication, this module uses pyDrive for connection. - - -== Pre-requisites == - -1. Go to *[Google APIs Console](https://console.developers.google.com/iam-admin/projects)* and make your own project. -2. Go to "API and Services", click on "Library" -3. Search for ‘Google Drive API’, select the entry, and click ‘Enable’. -4. Select ‘Credentials’ from the left menu, click ‘Create Credentials’, select ‘OAuth client ID’. -5. Now, the product name and consent screen need to be set -> click ‘Configure consent screen’ and follow the instructions. -6. Finish configure the Application: - a. Configure the Application, select "Application Type" to be Web Application - b. Enter an appropiate Name for the application - c. put in ‘Authorized JavaScript origins’: http://localhost:8090 - d. put in ‘Authorized redirect URIs’. the same URL: http://localhost:8090/ - e. Click on "SAVE" - -7. Once you finished of configuring the Application, Click on ‘Download JSON’ on the right side of Client ID to download the secret.json file. -8. rename file to "client_secrets.json" and put in the root of the working directory. - -When you're ready to use Google Drive, let's move out to use it. - -== Using Config via Google Drive == - -1. Upload a File to Google Drive -2. Click on 'Get Link', in the link (something like this https://drive.google.com/file/d/{ID}/view?usp=sharing) the number after **/d/** is the **File ID** -3. Put the ID of the file in a environment variable, called: - -`os.environ['NAVCONFIG_DRIVE_ID']` - -before to invoking NavConfig. - -4. Put the other two expected environment variables before invoking NavConfig: - -`os.environ['NAVCONFIG_ENV'] = 'drive' -os.environ['NAVCONFIG_DRIVE_CLIENT'] = 'credentials.txt'` - -Note: the last file (credentials.txt) creates a File for saving the credentials and reuse for next authentications. - -5. And that's it!, when you try to load NavConfig: - -`from navconfig import config, BASE_DIR, DEBUG` - -NavConfig will try to use Google Drive as Source of Environment Variables. diff --git a/docs/requirements-dev.txt b/docs/requirements-dev.txt index e5d2f02a..dafa4e71 100644 --- a/docs/requirements-dev.txt +++ b/docs/requirements-dev.txt @@ -4,10 +4,10 @@ build==1.0.3 coverage[toml]==7.4.0 flit==3.9.0 hypothesis==6.91.0 -ipython==8.19.0 +ipython==8.31.0 lint==1.2.1 mypy==1.7.1 -pylint==2.16.1 +pylint==3.3.1 pyperf==2.6.2 pytest>=6.0.0 pytest-asyncio==0.24.0 diff --git a/examples/test.py b/examples/test.py new file mode 100644 index 00000000..664dc1aa --- /dev/null +++ b/examples/test.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +"""Test script to verify NavConfig installation.""" + +def test_basic_import(): + try: + import navconfig + print(f"✅ NavConfig imported successfully (version: {navconfig.__version__})") + return True + except ImportError as e: + print(f"❌ Failed to import NavConfig: {e}") + return False + +def test_config_object(): + try: + from navconfig import config + print(f"✅ Config object created successfully") + print(f" Site root: {config.site_root}") + print(f" Debug mode: {config.debug}") + return True + except Exception as e: + print(f"❌ Failed to create config object: {e}") + return False + +def test_cython_extensions(): + """Test that Cython extensions are compiled and working.""" + tests = [ + ("navconfig.utils.functions", "strtobool"), + ("navconfig.utils.types", "Singleton"), + ("navconfig.exceptions", "KardexError"), + ("navconfig.utils.json", "json_encoder"), + ] + + for module_name, attr_name in tests: + try: + module = __import__(module_name, fromlist=[attr_name]) + getattr(module, attr_name) + print(f"✅ Cython extension {module_name} working") + except ImportError as e: + print(f"❌ Failed to import {module_name}: {e}") + return False + except AttributeError as e: + print(f"❌ Missing attribute {attr_name} in {module_name}: {e}") + return False + + return True + +if __name__ == "__main__": + print("🧪 Testing NavConfig installation...") + + success = all([ + test_basic_import(), + test_config_object(), + test_cython_extensions(), + ]) + + if success: + print("\n🎉 All tests passed! NavConfig is working correctly.") + exit(0) + else: + print("\n💥 Some tests failed. Check the errors above.") + exit(1) diff --git a/examples/test_multi_env.py b/examples/test_multi_env.py new file mode 100644 index 00000000..90631b68 --- /dev/null +++ b/examples/test_multi_env.py @@ -0,0 +1,6 @@ +# Use prod/ env/.env for production settings +from navconfig import config, BASE_DIR + +print("Current Environment:", config.ENV) +print('PRODUCTION:', config.PRODUCTION) +print("OKTA_CLIENT_ID:", config.get('OKTA_CLIENT_ID')) diff --git a/examples/vault_migration.py b/examples/vault_migration.py new file mode 100644 index 00000000..eb5302cc --- /dev/null +++ b/examples/vault_migration.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +""" +Migrate .env file to HashiCorp Vault + +This script migrates environment variables from a .env file to HashiCorp Vault +using the KV secrets engine v2 format. + +Usage: + python vault_migration.py --vault_url https://127.0.0.1:8200 --vault_token hvs.xxx --vault_mountpoint navapi --env dev [.env file] +""" + +import os +import sys +import json +import requests +import urllib3 +import argparse +from pathlib import Path +from typing import Dict, Optional +import re + +# Disable SSL warnings for development (remove in production) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +def parse_env_file(file_path: Path) -> Dict[str, str]: + """ + Parse .env file manually to handle various formats. + """ + env_vars = {} + + if not file_path.exists(): + raise FileNotFoundError(f"Environment file not found: {file_path}") + + with open(file_path, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Match KEY=VALUE pattern + match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$', line) + if not match: + print(f"Warning: Skipping invalid line {line_num}: {line}") + continue + + key, value = match.groups() + + # Handle quoted values + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] # Remove quotes + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] # Remove quotes + + # Handle escaped characters in double quotes + if '"' in value: + value = value.replace('\\"', '"') + + env_vars[key] = value + + return env_vars + + +def parse_arguments(): + """ + Parse command line arguments. + """ + parser = argparse.ArgumentParser( + description="Migrate .env file to HashiCorp Vault", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic migration + python vault_migration.py --vault_url https://127.0.0.1:8200 --vault_token hvs.xxx --vault_mountpoint navapi --env dev + + # Specify custom .env file + python vault_migration.py --vault_url https://127.0.0.1:8200 --vault_token hvs.xxx --vault_mountpoint navapi --env prod .env.production + + # Migration with insecure SSL (for development) + python vault_migration.py --vault_url https://127.0.0.1:8200 --vault_token hvs.xxx --vault_mountpoint navapi --env dev --insecure + """ + ) + + parser.add_argument( + "--vault_url", + required=True, + help="Vault server URL (e.g., https://127.0.0.1:8200)" + ) + + parser.add_argument( + "--vault_token", + required=True, + help="Vault root token or token with write permissions" + ) + + parser.add_argument( + "--vault_mountpoint", + required=True, + help="Vault KV mount point name (e.g., navapi)" + ) + + parser.add_argument( + "--env", + required=True, + help="Environment name (e.g., dev, prod, staging)" + ) + + parser.add_argument( + "env_file", + nargs="?", + default=".env", + help="Path to .env file (default: .env)" + ) + + parser.add_argument( + "--insecure", + action="store_true", + help="Skip SSL certificate verification (for development only)" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be migrated without actually writing to Vault" + ) + + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose output" + ) + + parser.add_argument( + "--force", + action="store_true", + help="Skip confirmation prompt" + ) + + return parser.parse_args() + + +def test_vault_connection(vault_url: str, token: str, verify_ssl: bool = True, verbose: bool = False) -> bool: + """ + Test connection to Vault server. + """ + try: + headers = {"X-Vault-Token": token} + response = requests.get( + f"{vault_url}/v1/sys/health", + headers=headers, + timeout=10, + verify=verify_ssl + ) + + if response.status_code == 200: + health = response.json() + server_version = health.get('version', 'unknown') + if verbose: + print(f"Vault connection successful. Server version: {server_version}") + print(f"Vault status: {'sealed' if health.get('sealed') else 'unsealed'}") + print(f"Cluster name: {health.get('cluster_name', 'unknown')}") + else: + print(f"Vault connection successful (version: {server_version})") + return True + else: + print(f"Vault health check failed: {response.status_code}") + if verbose: + print(f"Response: {response.text}") + return False + + except requests.exceptions.RequestException as e: + print(f"Failed to connect to Vault: {e}") + return False + + +def check_mount_point(vault_url: str, token: str, mount_point: str, verify_ssl: bool = True, verbose: bool = False) -> bool: + """ + Check if mount point exists and is KV v2. + """ + try: + headers = {"X-Vault-Token": token} + response = requests.get( + f"{vault_url}/v1/sys/mounts", + headers=headers, + timeout=10, + verify=verify_ssl + ) + + if response.status_code != 200: + print(f"Failed to list mounts: {response.status_code}") + if verbose: + print(f"Response: {response.text}") + return False + + mounts = response.json() + mount_path = f"{mount_point}/" + + if mount_path in mounts.get('data', {}): + mount_info = mounts['data'][mount_path] + mount_type = mount_info.get('type') + version = mount_info.get('options', {}).get('version', '1') + + if mount_type == 'kv' and version == '2': + print(f"Mount point '{mount_point}' exists and is KV v2") + return True + else: + print(f"Mount point '{mount_point}' exists but is not KV v2 (type: {mount_type}, version: {version})") + return False + else: + print(f"Mount point '{mount_point}' does not exist") + print(f"Creating KV v2 mount point '{mount_point}'...") + return create_mount_point(vault_url, token, mount_point, verify_ssl, verbose) + + except requests.exceptions.RequestException as e: + print(f"Failed to check mount point: {e}") + return False + + +def create_mount_point(vault_url: str, token: str, mount_point: str, verify_ssl: bool = True, verbose: bool = False) -> bool: + """ + Create KV v2 mount point. + """ + try: + headers = { + "X-Vault-Token": token, + "Content-Type": "application/json" + } + + data = { + "type": "kv", + "options": { + "version": "2" + }, + "description": f"KV v2 secrets engine for {mount_point}" + } + + response = requests.post( + f"{vault_url}/v1/sys/mounts/{mount_point}", + headers=headers, + json=data, + timeout=10, + verify=verify_ssl + ) + + if response.status_code == 204: + print(f"Successfully created KV v2 mount point: {mount_point}") + return True + else: + print(f"Failed to create mount point: {response.status_code}") + if verbose: + print(f"Response: {response.text}") + return False + + except requests.exceptions.RequestException as e: + print(f"Failed to create mount point: {e}") + return False + + +def write_secrets_to_vault(vault_url: str, token: str, mount_point: str, + env_name: str, secrets: Dict[str, str], + verify_ssl: bool = True, verbose: bool = False) -> bool: + """ + Write secrets to Vault using KV v2 format. + """ + if not secrets: + print("No secrets to write") + return True + + headers = { + "X-Vault-Token": token, + "Content-Type": "application/json" + } + + # KV v2 requires data to be wrapped in a "data" object + payload = { + "data": secrets + } + + try: + # KV v2 path format: /v1/{mount}/data/{path} + url = f"{vault_url}/v1/{mount_point}/data/{env_name}" + + if verbose: + print(f"Writing to URL: {url}") + + response = requests.post( + url, + headers=headers, + json=payload, + timeout=30, + verify=verify_ssl + ) + + if response.status_code in (200, 204): + print(f"Successfully wrote {len(secrets)} secrets to {mount_point}/{env_name}") + return True + else: + print(f"Failed to write secrets: {response.status_code}") + if verbose: + print(f"Response: {response.text}") + return False + + except requests.exceptions.RequestException as e: + print(f"Failed to write secrets to Vault: {e}") + return False + + +def verify_secrets(vault_url: str, token: str, mount_point: str, + env_name: str, original_secrets: Dict[str, str], + verify_ssl: bool = True, verbose: bool = False) -> bool: + """ + Verify that secrets were written correctly to Vault. + """ + headers = {"X-Vault-Token": token} + + try: + # KV v2 read path format: /v1/{mount}/data/{path} + url = f"{vault_url}/v1/{mount_point}/data/{env_name}" + + if verbose: + print(f"Verifying from URL: {url}") + + response = requests.get( + url, + headers=headers, + timeout=10, + verify=verify_ssl + ) + + if response.status_code != 200: + print(f"Failed to read back secrets: {response.status_code}") + if verbose: + print(f"Response: {response.text}") + return False + + data = response.json() + vault_secrets = data.get('data', {}).get('data', {}) + + # Compare secrets + missing_keys = set(original_secrets.keys()) - set(vault_secrets.keys()) + extra_keys = set(vault_secrets.keys()) - set(original_secrets.keys()) + + mismatched_values = [] + for key in original_secrets: + if key in vault_secrets and original_secrets[key] != vault_secrets[key]: + mismatched_values.append(key) + + if missing_keys: + print(f"Missing keys in Vault: {missing_keys}") + return False + + if extra_keys: + print(f"Extra keys in Vault: {extra_keys}") + + if mismatched_values: + print(f"Mismatched values for keys: {mismatched_values}") + return False + + print(f"Verification successful: All {len(original_secrets)} secrets match") + return True + + except requests.exceptions.RequestException as e: + print(f"Failed to verify secrets: {e}") + return False + + +def main(): + """ + Main migration function using argparse. + """ + args = parse_arguments() + + print("=== .env to HashiCorp Vault Migration Tool ===\n") + + # Clean up vault URL (remove trailing slash) + vault_url = args.vault_url.rstrip('/') + env_path = Path(args.env_file) + verify_ssl = not args.insecure + + if args.verbose: + print(f"Configuration:") + print(f" Vault URL: {vault_url}") + print(f" Mount point: {args.vault_mountpoint}") + print(f" Environment: {args.env}") + print(f" .env file: {env_path}") + print(f" SSL verification: {verify_ssl}") + print(f" Dry run: {args.dry_run}") + print() + + try: + # Parse .env file + if args.verbose: + print(f"Parsing {env_path}...") + else: + print(f"Parsing {env_path}...") + + env_vars = parse_env_file(env_path) + print(f"Found {len(env_vars)} environment variables") + + if not env_vars: + print("No environment variables found in the file") + return 1 + + # Show preview of variables (without values for security) + if args.verbose: + print("\nEnvironment variables found:") + for key in sorted(env_vars.keys()): + value_preview = env_vars[key][:20] + "..." if len(env_vars[key]) > 20 else env_vars[key] + print(f" {key} = {value_preview}") + else: + print(f"Variables: {', '.join(sorted(env_vars.keys()))}") + + if args.dry_run: + print(f"\nDRY RUN - Would migrate to:") + print(f" Vault: {vault_url}") + print(f" Path: {args.vault_mountpoint}/{args.env}") + print(f" Variables: {len(env_vars)}") + return 0 + + # Test Vault connection + print(f"\nTesting connection to {vault_url}...") + if not test_vault_connection(vault_url, args.vault_token, verify_ssl, args.verbose): + return 1 + + # Check/create mount point + print(f"\nChecking mount point '{args.vault_mountpoint}'...") + if not check_mount_point(vault_url, args.vault_token, args.vault_mountpoint, verify_ssl, args.verbose): + return 1 + + # Final confirmation (unless --force is used) + if not args.force: + print(f"\nReady to migrate:") + print(f" Source: {env_path}") + print(f" Destination: {args.vault_mountpoint}/{args.env}") + print(f" Variables: {len(env_vars)}") + print(f" Vault URL: {vault_url}") + + confirm = input("\nProceed with migration? (y/N): ").strip().lower() + if confirm not in ('y', 'yes'): + print("Migration cancelled") + return 0 + + # Write secrets to Vault + print(f"\nWriting secrets to Vault...") + if not write_secrets_to_vault( + vault_url, args.vault_token, args.vault_mountpoint, + args.env, env_vars, verify_ssl, args.verbose + ): + return 1 + + # Verify secrets + print("\nVerifying written secrets...") + if verify_secrets( + vault_url, args.vault_token, args.vault_mountpoint, + args.env, env_vars, verify_ssl, args.verbose + ): + print("\nMigration completed successfully!") + print(f"\nTo use with NavConfig, set these environment variables:") + print(f" export VAULT_ENABLED=true") + print(f" export VAULT_URL={vault_url}") + print(f" export VAULT_TOKEN=your-token") # Don't expose the actual token + print(f" export VAULT_MOUNT_POINT={args.vault_mountpoint}") + print(f" export VAULT_VERSION=2") + print(f" export ENV={args.env}") + + if args.verbose: + print(f"\nOr create a .env.vault file:") + print(f"VAULT_ENABLED=true") + print(f"VAULT_URL={vault_url}") + print(f"VAULT_TOKEN=your-token") + print(f"VAULT_MOUNT_POINT={args.vault_mountpoint}") + print(f"VAULT_VERSION=2") + print(f"ENV={args.env}") + + return 0 + else: + print("\nMigration completed but verification failed") + return 1 + + except FileNotFoundError as e: + print(f"Error: {e}") + return 1 + except KeyboardInterrupt: + print("\n\nMigration cancelled by user") + return 130 + except Exception as e: + print(f"Unexpected error: {e}") + if args.verbose: + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/navconfig/__init__.py b/navconfig/__init__.py index cc660abb..5819437d 100644 --- a/navconfig/__init__.py +++ b/navconfig/__init__.py @@ -5,37 +5,112 @@ """ import sys import logging +from typing import Any from .project import ( project_root, get_env_type, get_environment ) +from .utils import install_uvloop +from .utils.settings import ensure_settings_priority from .kardex import Kardex # noqa from .version import __version__ # Reduce asyncio log level: logging.getLogger('asyncio').setLevel(logging.INFO) -# PROJECT PATH IS DEFINED? -SITE_ROOT, BASE_DIR = project_root(__file__) -# configuration of the environment type: -ENV_TYPE = get_env_type() +#: Names that are resolved on first access instead of at import time. +_LAZY_NAMES: tuple = ( + "SITE_ROOT", + "BASE_DIR", + "SETTINGS_DIR", + "ENV_TYPE", + "ENV", + "config", + "DEBUG", + "PRODUCTION", + "ENVIRONMENT", +) -# ENV version (dev, prod, staging) -ENV = get_environment() +__all__ = ("Kardex", "bootstrap", "__version__", *_LAZY_NAMES) + +_bootstrapped: bool = False + + +def bootstrap() -> "Kardex": + """Resolve the project layout and build the global configuration. + + This is deliberately *not* executed at import time. Importing + ``navconfig`` -- or any of its submodules, ``navconfig.cli`` included -- + must never require a project to be scaffolded already: otherwise + ``kardex env create``, the very command that creates ``env/``, could + not run on a fresh checkout. + + Everything exported by this package (``config``, ``BASE_DIR``, + ``DEBUG``, ...) triggers this function on first access, so regular + consumers never need to call it explicitly. Call it directly only when + you need the environment loaded into ``os.environ`` as a side effect + without touching any of those names. + + Returns: + Kardex: the global configuration container. + """ + global _bootstrapped # pylint: disable=W0603 + + if _bootstrapped: + return globals()["config"] + + install_uvloop() + + ns = globals() + + # PROJECT PATH IS DEFINED? + site_root, base_dir = project_root(__file__) + ns["SITE_ROOT"] = site_root + ns["BASE_DIR"] = base_dir + + ## Settings Directory + settings_dir = base_dir.joinpath("settings") + ns["SETTINGS_DIR"] = settings_dir + + # configuration of the environment type: + env_type = get_env_type() + ns["ENV_TYPE"] = env_type + + # ENV version (dev, prod, staging) + ns["ENV"] = get_environment() + + # Loading main Configuration Object. + cfg = Kardex(site_root, env=ns["ENV"], env_type=env_type) + ns["config"] = cfg + + # DEBUG VERSION + ns["DEBUG"] = cfg.debug + ns["PRODUCTION"] = cfg.getboolean('PRODUCTION', fallback=not cfg.debug) + # Environment + ns["ENVIRONMENT"] = cfg.get('ENVIRONMENT', fallback='development') + ns["ENV"] = cfg.get('ENV', fallback='dev') + + # Add Path Navigator to Sys path + sys.path.append(str(base_dir)) + + # Add Path settings to Sys path if exists. + ensure_settings_priority(settings_dir) + + _bootstrapped = True + return cfg -""" -Loading main Configuration Object. -""" -config = Kardex(SITE_ROOT, env=ENV, env_type=ENV_TYPE) -# DEBUG VERSION -DEBUG = config.debug +def __getattr__(name: str) -> Any: + """Resolve the lazily-initialized package attributes (PEP 562).""" + if name in _LAZY_NAMES: + bootstrap() + return globals()[name] + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) -## Settings Directory -SETTINGS_DIR = BASE_DIR.joinpath("settings") -# Add Path Navigator to Sys path -sys.path.append(str(BASE_DIR)) -sys.path.append(str(SETTINGS_DIR)) +def __dir__() -> list: + return sorted(set(globals()) | set(__all__)) diff --git a/navconfig/cli.py b/navconfig/cli.py new file mode 100644 index 00000000..af60572a --- /dev/null +++ b/navconfig/cli.py @@ -0,0 +1,64 @@ +"""``kardex`` -- the NavConfig command line interface. + +The CLI is organised in command groups (``env``, ``vault``, ``log``), each +of them exposing its own actions:: + + kardex env create [--split] + kardex env new <name> + kardex vault create + kardex vault migrate + kardex vault save VARIABLE:VALUE + kardex log enable [--logstash] + +Importing this module must never build the global configuration: the whole +point of ``kardex env create`` is to run on a project that does not have an +``env/`` directory yet. That is why :mod:`navconfig` resolves ``config`` +lazily and why nothing here imports it. +""" +from __future__ import annotations + +import argparse +import sys + +from .commands import COMMANDS, CommandError +from .version import __version__ + + +def build_parser() -> argparse.ArgumentParser: + """Build the ``kardex`` argument parser with every command group.""" + parser = argparse.ArgumentParser( + prog="kardex", + description="Utilities for bootstrapping and managing NavConfig projects.", + ) + parser.add_argument( + "-V", "--version", + action="version", + version=f"kardex (navconfig) {__version__}", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + for command in COMMANDS: + command.add_parser(subparsers) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the ``kardex`` CLI.""" + parser = build_parser() + args = parser.parse_args(argv) + + handler = getattr(args, "func", None) + if handler is None: # pragma: no cover - argparse enforces a subcommand + parser.print_help() + return 1 + + try: + return handler(args) + except CommandError as err: + sys.stderr.write(f"kardex: {err}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/navconfig/commands/__init__.py b/navconfig/commands/__init__.py new file mode 100644 index 00000000..3d8fe8da --- /dev/null +++ b/navconfig/commands/__init__.py @@ -0,0 +1,14 @@ +"""Sub-commands of the ``kardex`` command line interface. + +Every module here exposes an ``add_parser(subparsers)`` function that +registers its command group and wires each action to a handler through +``set_defaults(func=...)``. +""" +from . import env, log, vault +from .common import CommandError + +#: Command groups registered by :func:`navconfig.cli.build_parser`, in the +#: order they should appear in ``kardex --help``. +COMMANDS = (env, vault, log) + +__all__ = ("COMMANDS", "CommandError", "env", "log", "vault") diff --git a/navconfig/commands/common.py b/navconfig/commands/common.py new file mode 100644 index 00000000..b2d6f8a6 --- /dev/null +++ b/navconfig/commands/common.py @@ -0,0 +1,106 @@ +"""Shared helpers for the ``kardex`` sub-commands. + +Nothing in this module may touch :data:`navconfig.config`: the CLI has to +run *before* a project owns an ``env/`` directory, which is exactly the +situation where building the global configuration fails. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +from ..samples import get_sample_path + +#: Base file of an environment, always loaded first. +BASE_ENV_FILE = ".env" + +#: Delimiters of the Vault block ``kardex vault create`` manages inside a +#: ``.env`` file. Keeping them lets the command rewrite its own block +#: without touching anything the user added around it. +VAULT_BLOCK_START = "# --- navconfig:vault ---" +VAULT_BLOCK_END = "# --- navconfig:vault:end ---" + +#: Supplementary files created by ``kardex env create --split``, in the +#: order NavConfig loads them (``.env.local`` last, so it always wins). +SPLIT_ENV_FILES = ( + ".env.resources", + ".env.databases", + ".env.api", + ".env.cache", + ".env.local", +) + + +class CommandError(Exception): + """Raised when a sub-command cannot complete its work.""" + + +def msg(text: str = "") -> None: + """Print an informational message to stdout.""" + sys.stdout.write(f"{text}\n") + + +def warn(text: str) -> None: + """Print a warning to stderr.""" + sys.stderr.write(f"warning: {text}\n") + + +def read_sample(name: str) -> str: + """Read a bundled sample file and return its text.""" + return get_sample_path(name).read_text(encoding="utf-8") + + +def resolve_root(path: str) -> Path: + """Return the absolute project root for a ``--path`` argument.""" + return Path(path).expanduser().resolve() + + +def env_directory(project_root: Path, env: str) -> Path: + """Return the directory holding the files of a given environment.""" + return project_root / "env" / env + + +def write_file(path: Path, content: str, force: bool = False) -> bool: + """Write *content* into *path*, creating parent directories. + + Args: + path: Destination file. + content: Text to write. + force: Overwrite the file when it already exists. + + Returns: + ``True`` when the file was written, ``False`` when it already + existed and *force* was not set. + """ + if path.exists() and not force: + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return True + + +def report(created: dict, skipped: dict = None) -> None: + """Print the files/directories a sub-command produced.""" + for label, path in created.items(): + msg(f" created {label}: {path}") + for label, path in (skipped or {}).items(): + msg(f" skipped {label}: {path} (already exists)") + + +def set_env_variable(content: str, key: str, value: str) -> str: + """Return *content* with ``key`` set to ``value``. + + An existing (possibly commented-out) assignment is replaced in place so + the surrounding comments survive; otherwise the assignment is appended. + """ + lines = content.splitlines() + replaced = False + for index, line in enumerate(lines): + stripped = line.lstrip("#").strip() + if stripped.split("=", 1)[0].strip() == key: + lines[index] = f"{key}={value}" + replaced = True + break + if not replaced: + lines.append(f"{key}={value}") + return "\n".join(lines) + "\n" diff --git a/navconfig/commands/env.py b/navconfig/commands/env.py new file mode 100644 index 00000000..54c77063 --- /dev/null +++ b/navconfig/commands/env.py @@ -0,0 +1,270 @@ +"""``kardex env`` -- scaffold and manage NavConfig environments.""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from .common import ( + BASE_ENV_FILE, + SPLIT_ENV_FILES, + CommandError, + env_directory, + msg, + read_sample, + report, + resolve_root, + set_env_variable, + write_file, +) + + +def _base_env_content(sample: str) -> str: + """Return the shared ``env/.env`` body, without the ``ENV=`` pin. + + The shared file is used by every environment, so pinning ``ENV`` in it + would defeat the purpose of the per-environment directories. The + assignment is commented out rather than dropped, both to document the + directive and to give ``kardex env new`` a place to write it back. + """ + body = "\n".join( + "# ENV=" if line.strip().startswith("ENV=") else line + for line in sample.splitlines() + ) + return body + "\n" + + +def create_environment( + env: str, + project_root: Path, + split: bool = False, + force: bool = False, +) -> tuple[dict, dict]: + """Create the default NavConfig project structure. + + Two complementary environment layouts are scaffolded so the project + works with either resolution strategy: + + * ``env/.env`` -- shared base file, **without** an ``ENV=`` pin. + * ``env/<env>/.env`` -- environment-specific file pinned to ``ENV=<env>``. + + Args: + env: Name of the environment to create (``dev``, ``prod``, ...). + project_root: Directory the structure is created into. + split: Also create the supplementary ``.env.*`` files NavConfig + loads alongside the base ``.env``. + force: Overwrite files that already exist. + + Returns: + A ``(created, skipped)`` pair of ``{label: path}`` mappings. + """ + created: dict = {} + skipped: dict = {} + sample = read_sample(".env.sample") + + env_root = project_root / "env" + env_root.mkdir(parents=True, exist_ok=True) + + # Shared env/.env -- common to every environment, no ENV= assignment. + base_env_file = env_root / BASE_ENV_FILE + target = created if write_file( + base_env_file, _base_env_content(sample), force + ) else skipped + target["env/.env"] = base_env_file + + # Environment-specific env/<env>/.env -- pinned to ENV=<env>. + env_path = env_directory(project_root, env) + env_path.mkdir(parents=True, exist_ok=True) + + env_file = env_path / BASE_ENV_FILE + target = created if write_file( + env_file, set_env_variable(sample, "ENV", env), force + ) else skipped + target[f"env/{env}/.env"] = env_file + + if split: + for filename in SPLIT_ENV_FILES: + path = env_path / filename + content = read_sample(f"{filename}.sample") + target = created if write_file(path, content, force) else skipped + target[f"env/{env}/{filename}"] = path + + # etc/config.ini + config_file = project_root / "etc" / "config.ini" + target = created if write_file( + config_file, read_sample("config.ini.sample"), force + ) else skipped + target["etc/config.ini"] = config_file + + # logs/ directory (referenced by the default logging configuration) + logs_directory = project_root / "logs" + logs_directory.mkdir(parents=True, exist_ok=True) + created["logs/"] = logs_directory + + # templates/ directory + templates_directory = project_root / "templates" + templates_directory.mkdir(parents=True, exist_ok=True) + created["templates/"] = templates_directory + + return created, skipped + + +def new_environment( + name: str, + project_root: Path, + split: bool = False, + force: bool = False, +) -> tuple[dict, dict]: + """Create an additional environment from the shared ``env/.env``. + + When no shared ``env/.env`` exists the bundled sample is used instead. + + Args: + name: Name of the new environment (``prod``, ``staging``, ``qa``). + project_root: Project root directory. + split: Also create the supplementary ``.env.*`` files. + force: Overwrite the environment file when it already exists. + + Returns: + A ``(created, skipped)`` pair of ``{label: path}`` mappings. + """ + created: dict = {} + skipped: dict = {} + + base_env_file = project_root / "env" / BASE_ENV_FILE + if base_env_file.exists(): + content = base_env_file.read_text(encoding="utf-8") + else: + content = read_sample(".env.sample") + + # Re-point the environment token, whatever the base file was pinned to. + content = set_env_variable(content, "ENV", name) + + env_path = env_directory(project_root, name) + env_file = env_path / BASE_ENV_FILE + if env_file.exists() and not force: + raise CommandError( + f"Environment '{name}' already exists: {env_file}\n" + "Re-run with --force to overwrite it." + ) + + write_file(env_file, content, force=True) + created[f"env/{name}/.env"] = env_file + + if split: + for filename in SPLIT_ENV_FILES: + path = env_path / filename + body = read_sample(f"{filename}.sample") + target = created if write_file(path, body, force) else skipped + target[f"env/{name}/{filename}"] = path + + return created, skipped + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +def add_parser(subparsers: argparse._SubParsersAction) -> None: + """Register the ``env`` command group.""" + parser = subparsers.add_parser( + "env", + help="Create and manage NavConfig environments.", + description="Create and manage NavConfig environments.", + ) + actions = parser.add_subparsers(dest="action", required=True) + + create = actions.add_parser( + "create", + help="Create the default NavConfig project structure.", + description=( + "Create the env/, etc/, logs/ and templates/ directories along " + "with the base .env and etc/config.ini files." + ), + ) + create.add_argument( + "--env", + default="dev", + help="Name of the environment to create (default: dev).", + ) + create.add_argument( + "--path", + default=".", + help="Project root directory (default: current directory).", + ) + create.add_argument( + "--split", + action="store_true", + default=False, + help=( + "Also create the supplementary .env files NavConfig loads " + "alongside the base one: " + f"{', '.join(SPLIT_ENV_FILES)}." + ), + ) + create.add_argument( + "--force", + action="store_true", + default=False, + help="Overwrite files that already exist.", + ) + create.set_defaults(func=_run_create) + + new = actions.add_parser( + "new", + help="Create an additional environment from the shared env/.env.", + description="Create an additional environment from the shared env/.env.", + ) + new.add_argument( + "name", + help="Name of the new environment (e.g. prod, staging, qa).", + ) + new.add_argument( + "--path", + default=".", + help="Project root directory (default: current directory).", + ) + new.add_argument( + "--split", + action="store_true", + default=False, + help="Also create the supplementary .env files.", + ) + new.add_argument( + "--force", + action="store_true", + default=False, + help="Overwrite the environment file when it already exists.", + ) + new.set_defaults(func=_run_new) + + +def _run_create(args: argparse.Namespace) -> int: + project_root = resolve_root(args.path) + created, skipped = create_environment( + env=args.env, + project_root=project_root, + split=args.split, + force=args.force, + ) + msg( + f"NavConfig project structure ready at {project_root} " + f"(environment: {args.env})" + ) + report(created, skipped) + msg("") + msg(f"Next: edit env/{args.env}/.env, then run your application with " + f"ENV={args.env}.") + return 0 + + +def _run_new(args: argparse.Namespace) -> int: + project_root = resolve_root(args.path) + created, skipped = new_environment( + name=args.name, + project_root=project_root, + split=args.split, + force=args.force, + ) + msg(f"Created environment '{args.name}' at {project_root}") + report(created, skipped) + return 0 diff --git a/navconfig/commands/log.py b/navconfig/commands/log.py new file mode 100644 index 00000000..65768a81 --- /dev/null +++ b/navconfig/commands/log.py @@ -0,0 +1,302 @@ +"""``kardex log`` -- turn NavConfig's logging facility on.""" +from __future__ import annotations + +import argparse +from configparser import ConfigParser +from pathlib import Path + +from .common import msg, read_sample, resolve_root, warn + +#: Section of ``etc/config.ini`` read by ``navconfig.logging``. +LOGGING_SECTION = "logging" + + +def _load_ini(config_file: Path) -> tuple[str, ConfigParser, bool]: + """Return the INI text and parser, creating the file when missing.""" + created = False + if not config_file.exists(): + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(read_sample("config.ini.sample"), encoding="utf-8") + created = True + + content = config_file.read_text(encoding="utf-8") + parser = ConfigParser() + parser.read_string(content, source=str(config_file)) + return content, parser, created + + +def set_ini_option(content: str, section: str, key: str, value: str) -> str: + """Return *content* with ``key = value`` set inside ``[section]``. + + ``configparser`` drops every comment when it writes a file back, which + would throw away the documentation shipped in ``config.ini.sample`` on + the very first run. This rewrites the assignment line in place instead, + so the surrounding comments survive. + """ + lines = content.splitlines() + header = f"[{section}]" + assignment = f"{key} = {value}" + + start = None + for index, line in enumerate(lines): + if line.strip() == header: + start = index + 1 + break + + if start is None: + # The section is missing entirely: append it with this single option. + body = "\n".join(lines).rstrip() + return f"{body}\n\n{header}\n{assignment}\n" + + end = len(lines) + for index in range(start, len(lines)): + stripped = lines[index].strip() + if stripped.startswith("[") and stripped.endswith("]"): + end = index + break + + for index in range(start, end): + candidate = lines[index].lstrip("#").strip() + if "=" not in candidate: + continue + # configparser is case-insensitive on option names. + if candidate.split("=", 1)[0].strip().lower() == key.lower(): + lines[index] = assignment + return "\n".join(lines) + "\n" + + # Not present yet: append it after the last non-blank line of the section. + insert_at = end + while insert_at > start and not lines[insert_at - 1].strip(): + insert_at -= 1 + lines.insert(insert_at, assignment) + return "\n".join(lines) + "\n" + + +def _handler_list(parser: ConfigParser, wanted: list) -> str: + """Merge *wanted* handlers into the configured handler list.""" + current = parser.get(LOGGING_SECTION, "handlers", fallback="console") + handlers = [name.strip() for name in current.split(",") if name.strip()] + for name in wanted: + if name not in handlers: + handlers.append(name) + return ",".join(handlers) + + +def enable_logging( + project_root: Path, + loglevel: str = None, + logdir: str = None, + echo: bool = True, + filehandler: bool = True, + logstash: bool = False, + logstash_host: str = None, + logstash_port: int = None, + logstash_level: str = None, + mailer: bool = False, +) -> tuple[Path, dict]: + """Enable the ``[logging]`` section of ``etc/config.ini``. + + NavConfig reads its logging configuration from that section; this + command writes it so ``dictConfig(logging_config)`` produces a working + setup without hand-editing the INI file. + + Args: + project_root: Project root directory. + loglevel: Level applied to the console and file handlers. + logdir: Directory rotating log files are written to. + echo: Send log records to the console. + filehandler: Enable the rotating file handler. + logstash: Enable the asynchronous Logstash handler. + logstash_host: Host of the Logstash server. + logstash_port: TCP port of the Logstash server. + logstash_level: Minimum level forwarded to Logstash. + mailer: Send an email on CRITICAL records. + + Returns: + A ``(config_file, settings)`` pair, where *settings* holds the + values written to the ``[logging]`` section. + """ + config_file = project_root / "etc" / "config.ini" + content, parser, created = _load_ini(config_file) + + settings: dict = { + "logdir": logdir or parser.get(LOGGING_SECTION, "logdir", fallback="logs"), + "loglevel": ( + loglevel + or parser.get(LOGGING_SECTION, "loglevel", fallback="INFO") + ).upper(), + "logging_echo": str(echo).lower(), + "filehandler_enabled": str(filehandler).lower(), + "logstash_enabled": str(logstash).lower(), + "mailer_enabled": str(mailer).lower(), + "logging_disable_other": parser.get( + LOGGING_SECTION, "logging_disable_other", fallback="false" + ), + } + + handlers = ["console"] if echo else [] + if filehandler: + handlers.extend(["RotatingFileHandler", "ErrorFileHandler"]) + + if logstash: + settings["logging_host"] = logstash_host or parser.get( + LOGGING_SECTION, "logging_host", fallback="localhost" + ) + settings["logging_port"] = str( + logstash_port + if logstash_port is not None + else parser.get(LOGGING_SECTION, "logging_port", fallback="5044") + ) + settings["logstash_logging"] = ( + logstash_level + or parser.get(LOGGING_SECTION, "logstash_logging", fallback="INFO") + ).upper() + settings["logstash_flush_timeout"] = parser.get( + LOGGING_SECTION, "logstash_flush_timeout", fallback="10" + ) + handlers.append("LogstashHandler") + + if mailer: + handlers.append("CriticalMailHandler") + + settings["handlers"] = _handler_list(parser, handlers) + + for key, value in settings.items(): + content = set_ini_option(content, LOGGING_SECTION, key, value) + config_file.write_text(content, encoding="utf-8") + + # The rotating file handler does not create its own directory. + log_path = Path(settings["logdir"]) + if not log_path.is_absolute(): + log_path = project_root / log_path + log_path.mkdir(parents=True, exist_ok=True) + + settings["_created_ini"] = created + return config_file, settings + + +def _logstash_installed() -> bool: + try: + import logstash_async # noqa: F401 # pylint: disable=W0611 + except ModuleNotFoundError: + return False + return True + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +def add_parser(subparsers: argparse._SubParsersAction) -> None: + """Register the ``log`` command group.""" + parser = subparsers.add_parser( + "log", + help="Configure the NavConfig logging facility.", + description="Configure the NavConfig logging facility.", + ) + actions = parser.add_subparsers(dest="action", required=True) + + enable = actions.add_parser( + "enable", + help="Enable the [logging] section of etc/config.ini.", + description=( + "Write the [logging] section of etc/config.ini, creating the " + "file from the bundled sample when missing. Console and " + "rotating-file logging are enabled by default; add --logstash " + "to also forward records to a Logstash server." + ), + ) + enable.add_argument( + "--path", + default=".", + help="Project root directory (default: current directory).", + ) + enable.add_argument( + "--loglevel", + default=None, + choices=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), + help="Level applied to the console and file handlers.", + ) + enable.add_argument( + "--logdir", + default=None, + help="Directory rotating log files are written to (default: logs).", + ) + enable.add_argument( + "--quiet", + action="store_true", + default=False, + help="Do not echo log records to the console.", + ) + enable.add_argument( + "--no-file", + action="store_true", + default=False, + help="Do not enable the rotating file handler.", + ) + enable.add_argument( + "--logstash", + action="store_true", + default=False, + help="Enable the asynchronous Logstash handler.", + ) + enable.add_argument( + "--logstash-host", + default=None, + help="Host of the Logstash server (default: localhost).", + ) + enable.add_argument( + "--logstash-port", + default=None, + type=int, + help="TCP port of the Logstash server (default: 5044).", + ) + enable.add_argument( + "--logstash-level", + default=None, + choices=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), + help="Minimum level forwarded to Logstash (default: INFO).", + ) + enable.add_argument( + "--mailer", + action="store_true", + default=False, + help="Send an email on CRITICAL records.", + ) + enable.set_defaults(func=_run_enable) + + +def _run_enable(args: argparse.Namespace) -> int: + project_root = resolve_root(args.path) + config_file, settings = enable_logging( + project_root=project_root, + loglevel=args.loglevel, + logdir=args.logdir, + echo=not args.quiet, + filehandler=not args.no_file, + logstash=args.logstash, + logstash_host=args.logstash_host, + logstash_port=args.logstash_port, + logstash_level=args.logstash_level, + mailer=args.mailer, + ) + created_ini = settings.pop("_created_ini") + + if created_ini: + msg(f"Created {config_file} from the bundled sample.") + msg(f"Logging enabled in {config_file}") + for key, value in settings.items(): + msg(f" {key} = {value}") + + if args.logstash and not _logstash_installed(): + warn( + "Logstash is enabled but 'python-logstash-async' is not " + "installed. Run: pip install navconfig[logstash]" + ) + + msg("") + msg("Apply it in your application with:") + msg(" from logging.config import dictConfig") + msg(" from navconfig.logging import logging_config") + msg(" dictConfig(logging_config)") + return 0 diff --git a/navconfig/commands/vault.py b/navconfig/commands/vault.py new file mode 100644 index 00000000..c1cbb2df --- /dev/null +++ b/navconfig/commands/vault.py @@ -0,0 +1,683 @@ +"""``kardex vault`` -- bootstrap and populate a HashiCorp Vault backend.""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +from typing import Any + +from .common import ( + BASE_ENV_FILE, + VAULT_BLOCK_END, + VAULT_BLOCK_START, + CommandError, + env_directory, + msg, + read_sample, + report, + resolve_root, + set_env_variable, + warn, + write_file, +) + +#: Directives owned by the Vault integration itself. They configure *how* to +#: reach Vault, so pushing them into Vault would be circular: NavConfig has +#: to read them from the .env file before it can open a connection. +VAULT_OWN_VARIABLES = ( + "VAULT_ENABLED", + "VAULT_URL", + "VAULT_TOKEN", + "VAULT_MOUNT_POINT", + "VAULT_VERSION", + "VAULT_ENV", + "VAULT_NAMESPACE", +) + +#: Directives NavConfig needs *before* any external source is reachable: +#: they select the environment, the INI file and the project layout. Storing +#: them in Vault would be circular, so ``kardex vault migrate`` leaves them +#: in the .env file unless ``--include-bootstrap`` is given. +BOOTSTRAP_VARIABLES = ( + "ENV", + "ENV_TYPE", + "SITE_ROOT", + "BASE_DIR", + "CONFIG_FILE", + "LAZY_LOAD", + "AUTO_DISCOVERY", + "CONFIG_CREATE", + "PROJECT_NAME", + "PROJECT_PATH", + "PROJECT_FILE", + "NAVCONFIG_FILE_OVERRIDE_ENABLED", +) + + +def _mask(value: Any) -> str: + """Return a redacted representation of a secret value.""" + text = str(value) + if len(text) <= 4: + return "*" * len(text) + return f"{text[:2]}{'*' * (len(text) - 4)}{text[-2:]}" + + +def is_vault_variable(key: str) -> bool: + """Return ``True`` when *key* configures the Vault connection itself.""" + return key in VAULT_OWN_VARIABLES or key.startswith("VAULT_") + + +# --------------------------------------------------------------------------- +# vault create +# --------------------------------------------------------------------------- + +def _strip_vault_block(content: str) -> tuple[str, bool]: + """Remove the managed Vault block from *content*. + + Returns the remaining text and whether an *unmanaged* block (Vault + directives written by hand, without the delimiters) was found instead. + """ + lines = content.splitlines() + kept, inside, managed = [], False, False + for line in lines: + if line.startswith(VAULT_BLOCK_START): + inside = managed = True + continue + if inside: + if line.startswith(VAULT_BLOCK_END): + inside = False + continue + kept.append(line) + + if managed: + return "\n".join(kept).rstrip() + "\n", False + + # No delimiters: drop the bare VAULT_* assignments and report it, so the + # caller can warn that surrounding comments were left untouched. + kept = [ + line for line in lines + if not line.split("=", 1)[0].strip().startswith("VAULT_") + ] + return "\n".join(kept).rstrip() + "\n", True + + +def create_vault_env( + env: str, + project_root: Path, + url: str = None, + token: str = None, + mount_point: str = None, + version: int = None, + vault_env: str = None, + force: bool = False, +) -> tuple[dict, dict]: + """Write the Vault directives into ``env/<env>/.env``. + + When the environment file does not exist yet it is created from the + base sample, so ``kardex vault create`` also works as a one-step + bootstrap for a Vault-backed project. The block is delimited by + ``# --- navconfig:vault ---`` markers so it can be rewritten later + without disturbing the rest of the file. + + Args: + env: Environment whose ``.env`` file is configured. + project_root: Project root directory. + url: Address of the Vault server. + token: Token used to authenticate. + mount_point: KV mount point holding the application secrets. + version: KV engine version (1 or 2). + vault_env: Path segment to read secrets from, when it must differ + from *env*. + force: Rewrite the Vault block even if one is already present. + + Returns: + A ``(created, skipped)`` pair of ``{label: path}`` mappings. + """ + created: dict = {} + skipped: dict = {} + + env_path = env_directory(project_root, env) + env_file = env_path / BASE_ENV_FILE + + if env_file.exists(): + content = env_file.read_text(encoding="utf-8") + else: + content = set_env_variable(read_sample(".env.sample"), "ENV", env) + created[f"env/{env}/.env"] = env_file + + has_block = VAULT_BLOCK_START in content or "VAULT_ENABLED" in content + if has_block and not force: + skipped[f"env/{env}/.env (Vault block)"] = env_file + else: + if has_block: + content, unmanaged = _strip_vault_block(content) + if unmanaged: + warn( + "replaced hand-written VAULT_* directives; review " + f"{env_file} for leftover comments." + ) + content = content.rstrip() + "\n\n" + read_sample(".env.vault.sample") + if f"env/{env}/.env" not in created: + created[f"env/{env}/.env (Vault block)"] = env_file + + overrides = { + "VAULT_URL": url, + "VAULT_TOKEN": token, + "VAULT_MOUNT_POINT": mount_point, + "VAULT_VERSION": str(version) if version is not None else None, + "VAULT_ENV": vault_env, + } + for key, value in overrides.items(): + if value is not None: + content = set_env_variable(content, key, value) + + env_path.mkdir(parents=True, exist_ok=True) + write_file(env_file, content, force=True) + + return created, skipped + + +# --------------------------------------------------------------------------- +# Vault connection +# --------------------------------------------------------------------------- + +class VaultWriter: + """Minimal Vault KV writer used by the ``kardex vault`` sub-commands. + + ``navconfig.readers.vault.VaultReader`` performs a read-modify-write per + key, which is both slow and racy when migrating a whole ``.env`` file. + This writer merges every key in memory and issues a single write. + """ + + def __init__(self, settings: dict) -> None: + try: + import hvac # pylint: disable=import-outside-toplevel + except ModuleNotFoundError as ex: # pragma: no cover - dependency + raise CommandError( + "HashiCorp Vault support requires the 'hvac' package: " + "pip install navconfig[hvac]" + ) from ex + + self._hvac = hvac + self.url: str = settings["url"] + self.mount_point: str = settings["mount_point"] + self.version: int = settings["version"] + self.path: str = settings["path"] + + if not settings.get("token"): + raise CommandError( + "VAULT_TOKEN is not set. Provide it with --token, or add it " + "to the environment .env file (kardex vault create)." + ) + + try: + self.client = hvac.Client(url=self.url, token=settings["token"]) + authenticated = self.client.is_authenticated() + except Exception as ex: + raise CommandError(f"Unable to reach Vault at {self.url}: {ex}") from ex + + if not authenticated: + raise CommandError( + f"Vault rejected the token used for {self.url}. " + "Check VAULT_TOKEN and its policies." + ) + + def read(self) -> dict: + """Return the secrets currently stored at the configured path.""" + try: + if self.version == 1: + response = self.client.secrets.kv.v1.read_secret( + path=self.path, mount_point=self.mount_point + ) + return dict(response["data"]) + response = self.client.secrets.kv.v2.read_secret_version( + path=self.path, mount_point=self.mount_point + ) + return dict(response["data"]["data"]) + except self._hvac.exceptions.InvalidPath: + # The path simply does not hold a secret yet. + return {} + except Exception as ex: + raise CommandError( + f"Unable to read '{self.mount_point}/{self.path}': {ex}" + ) from ex + + def write(self, data: dict) -> None: + """Replace the secret at the configured path with *data*.""" + try: + if self.version == 1: + self.client.secrets.kv.v1.create_or_update_secret( + path=self.path, secret=data, mount_point=self.mount_point + ) + else: + self.client.secrets.kv.v2.create_or_update_secret( + path=self.path, secret=data, mount_point=self.mount_point + ) + except Exception as ex: + hint = "" + if "no handler for route" in str(ex): + hint = ( + f"\nThe KV engine does not seem to be mounted at " + f"'{self.mount_point}'. Enable it with: vault secrets " + f"enable -path={self.mount_point} -version={self.version} kv" + ) + raise CommandError( + f"Unable to write to '{self.mount_point}/{self.path}': {ex}{hint}" + ) from ex + + +def load_env_values(path: Path) -> dict: + """Return the variables declared in a ``.env`` file, without side effects.""" + from dotenv import dotenv_values # pylint: disable=import-outside-toplevel + + if not path.exists(): + return {} + return {k: v for k, v in dotenv_values(path).items() if v is not None} + + +def resolve_settings(args: argparse.Namespace, project_root: Path) -> dict: + """Resolve the Vault connection settings for a sub-command. + + Precedence is: explicit command-line flags, then the environment's + ``.env`` file, then the process environment. + """ + env_file = env_directory(project_root, args.env) / BASE_ENV_FILE + file_values = load_env_values(env_file) + + def pick(flag: Any, key: str, default: Any = None) -> Any: + if flag is not None: + return flag + return file_values.get(key, os.getenv(key, default)) + + version = pick(args.version, "VAULT_VERSION", "2") + try: + version = int(version) + except (TypeError, ValueError) as ex: + raise CommandError(f"VAULT_VERSION must be 1 or 2, got {version!r}") from ex + if version not in (1, 2): + raise CommandError(f"VAULT_VERSION must be 1 or 2, got {version!r}") + + path = ( + getattr(args, "vault_path", None) + or file_values.get("VAULT_ENV") + or os.getenv("VAULT_ENV") + or args.env + ) + + return { + "url": pick(args.url, "VAULT_URL", "http://localhost:8200"), + "token": pick(args.token, "VAULT_TOKEN"), + "mount_point": pick(args.mount_point, "VAULT_MOUNT_POINT", "navigator"), + "version": version, + "path": path, + "env_file": env_file, + } + + +def _confirm(question: str, assume_yes: bool) -> bool: + """Ask for confirmation before writing to a remote Vault server.""" + if assume_yes: + return True + try: + answer = input(f"{question} [y/N]: ") + except (EOFError, KeyboardInterrupt): + msg("") + return False + return answer.strip().lower() in ("y", "yes") + + +# --------------------------------------------------------------------------- +# vault migrate +# --------------------------------------------------------------------------- + +def collect_migratable( + project_root: Path, + env: str, + source: Path = None, + include_extra: bool = False, + include_bootstrap: bool = False, +) -> tuple[dict, dict]: + """Split an environment into the variables that belong in Vault or not. + + Args: + project_root: Project root directory. + env: Environment to read. + source: Read this file instead of ``env/<env>/.env``. + include_extra: Also read the supplementary ``env/<env>/.env.*`` files. + include_bootstrap: Migrate the NavConfig bootstrap directives too. + + Returns: + A ``(migratable, excluded)`` pair, where *excluded* maps each + left-behind variable to the reason it was left behind. + """ + env_path = env_directory(project_root, env) + if source is not None: + files = [source] + else: + files = [env_path / BASE_ENV_FILE] + if include_extra: + files.extend( + sorted( + path for path in env_path.glob(".env.*") + if path.is_file() + ) + ) + + values: dict = {} + for path in files: + if not path.exists(): + raise CommandError(f"Environment file not found: {path}") + values.update(load_env_values(path)) + + migratable: dict = {} + excluded: dict = {} + for key, value in values.items(): + if is_vault_variable(key): + excluded[key] = "configures the Vault connection" + elif not include_bootstrap and key in BOOTSTRAP_VARIABLES: + excluded[key] = "NavConfig bootstrap directive" + else: + migratable[key] = value + + return migratable, excluded + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +def _add_connection_flags(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--env", + default=os.getenv("ENV") or "dev", + help="Environment to operate on (default: $ENV, or dev).", + ) + parser.add_argument( + "--path", + default=".", + help="Project root directory (default: current directory).", + ) + parser.add_argument("--url", default=None, help="Vault server address.") + parser.add_argument("--token", default=None, help="Vault token.") + parser.add_argument( + "--mount-point", + default=None, + help="KV mount point holding the application secrets.", + ) + parser.add_argument( + "--version", + default=None, + type=int, + choices=(1, 2), + help="KV engine version (default: 2).", + ) + parser.add_argument( + "--vault-path", + default=None, + help=( + "Secret path under the mount point. Defaults to VAULT_ENV when " + "set, otherwise to the environment name." + ), + ) + + +def add_parser(subparsers: argparse._SubParsersAction) -> None: + """Register the ``vault`` command group.""" + parser = subparsers.add_parser( + "vault", + help="Configure and populate a HashiCorp Vault backend.", + description="Configure and populate a HashiCorp Vault backend.", + ) + actions = parser.add_subparsers(dest="action", required=True) + + # -- vault create ------------------------------------------------------ + create = actions.add_parser( + "create", + help="Write the HashiCorp Vault directives into env/<env>/.env.", + description=( + "Write the HashiCorp Vault directives into env/<env>/.env, " + "creating the file from the bundled sample when missing." + ), + ) + _add_connection_flags(create) + create.add_argument( + "--force", + action="store_true", + default=False, + help="Rewrite the Vault block even if one is already present.", + ) + create.set_defaults(func=_run_create) + + # -- vault migrate ----------------------------------------------------- + migrate = actions.add_parser( + "migrate", + help="Push the variables of a .env file into HashiCorp Vault.", + description=( + "Push the variables of an environment into HashiCorp Vault. The " + "VAULT_* directives are never migrated: NavConfig reads them " + "from the .env file to open the connection." + ), + ) + _add_connection_flags(migrate) + migrate.add_argument( + "--file", + default=None, + help="Migrate this file instead of env/<env>/.env.", + ) + migrate.add_argument( + "--include-extra", + action="store_true", + default=False, + help="Also migrate the supplementary env/<env>/.env.* files.", + ) + migrate.add_argument( + "--include-bootstrap", + action="store_true", + default=False, + help=( + "Also migrate the NavConfig bootstrap directives " + f"({', '.join(BOOTSTRAP_VARIABLES[:3])}, ...), which are " + "normally kept in the .env file." + ), + ) + migrate.add_argument( + "--keep-existing", + action="store_true", + default=False, + help="Do not overwrite keys already stored in Vault.", + ) + migrate.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Show what would be written without contacting Vault.", + ) + migrate.add_argument( + "-y", "--yes", + action="store_true", + default=False, + help="Do not ask for confirmation before writing.", + ) + migrate.set_defaults(func=_run_migrate) + + # -- vault save -------------------------------------------------------- + save = actions.add_parser( + "save", + help="Store one or more variables in HashiCorp Vault.", + description=( + "Store one or more VARIABLE:VALUE pairs in HashiCorp Vault. " + "Only the first colon separates the name from the value, so " + "values may contain colons themselves." + ), + ) + save.add_argument( + "pairs", + nargs="+", + metavar="VARIABLE:VALUE", + help="Variable and value to store, separated by a colon.", + ) + _add_connection_flags(save) + save.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Show what would be written without contacting Vault.", + ) + save.set_defaults(func=_run_save) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _run_create(args: argparse.Namespace) -> int: + project_root = resolve_root(args.path) + created, skipped = create_vault_env( + env=args.env, + project_root=project_root, + url=args.url, + token=args.token, + mount_point=args.mount_point, + version=args.version, + vault_env=args.vault_path, + force=args.force, + ) + updated = [ + name for name, value in ( + ("VAULT_URL", args.url), + ("VAULT_TOKEN", args.token), + ("VAULT_MOUNT_POINT", args.mount_point), + ("VAULT_VERSION", args.version), + ("VAULT_ENV", args.vault_path), + ) if value is not None + ] + + msg(f"HashiCorp Vault configuration ready for environment '{args.env}'") + report(created, skipped) + if updated: + msg(f" updated {', '.join(updated)}") + if skipped: + msg("") + msg("Re-run with --force to rewrite the whole Vault block.") + if not args.token: + msg("") + msg("Remember to set VAULT_TOKEN before starting the application.") + return 0 + + +def _report_excluded(excluded: dict) -> None: + """List the variables deliberately left in the .env file.""" + if not excluded: + return + msg("") + msg(f"Left in the .env file ({len(excluded)}):") + for key, reason in excluded.items(): + msg(f" {key} -- {reason}") + + +def _run_migrate(args: argparse.Namespace) -> int: + project_root = resolve_root(args.path) + source = Path(args.file).expanduser().resolve() if args.file else None + + values, excluded = collect_migratable( + project_root=project_root, + env=args.env, + source=source, + include_extra=args.include_extra, + include_bootstrap=args.include_bootstrap, + ) + if not values: + msg("Nothing to migrate: every variable stays in the .env file.") + _report_excluded(excluded) + return 0 + + settings = resolve_settings(args, project_root) + destination = f"{settings['mount_point']}/{settings['path']}" + + msg(f"Migrating {len(values)} variable(s) to {settings['url']} -> {destination}") + for key, value in values.items(): + msg(f" {key} = {_mask(value)}") + _report_excluded(excluded) + + if args.dry_run: + msg("") + msg("Dry run: nothing was written to Vault.") + return 0 + + if not _confirm(f"Write these variables to {destination}?", args.yes): + msg("Aborted: nothing was written to Vault.") + return 1 + + writer = VaultWriter(settings) + existing = writer.read() + + payload = dict(existing) + written, kept = [], [] + for key, value in values.items(): + if args.keep_existing and key in existing: + kept.append(key) + continue + payload[key] = value + written.append(key) + + if not written: + msg("Every variable is already present in Vault; nothing written.") + return 0 + + writer.write(payload) + + msg("") + msg(f"Wrote {len(written)} variable(s) to {destination}.") + if kept: + msg(f"Kept {len(kept)} existing value(s): {', '.join(kept)}") + msg( + "Remove the migrated values from the .env file once you have " + "verified the application reads them from Vault." + ) + return 0 + + +def _parse_pair(pair: str) -> tuple[str, str]: + """Split a ``VARIABLE:VALUE`` argument on its first colon.""" + name, separator, value = pair.partition(":") + if not separator: + raise CommandError( + f"Invalid pair {pair!r}: expected the form VARIABLE:VALUE." + ) + name = name.strip() + if not name: + raise CommandError(f"Invalid pair {pair!r}: the variable name is empty.") + return name, value + + +def _run_save(args: argparse.Namespace) -> int: + project_root = resolve_root(args.path) + values = dict(_parse_pair(pair) for pair in args.pairs) + + for key in values: + if is_vault_variable(key): + warn( + f"{key} configures the Vault connection itself; NavConfig " + "reads it from the .env file, not from Vault." + ) + + settings = resolve_settings(args, project_root) + destination = f"{settings['mount_point']}/{settings['path']}" + + msg(f"Saving {len(values)} variable(s) to {settings['url']} -> {destination}") + for key, value in values.items(): + msg(f" {key} = {_mask(value)}") + + if args.dry_run: + msg("") + msg("Dry run: nothing was written to Vault.") + return 0 + + writer = VaultWriter(settings) + payload = writer.read() + payload.update(values) + writer.write(payload) + + msg("") + msg(f"Saved {len(values)} variable(s) to {destination}.") + return 0 diff --git a/navconfig/conf.py b/navconfig/conf.py index bab2293c..231ab386 100644 --- a/navconfig/conf.py +++ b/navconfig/conf.py @@ -1,34 +1,28 @@ import os +import contextlib import sys import logging from navconfig import BASE_DIR, DEBUG - os.chdir(str(BASE_DIR)) # Reduce asyncio log level: logging.getLogger('asyncio').setLevel(logging.INFO) - # Debug LOCAL_DEVELOPMENT = DEBUG is True and sys.argv[0] == "run.py" -### Global-Settings. -try: - from settings.settings import * # pylint: disable=W0401,W0614 # noqa -except ImportError as err: - try: - from settings import * # pylint: disable=W0401,W0614 # noqa - except ImportError as err: - logging.error("There is no *settings/* module in project.") - print( - "Settings.py File is missing." - "Hint: Its recommended to use a settings/settings.py" - " or settings/__init__.py module to customize your " - " Configuration." - ) -### User Local Settings -try: - from settings.local_settings import * # pylint: disable=W0401,W0614 # noqa -except ImportError as err: - pass +### Load Global-Settings if available (optional). +### The settings package is not mandatory for navconfig to work. +with contextlib.suppress(ImportError): + ### Global-Settings. + try: + from settings.settings import * # pylint: disable=W0401,W0614 # noqa + except ImportError as err: + try: + from settings.settings import * # pylint: disable=W0401,W0614 # noqa + except ImportError: + try: + from settings import * # pylint: disable=W0401,W0614 # noqa + except ImportError: + pass \ No newline at end of file diff --git a/navconfig/contrib/__init__.py b/navconfig/contrib/__init__.py new file mode 100644 index 00000000..df7f0726 --- /dev/null +++ b/navconfig/contrib/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .web import AiohttpConfig diff --git a/navconfig/contrib/web.py b/navconfig/contrib/web.py new file mode 100644 index 00000000..629fe8ab --- /dev/null +++ b/navconfig/contrib/web.py @@ -0,0 +1,12 @@ +from aiohttp import web +from navconfig import config, Kardex + +class AiohttpConfig: + def __init__(self, app: web.Application, key_name: str = 'config'): + self.app = app + config_key = web.AppKey(key_name, Kardex) + if hasattr(self.app, key_name): + # already configured + return + self.app[config_key] = config + setattr(self.app, key_name, config) diff --git a/navconfig/kardex.py b/navconfig/kardex.py index e91b9346..77b2283e 100644 --- a/navconfig/kardex.py +++ b/navconfig/kardex.py @@ -1,8 +1,13 @@ -import os -import asyncio from typing import ( Any, + Dict, + List, + Optional, ) +import os +import contextlib +import asyncio +import warnings from collections.abc import Callable import logging from configparser import ( @@ -19,17 +24,10 @@ from .loaders import import_loader, pyProjectLoader from .exceptions import ConfigError, KardexError, ReaderNotSet -## memcache: -try: - from .readers.memcache import mcache - MEMCACHE_LOADER = mcache -except ModuleNotFoundError: - MEMCACHE_LOADER = None ## redis: try: from .readers.redis import mredis - REDIS_LOADER = mredis except ModuleNotFoundError: REDIS_LOADER = None @@ -37,7 +35,6 @@ ## Hashicorp Vault: try: from .readers.vault import VaultReader - HVAULT_LOADER = VaultReader except ModuleNotFoundError: HVAULT_LOADER = None @@ -66,14 +63,23 @@ def __init__( # check if create is True (default: false) # create the required directories: self._create: bool = strtobool(os.getenv("CONFIG_CREATE", False)) + self._auto_env: bool = strtobool(os.getenv("AUTO_DISCOVERY", "True")) + + # Core components + self._site_path: Path = None + self._env_loader: Callable = None self._ini: Callable = None - lazy_load = strtobool(os.getenv('LAZY_LOAD', 'False')) + self._current_env: str = None + # Cache for multiple environments + self._env_cache: Dict[str, Dict] = {} + # asyncio loop try: self._loop = asyncio.get_running_loop() except RuntimeError: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) + # this only load at first time if not site_root: # TODO: better discovery of Project Root @@ -84,24 +90,23 @@ def __init__( else: self._site_path = site_root # then: configure the instance: + lazy_load = strtobool(os.getenv('LAZY_LOAD', 'False')) if lazy_load is False: self.configure(env, **kwargs) def configure( self, env: str = None, - env_type: str = "file", + env_type: str = "vault", override: bool = False ): - """_summary_ + """ + Configure Kardex with enhanced vault + file loading. Args: - env (str, optional): Environment name (dev, prod). - Defaults to None. - env_type (str, optional): type of enviroment. - Defaults to "file". - override (bool, optional): override current .env variables. - Defaults to False. + env (str, optional): Environment name (dev, prod, staging). + env_type (str, optional): Loader type - defaults to "vault" (unified vault+file). + override (bool, optional): Override current environment variables. Raises: ConfigError: Error on Configuration. @@ -109,97 +114,148 @@ def configure( # Environment Configuration: if env is not None: self.ENV = env + self._current_env = env else: environment = os.getenv("ENV", "") self.ENV = environment - # getting type of enviroment consumer: + self._current_env = environment + # getting type of environment consumer: try: - self.load_enviroment( + self.load_environment( env_type, override=override ) except FileNotFoundError: - logging.error( - "NavConfig Error: Environment (.env) File is Missing." - ) - # Get External Readers: - self._use_redis: bool = strtobool(os.environ.get("USE_REDIS", False)) - if self._use_redis: - if REDIS_LOADER: - try: - self._readers["redis"] = REDIS_LOADER() - except ReaderNotSet as err: - logging.error(f"{err}") - self._use_redis = False - except Exception as err: - logging.warning(f"Redis error: {err}") - raise ConfigError(str(err)) from err - self._use_memcache: bool = strtobool( - os.environ.get("USE_MEMCACHED", False) - ) - if self._use_memcache: - if MEMCACHE_LOADER: - try: - self._readers["memcache"] = MEMCACHE_LOADER() - except ReaderNotSet as err: - logging.error(f"{err}") - self._use_memcache = False - except Exception as err: - raise ConfigError(str(err)) from err - ## Hashicorp Vault: - self._use_vault: bool = strtobool(os.environ.get("USE_VAULT", False)) - if self._use_vault: - if HVAULT_LOADER: + logging.error("NavConfig Error: Environment configuration is missing.") + # Try fallback to file-only loading + if env_type == "vault": + logging.info("Falling back to file-only loading...") try: - self._readers["vault"] = HVAULT_LOADER( - env=self.ENV - ) - except ReaderNotSet as err: - logging.error(f"{err}") + self.load_environment("file", override=override) except Exception as err: - logging.warning(f"Vault error: {err}") - raise ConfigError(str(err)) from err - # define debug + logging.error(f"Fallback loading also failed: {err}") + raise ConfigError( + "NavConfig Error: Unable to load environment configuration" + ) from err + # Initialize external readers (redis cache, vault as reader) + self._init_external_readers() + # Load INI configuration + self._load_ini_config() + # Running Load PyProject: + self.load_pyproject() + # Defined as initialized: + self.__initialized__ = True + + def _resolve_cache_backend(self) -> Optional[str]: + """Resolve which cache backend to use. + + Priority: + 1. CACHE_BACKEND env var (explicit: 'redis') + 2. Legacy USE_REDIS flag (backward compat) + + Returns: + 'redis', or None if no cache backend is configured. + """ + backend = os.environ.get("CACHE_BACKEND", "").strip().lower() + if backend == "redis": + return backend + if backend == "memcached": + warnings.warn( + "Memcached support has been removed from NavConfig. " + "Please use CACHE_BACKEND='redis' instead.", + DeprecationWarning, + stacklevel=3, + ) + return None + + # Legacy support: infer from USE_REDIS + use_redis = strtobool(os.environ.get("USE_REDIS", False)) + if use_redis: + warnings.warn( + "USE_REDIS is deprecated. Use CACHE_BACKEND='redis' instead.", + DeprecationWarning, + stacklevel=3, + ) + return "redis" + + return None + + def _init_external_readers(self): + """Initialize external readers (cache backend, vault as reader). + + A single key-value cache backend (Redis) is active when enabled via + ``CACHE_BACKEND`` (preferred) or the legacy ``USE_REDIS`` flag. + """ + # --- Cache backend (redis) --- + self._cache_backend: Optional[str] = self._resolve_cache_backend() + self._use_cache: bool = False + + if self._cache_backend == "redis" and REDIS_LOADER: + try: + reader = REDIS_LOADER() + self._readers["cache"] = reader + self._use_cache = True + except ReaderNotSet as err: + logging.debug(f"{err}") + except Exception as err: + logging.debug(f"Redis error: {err}") + raise ConfigError(str(err)) from err + elif self._cache_backend is not None: + logging.warning( + f"CACHE_BACKEND='{self._cache_backend}' but REDIS_LOADER " + f"is not available (missing dependency)." + ) + + # Backward-compat alias so source("redis") still works + if self._use_cache: + self._readers["redis"] = self._readers["cache"] + + # --- Vault as external reader (different from vault loader) --- + self._use_vault: bool = strtobool(os.environ.get("VAULT_ENABLED", False)) + if self._use_vault and HVAULT_LOADER: + try: + self._readers["vault"] = HVAULT_LOADER(env=self.ENV) + except ReaderNotSet as err: + logging.error(f"{err}") + except Exception as err: + logging.warning(f"Vault error: {err}") + raise ConfigError(str(err)) from err + + def _load_ini_config(self): + """Load INI configuration file.""" self._debug = bool(self.getboolean("DEBUG", fallback=False)) - # and get the config file declared in the environment file config_file = self.get("CONFIG_FILE", fallback=self._conffile) self._ini = ConfigParser() + cf = Path(config_file) if not cf.is_absolute(): cf = self._site_path.joinpath(config_file) if not cf.exists(): - # try ini file from etc/ directory. cf = self._site_path.joinpath(self._conffile) + self._ini_path = cf if cf.exists(): try: self._ini.read(cf) except IOError as err: - logging.exception( - f"NavConfig: INI file doesn't exist: {err}" - ) + logging.exception(f"NavConfig: INI file doesn't exist: {err}") except ParsingError as ex: - logging.exception( - f"Navconfig: unable to parse INI file: {ex}" - ) + logging.exception(f"Navconfig: unable to parse INI file: {ex}") else: - logging.warning( - f"Navconfig: INI file doesn't exists on path: {cf!s}" - ) + logging.warning(f"Navconfig: INI file doesn't exists on path: {cf!s}") if self._create is True: - try: - cf.mkdir(parents=True, exist_ok=True) - except IOError: - pass - # Running Load PyProject: - self.load_pyproject() - # Defined as initialized: - self.__initialized__ = True + with contextlib.suppress(IOError): + cf.parent.mkdir(parents=True, exist_ok=True) @property def initialized(self) -> bool: return self.__initialized__ + @property + def cache_backend(self) -> Optional[str]: + """Return the active cache backend name ('redis') or None.""" + return self._cache_backend if self._use_cache else None + def __del__(self): try: self.close() @@ -227,7 +283,7 @@ def load_pyproject(self): project_file = os.getenv("PROJECT_FILE", "pyproject.toml") if isinstance(project_path, str): project_path = Path(project_path).resolve() - try: + with contextlib.suppress(FileNotFoundError): self._pyproject = pyProjectLoader( env_path=project_path, project_name=project_name, @@ -236,25 +292,13 @@ def load_pyproject(self): ) data = self._pyproject.load_environment() self._mapping_ = {**self._mapping_, **data} - except FileNotFoundError: - # don't raise an error if file doesn't exist - pass except Exception as err: logging.exception(err) raise ConfigError( f"PyProject: {err}" ) from err - def save_environment(self, env_type: str = "drive"): - """ - Saving a remote Environment into a local File. - """ - env_path = self.site_root.joinpath("env", self.ENV, ".env") - # pluggable types - if self._env_loader.downloadable is True: - self._env_loader.save_enviroment(env_path) - - def load_enviroment(self, env_type: str = "file", override: bool = False): + def load_environment(self, env_type: str = "vault", override: bool = False): """load_environment. Load an environment from a File or any pluggable Origin. """ @@ -270,13 +314,22 @@ def load_enviroment(self, env_type: str = "file", override: bool = False): override=override, create=self._create, env=self.ENV, + auto=self._auto_env ) self._mapping_ = self._env_loader.load_environment() if self._mapping_ is None: self._mapping_ = {} # empty dict except (FileExistsError, FileNotFoundError) as ex: - logging.warning(str(ex)) - raise + error_message = ( + "NavConfig initialization failed: environment assets are missing.\n" + f"Original error: {ex}\n" + "Ensure your project contains an 'env' directory with the selected " + "environment subfolder and a '.env' file (e.g. env/" + f"{self.ENV or 'dev'}/.env).\n" + "You can scaffold the required files by running `kardex env create`." + ) + logging.warning(error_message) + raise type(ex)(error_message) from ex except RuntimeError as ex: raise RuntimeError(str(ex)) from ex except Exception as ex: @@ -329,17 +382,16 @@ def addEnv(self, file, override: bool = False): Args: file (Path): File to be loaded on ENV override (bool, optional): Override current ENV variables. - Defaults to False. + Defaults to False. """ - if file.exists() and file.is_file(): - try: - load_dotenv(dotenv_path=file, override=override) - except Exception as err: - raise KardexError(str(err)) from err - else: + if not file.exists() or not file.is_file(): raise ConfigError( f"Failed to load a new ENV file from {file}" ) + try: + load_dotenv(dotenv_path=file, override=override) + except Exception as err: + raise KardexError(str(err)) from err def _get_external(self, key: str) -> Any: """Get value fron an External Reader.""" @@ -374,14 +426,10 @@ def getboolean(self, key: str, section: str = None, fallback: Any = None): return strtobool(val) elif self._ini: try: - val = self._ini.getboolean(section, key) - return val + return self._ini.getboolean(section, key) except ValueError: val = self._ini.get(section, key) - if not val: - return fallback - else: - return self._ini.BOOLEAN_STATES[val.lower()] + return self._ini.BOOLEAN_STATES[val.lower()] if val else fallback # noqa except (NoOptionError, NoSectionError): return fallback # get ENV value @@ -393,10 +441,7 @@ def getboolean(self, key: str, section: str = None, fallback: Any = None): else: val = self._get_external(key) val = self._unserialize(val) - if val: - return strtobool(val) - else: - return fallback + return strtobool(val) if val else fallback def getint(self, key: str, section: str = None, fallback: Any = None): """ @@ -408,10 +453,8 @@ def getint(self, key: str, section: str = None, fallback: Any = None): if section in self._mapping_: val = self._mapping_[section] else: - try: + with contextlib.suppress(NoOptionError, NoSectionError): val = self._ini.getint(section, key) - except (NoOptionError, NoSectionError): - pass elif key in os.environ: val = os.getenv(key, fallback) else: @@ -421,9 +464,7 @@ def getint(self, key: str, section: str = None, fallback: Any = None): try: return int(val) except (TypeError, ValueError): - if val.isdigit(): - return int(val) - return fallback + return int(val) if val.isdigit() else fallback def getlist(self, key: str, section: str = None, fallback: Any = None): """ @@ -435,10 +476,8 @@ def getlist(self, key: str, section: str = None, fallback: Any = None): if section in self._mapping_: val = self._mapping_[section] else: - try: + with contextlib.suppress(NoOptionError, NoSectionError): val = self._ini.get(section, key) - except (NoOptionError, NoSectionError): - pass if key in os.environ: val = os.getenv(key, fallback) val = self._unserialize(val) @@ -446,10 +485,7 @@ def getlist(self, key: str, section: str = None, fallback: Any = None): val = self._mapping_[key] if isinstance(val, (list, tuple)): return val - if val: - return val.split(",") - else: - return [] + return val.split(",") if val else [] def getdict(self, key: str) -> dict: if key in self._mapping_: @@ -466,14 +502,10 @@ def get(self, key: str, section: str = None, fallback: Any = None) -> Any: # if not val and if section, get from INI if section is not None: if section in self._mapping_: - val = self._mapping_[section] - return val + return self._mapping_[section] elif self._ini: - try: - val = self._ini.get(section, key) - return val - except (NoOptionError, NoSectionError): - pass + with contextlib.suppress(NoOptionError, NoSectionError): + return self._ini.get(section, key) if key in self._mapping_: return self._mapping_[key] # get ENV value @@ -495,8 +527,7 @@ def __setitem__(self, key: str, value: Any) -> None: os.environ[key] = value elif key in self._mapping_: return self._mapping_[key] - else: - pass # Adding to Mutable Mapping + # Adding to Mutable Mapping def __getitem__(self, key: str) -> Any: """ @@ -505,17 +536,12 @@ def __getitem__(self, key: str) -> Any: return self.get(key) def __contains__(self, key: str) -> bool: - if key in os.environ: - return True - elif key in self._mapping_: + if key in os.environ or key in self._mapping_: return True else: for _, reader in self._readers.items(): val = reader.exists(key) - if val is True: - return True - else: - return False + return val is True return False def exists(self, key: str) -> bool: @@ -541,14 +567,7 @@ def __getattr__(self, key: str) -> Any: # get data from external readers: val = self._get_external(key) if val: - val = self._unserialize(val) - try: - if val.lower() in self._ini.BOOLEAN_STATES: - return self._ini.BOOLEAN_STATES[val.lower()] - elif val.isdigit(): - return int(val) - finally: - return val # pylint: disable=W0150 + return self._unserialize(val) else: raise AttributeError( f"Config Error: has not attribute {key}" @@ -575,8 +594,8 @@ def _unserialize(self, value: Any) -> str: def set(self, key: str, value: Any) -> None: """ set. - Set an enviroment variable on REDIS, based on Strategy - TODO: add cloudpickle to serialize and unserialize data first. + Set an enviroment variable on REDIS, based on Strategy + TODO: add cloudpickle to serialize and unserialize data first. """ if key in self._mapping_: self._mapping_[key] = value @@ -591,13 +610,13 @@ def set(self, key: str, value: Any) -> None: ) except Exception: raise - elif self._use_redis: + elif self._use_cache: value = self._serialize(value) try: - return self._readers["redis"].set(key, value) + return self._readers["cache"].set(key, value) except KeyError: logging.warning( - f"Unable to Set key {key} in Redis" + f"Unable to Set key {key} in cache ({self._cache_backend})" ) else: # set the mapping: @@ -614,20 +633,16 @@ def setext( set set a variable in redis with expiration """ - if self._use_redis: - if not isinstance(timeout, int): - time = 3600 - else: - time = timeout + if self._use_cache: + time = timeout if isinstance(timeout, int) else 3600 try: - return self._readers["redis"].set(key, value, time) + return self._readers["cache"].set(key, value, time) except KeyError: - logging.warning(f"Unable to Set key {key} in Redis") - elif vault is True: - if not isinstance(timeout, int): - time = 3600 - else: - time = timeout + logging.warning( + f"Unable to Set key {key} in cache ({self._cache_backend})" + ) + elif vault: + time = timeout if isinstance(timeout, int) else 3600 try: return self._readers["vault"].set(key, value, timeout=timeout) except (ValueError, AttributeError): @@ -636,3 +651,142 @@ def setext( ) else: return False + + def set_env(self, new_env: str, reload: bool = True) -> bool: + """ + Switch environment at runtime. + + Args: + new_env: Target environment (dev, prod, staging, etc.) + reload: Whether to reload configuration immediately + + Returns: + bool: True if switch was successful + """ + if new_env == self._current_env: + logging.debug(f"Already in environment: {new_env}") + return True + + old_env = self._current_env + + try: + # Check cache first + if new_env in self._env_cache and not reload: + self._mapping_ = self._env_cache[new_env].copy() + self._current_env = new_env + self.ENV = new_env + logging.info(f"Switched to cached environment: {new_env}") + return True + + # Switch environment in loader if supported + if hasattr(self._env_loader, 'set_environment'): + self._env_loader.set_environment(new_env) + self._current_env = new_env + self.ENV = new_env + else: + # Reinitialize loader for new environment + self._current_env = new_env + self.ENV = new_env + self.load_environment(override=False) + + logging.info(f"Environment switched from {old_env} to {new_env}") + return True + + except Exception as e: + # Rollback on error + self._current_env = old_env + self.ENV = old_env + logging.error(f"Failed to switch to environment {new_env}: {e}") + raise RuntimeError(f"Environment switch failed: {e}") from e + + def get_current_env(self) -> str: + """Get currently active environment.""" + return self._current_env + + def list_available_envs(self) -> List[str]: + """List all available environments from filesystem.""" + envs = set() + + try: + env_base = self.site_root / "env" + if env_base.exists(): + envs.update(d.name for d in env_base.iterdir() if d.is_dir()) + except Exception as e: + logging.debug(f"Error scanning filesystem environments: {e}") + + # Add any cached environments + envs.update(self._env_cache.keys()) + + return sorted(envs) + + def get_env_info(self) -> Dict[str, Any]: + """Get comprehensive information about current environment.""" + info = { + 'current_env': self._current_env, + 'loader_type': type(self._env_loader).__name__ if self._env_loader else None, + 'available_envs': self.list_available_envs(), + 'cached_envs': list(self._env_cache.keys()), + 'site_root': str(self.site_root), + 'total_variables': len(self._mapping_), + 'cache_backend': self.cache_backend, + } + + # Add vault-specific information if available + if hasattr(self._env_loader, 'get_vault_status'): + info['vault_status'] = self._env_loader.get_vault_status() + + # Add file loading information if available + if hasattr(self._env_loader, 'get_loaded_files'): + loaded_files = self._env_loader.get_loaded_files() + info['loaded_files'] = [str(f) for f in loaded_files] + info['file_count'] = len(loaded_files) + + return info + + def get_with_env(self, key: str, env: str = None, fallback: Any = None) -> Any: + """ + Get a variable from a specific environment without switching. + + Usage: + prod_db = config.get_with_env('DATABASE_URL', 'prod') + """ + if env is None or env == self._current_env: + return self.get(key, fallback=fallback) + + # Check cache first + if env in self._env_cache: + return self._env_cache[env].get(key, fallback) + + # Load environment temporarily (simplified version) + try: + temp_env_path = self.site_root.joinpath("env", env) + if temp_env_path.exists(): + from .loaders.vault import vaultLoader + temp_loader = vaultLoader( + env_path=temp_env_path, + env=env, + override=False, + create=False, + ) + if temp_data := temp_loader.load_environment(): + # Cache for future use + self._env_cache[env] = temp_data.copy() + return temp_data.get(key, fallback) + + except Exception as e: + logging.debug(f"Failed to load environment {env}: {e}") + + return fallback + + def clear_env_cache(self, env: str = None): + """Clear cached environment data.""" + if env: + self._env_cache.pop(env, None) + logging.debug(f"Cleared cache for environment: {env}") + else: + self._env_cache.clear() + logging.debug("Cleared all environment cache") + + def reload_current_env(self): + """Reload current environment from source.""" + self.set_env(self._current_env, reload=True) diff --git a/navconfig/loaders/__init__.py b/navconfig/loaders/__init__.py index 411a4f1e..20c6390f 100644 --- a/navconfig/loaders/__init__.py +++ b/navconfig/loaders/__init__.py @@ -2,12 +2,19 @@ from .pyproject import pyProjectLoader -def import_loader(loader: str = "file"): - classpath = f"navconfig.loaders.{loader}" - cls = f"{loader}Loader" - try: - module = import_module(classpath, package="loaders") - obj = getattr(module, cls) - return obj - except ImportError as err: - raise RuntimeError(f"Navconfig Error: Cannot load {cls}: {err}") from err +def import_loader(loader: str = "vault"): + if loader == "vault": + from .vault import vaultLoader + return vaultLoader + elif loader == "file": + from .file import fileLoader + return fileLoader + else: + # Import other loaders as before + classpath = f"navconfig.loaders.{loader}" + cls = f"{loader}Loader" + try: + module = import_module(classpath, package="loaders") + return getattr(module, cls) + except ImportError as err: + raise RuntimeError(f"Navconfig Error: Cannot load {cls}: {err}") from err diff --git a/navconfig/loaders/abstract.py b/navconfig/loaders/abstract.py index d2cfcd54..ad7b2bca 100644 --- a/navconfig/loaders/abstract.py +++ b/navconfig/loaders/abstract.py @@ -1,9 +1,35 @@ -from typing import Any -from typing import Union +from typing import Any, Union from abc import ABC, abstractmethod +import logging +import re from pathlib import PurePath from io import StringIO from dotenv import dotenv_values, load_dotenv +from ..project import validate_project_environment + + +# Matches INI-style section headers (e.g. ``[AUTH_BACKENDS]``) on their own line. +_SECTION_HEADER = re.compile(r"^\s*\[[^\]]*\]\s*$") + + +def _strip_section_headers(content: str) -> str: + """Remove INI-style ``[section]`` lines from a .env file content. + + Some projects use ``.env`` files as if they were ``.ini`` files, grouping + variables under ``[Section]`` headers. python-dotenv parses those headers + as keys with a ``None`` value (e.g. ``"[AUTH_BACKENDS]": None``), polluting + the environment. This filter drops those lines before they reach dotenv. + + Args: + content: Raw text content of a .env file. + + Returns: + The same content without lines that are pure section headers. + """ + return "\n".join( + line for line in content.splitlines() + if not _SECTION_HEADER.match(line) + ) class BaseLoader(ABC): @@ -18,20 +44,26 @@ def __init__( self.env_path = env_path self.env_file = ".env" self._kwargs = kwargs - self.downloadable: bool = False self._content: Any = None - if isinstance(self.env_path, PurePath): - if not env_path.exists(): - if create: - try: - env_path.mkdir(parents=True, exist_ok=True) - except IOError as ex: - raise RuntimeError( - f"{type(self).__name__}: Error creating directory {env_path}: {ex}" - ) from ex - raise FileExistsError( - f"{type(self).__name__}: No Directory Path: {env_path}" - ) + if isinstance(self.env_path, PurePath) and not env_path.exists(): + if create: + try: + env_path.mkdir(parents=True, exist_ok=True) + except IOError as ex: + raise RuntimeError( + f"{type(self).__name__}: Error creating directory {env_path}: {ex}" # noqa + ) from ex + env_status, warnings = validate_project_environment() + if env_status != 'ok': + for warning in warnings: + logging.warning(f"NavConfig: {warning}") + raise FileExistsError( + "NavConfig could not find the expected environment directory. " + f"Looked for: {env_path}.\n" + "NavConfig projects require an 'env/<environment>/.env' file. " + "Run `kardex env create` to scaffold the default structure (env folder, " + "base .env file, and etc/config.ini)." + ) @abstractmethod def load_environment(self): @@ -42,18 +74,20 @@ def save_environment(self): pass def load_from_file(self, path): - load_dotenv(dotenv_path=path, override=self.override) + with open(path, "r", encoding="utf-8") as fh: + content = _strip_section_headers(fh.read()) + load_dotenv(stream=StringIO(content), override=self.override) def load_from_stream(self, content: str): - load_dotenv(stream=content, override=self.override) + content = _strip_section_headers(content) + load_dotenv(stream=StringIO(content), override=self.override) def load_from_string(self, content: Union[str, dict]): - if isinstance(content, str): - filelike = StringIO(content) - filelike.seek(0) - dotenv_values(stream=filelike) - else: + if not isinstance(content, str): return content + filelike = StringIO(_strip_section_headers(content)) + filelike.seek(0) + dotenv_values(stream=filelike) def load(self): # TODO: making some validation of content diff --git a/navconfig/loaders/drive.py b/navconfig/loaders/drive.py deleted file mode 100644 index 69aa05ff..00000000 --- a/navconfig/loaders/drive.py +++ /dev/null @@ -1,57 +0,0 @@ -import os -import logging -from pydrive.auth import GoogleAuth -from pydrive.drive import GoogleDrive -from .abstract import BaseLoader - -logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.CRITICAL) - - -class driveLoader(BaseLoader): - """fileLoader. - - Use to read configuration settings from .env Files. - """ - - drive = None - file_id = None - - def __init__(self, **kwargs): - self.file_id = os.getenv("NAVCONFIG_DRIVE_ID") - client = os.getenv("NAVCONFIG_DRIVE_CLIENT") - if self.file_id and client: - gauth = GoogleAuth() - gauth.LoadCredentialsFile(client) - if gauth.credentials is None: - # Authenticate if they're not there - gauth.LocalWebserverAuth() # Creates local webserver and auto handles authentication. - # Save the current credentials to a file - elif gauth.access_token_expired: - gauth.Refresh() - else: - gauth.Authorize() - gauth.SaveCredentialsFile(client) - print("Google Auth Success") - self.drive = GoogleDrive(gauth) - else: - raise Exception( - "Config Google Drive Error: you need to provide **NAVCONFIG_DRIVE_CLIENT** for client configuration" - ) - - def load_enviroment(self): - try: - env = self.drive.CreateFile({"id": self.file_id}) - content = env.GetContentString() - self.load_from_string(content) - except Exception as err: - raise Exception("Error loading Environment: {}".format(err)) - - def save_enviroment(self, path: str = None): - try: - env = self.drive.CreateFile({"id": self.file_id}) - content = env.GetContentString() - if content: - with open(path, "w+") as f: - f.write(content) - except Exception as err: - raise Exception("Error Saving Environment: {}".format(err)) diff --git a/navconfig/loaders/file.py b/navconfig/loaders/file.py index ce0b18c2..d2d48e2c 100644 --- a/navconfig/loaders/file.py +++ b/navconfig/loaders/file.py @@ -1,24 +1,83 @@ import logging +from pathlib import PurePath from .abstract import BaseLoader +def sort_key(path): + name = path.name + return "0" if name == ".env" else name + + class fileLoader(BaseLoader): """fileLoader. Use to read configuration settings from .env Files. - """ - def load_environment(self): - file_path = self.env_path.joinpath(self.env_file) - if file_path.exists(): - if file_path.stat().st_size == 0: - logging.warning( - f"Empty Environment File: {file_path}" - ) - # load dotenv from file: - self.load_from_file(file_path) + Loads one or multiple .env files from a directory to separate variables. + Files are loaded in the order they are found, with later files overriding earlier ones. + + + """ # noqa: E501 + + # Define the order in which files should be loaded + DEFAULT_ENV_FILES = [ + ".env", # Base configuration + ".env.resources", # Resource configurations + ".env.databases", # Database credentials + ".env.api", # API keys and endpoints + ".env.secrets", # Sensitive data + ".env.local" # Local overrides (loaded last) + ] + + def __init__( + self, + env_path: PurePath, + override: bool = False, + create: bool = True, + file_patterns: list[str] = None, + auto: bool = False, + **kwargs + ) -> None: + super().__init__(env_path, override, create=create, **kwargs) + # Allow custom file patterns or use defaults + if auto: + if env_files := list(self.env_path.glob(".env*")): + env_files.sort(key=sort_key) + self.file_patterns = [file.name for file in env_files] + else: + self.file_patterns = self.DEFAULT_ENV_FILES else: - raise FileNotFoundError(f"Environment file not found: {file_path}") + self.file_patterns = file_patterns or self.DEFAULT_ENV_FILES + + # Track which files were actually loaded + self.loaded_files = [] + + def load_environment(self): + """Load multiple .env files in the specified order.""" + loaded_count = 0 + for file_pattern in self.file_patterns: + file_path = self.env_path.joinpath(file_pattern) + if file_path.exists() and file_path.is_file(): + try: + if file_path.stat().st_size == 0: + logging.warning(f"Empty environment file: {file_path}") + continue + self.load_from_file(file_path) + self.loaded_files.append(file_path) + loaded_count += 1 + except Exception as e: + logging.warning( + f"Error loading env file {file_path}: {e}" + ) + if loaded_count == 0: + raise FileNotFoundError( + f"No environment files found in {self.env_path}. " + f"Looking for: {', '.join(self.file_patterns)}" + ) def save_environment(self): raise NotImplementedError + + def get_loaded_files(self): + """Return list of successfully loaded files.""" + return self.loaded_files diff --git a/navconfig/loaders/vault.py b/navconfig/loaders/vault.py new file mode 100644 index 00000000..526597c0 --- /dev/null +++ b/navconfig/loaders/vault.py @@ -0,0 +1,371 @@ +# navconfig/loaders/vault.py +""" +Unified Vault + File Loader - Default loader for NavConfig + +This loader combines vault and file-based configuration loading: +1. Reads vault credentials from .env files +2. Connects to vault and loads environment-specific secrets +3. Supplements with .env.* files for additional configuration +4. Falls back to file-only loading if vault unavailable +5. Supports multi-file loading (.env, .env.resources, .env.databases, etc.) +""" + +import os +import logging +from typing import Dict, Any, Optional, List +from pathlib import Path, PurePath +from dotenv import load_dotenv, dotenv_values +from .abstract import BaseLoader + + +def sort_key(path): + """Sort key for .env files - .env first, then alphabetical.""" + name = path.name + return "0" if name == ".env" else name + + +class vaultLoader(BaseLoader): + """ + Unified Vault + File Loader - Default loader for NavConfig. + + This loader provides seamless integration between HashiCorp Vault and + traditional .env files with intelligent fallback behavior. + + Loading Strategy: + 1. Load base .env file to get vault credentials and basic config + 2. If vault credentials present, connect to vault + 3. Load environment-specific secrets from vault (vault/{env}/) + 4. Load additional .env.* files to supplement vault data + 5. If vault unavailable, continue with file-only loading + + Environment Structure: + - Vault: {mount_point}/{env}/* (e.g., navapi/dev/DATABASE_URL) + - Files: env/{env}/.env, env/{env}/.env.*, etc. + + The vault path segment can be overridden independently of ENV by + setting VAULT_ENV (env var or base .env file). When VAULT_ENV is + set and non-empty, vault secrets are read from + {mount_point}/{VAULT_ENV}/* while file loading keeps using ENV. + """ + + # Default file patterns for multi-file loading + DEFAULT_ENV_FILES = [ + ".env", # Base configuration + vault credentials + ".env.resources", # Resource configurations + ".env.databases", # Database configurations (non-secret) + ".env.api", # API configurations (non-secret) + ".env.cache", # Cache configurations + ".env.local" # Local overrides (loaded last) + ] + + def __init__( + self, + env_path: PurePath, + env: str = None, + override: bool = False, + create: bool = True, + file_patterns: List[str] = None, + auto: bool = False, + **kwargs + ) -> None: + super().__init__(env_path, override, create=create, **kwargs) + + # Environment determination + self.env = env or os.getenv('ENV', 'dev') + # Vault path segment: VAULT_ENV overrides ENV for vault lookups only + self._vault_env_override = bool(os.getenv('VAULT_ENV')) + self.vault_env = os.getenv('VAULT_ENV') or self.env + + # File loading configuration + if auto: + # Auto-discover .env* files + if env_files := list(self.env_path.glob(".env*")): + env_files.sort(key=sort_key) + self.file_patterns = [file.name for file in env_files] + else: + self.file_patterns = self.DEFAULT_ENV_FILES + else: + self.file_patterns = file_patterns or self.DEFAULT_ENV_FILES + + # Vault configuration + self.vault_enabled = False + self.vault_reader = None + self.vault_config = {} + + # Tracking + self.loaded_files = [] + self.vault_data = {} + self.file_data = {} + + def load_environment(self) -> Dict[str, Any]: + """ + Main loading method - orchestrates vault + file loading. + """ + all_data = {} + + # Step 1: Load base .env file first to get vault credentials + base_env_data = self._load_base_env_file() + all_data |= base_env_data + + # Step 2: Initialize and load from vault if credentials available + if vault_data := self._load_from_vault(): + all_data |= vault_data + self.vault_data = vault_data + logging.info(f"Loaded {len(vault_data)} variables from vault") + + # Step 3: Load additional .env.* files (excluding base .env) + file_data = self._load_additional_env_files() + + # Step 4: Merge file data + for key, value in file_data.items(): + if key not in all_data: + all_data[key] = value + + self.file_data = file_data + override_files = os.getenv("NAVCONFIG_FILE_OVERRIDE_ENABLED", "") + file_wins = override_files.lower() in ("true", "1", "yes") + if file_wins: + # File wins: base .env and .env.* override vault values + for key, value in {**base_env_data, **file_data}.items(): + all_data[key] = value + elif self.vault_data: + # Vault wins: vault overrides any file values + for key, value in self.vault_data.items(): + all_data[key] = value + + # Step 5: Update environment variables + self._update_environment_variables(all_data) + + logging.debug( + f"Environment '{self.env}' loaded: " + f"{len(self.vault_data)} from vault, " + f"{len(file_data)} from files, " + f"{len(all_data)} total variables" + ) + + return all_data + + def _load_base_env_file(self) -> Dict[str, Any]: + """ + Load the base .env file to get vault credentials and basic configuration. + """ + base_env_path = self.env_path / ".env" + base_data = {} + + if base_env_path.exists() and base_env_path.stat().st_size > 0: + try: + # Load into environment first + load_dotenv(dotenv_path=base_env_path, override=self.override) + + # Also get as dict for return value + base_data = dotenv_values(base_env_path) + + self.loaded_files.append(base_env_path) + logging.debug(f"Loaded base .env file: {base_env_path}") + + # Extract vault configuration + self._extract_vault_config(base_data) + + except Exception as e: + logging.warning(f"Error loading base .env file {base_env_path}: {e}") + else: + logging.debug(f"No base .env file found at {base_env_path}") + + return base_data + + def _extract_vault_config(self, env_data: Dict[str, Any]) -> None: + """ + Extract vault configuration from environment data. + """ + # Check if vault is enabled + vault_enabled = env_data.get('VAULT_ENABLED', os.getenv('VAULT_ENABLED', '')) + self.vault_enabled = vault_enabled.lower() in ('true', '1', 'yes') + + # VAULT_ENV (from the env-specific .env file, or the process + # environment) overrides ENV for the vault path segment; the file + # value wins over the container value, falling back to ENV. + vault_env = env_data.get('VAULT_ENV') or os.getenv('VAULT_ENV') + self._vault_env_override = bool(vault_env) + self.vault_env = vault_env or self.env + + if self.vault_enabled: + self.vault_config = { + 'url': env_data.get('VAULT_URL', os.getenv('VAULT_URL')), + 'token': env_data.get('VAULT_TOKEN', os.getenv('VAULT_TOKEN')), + 'mount_point': env_data.get('VAULT_MOUNT_POINT', os.getenv('VAULT_MOUNT_POINT', 'navigator')), + 'version': int(env_data.get('VAULT_VERSION', os.getenv('VAULT_VERSION', '2'))), + } + + # Validate required vault config + if not self.vault_config['url'] or not self.vault_config['token']: + logging.warning( + "Vault enabled but missing VAULT_URL or VAULT_TOKEN, " + "falling back to file-only loading" + ) + self.vault_enabled = False + + def _load_from_vault(self) -> Dict[str, Any]: + """ + Load environment variables from HashiCorp Vault. + """ + if not self.vault_enabled: + return {} + + try: + # Initialize vault reader + self._init_vault_reader() + + if not self.vault_reader: + return {} + + # Load secrets for current environment + vault_data = self.vault_reader.list(path=self.vault_env) + + if isinstance(vault_data, dict): + logging.debug(f"Retrieved {len(vault_data)} secrets from vault path: {self.vault_env}") + return vault_data + else: + logging.debug(f"No secrets found in vault path: {self.vault_env}") + return {} + + except Exception as e: + logging.warning(f"Vault loading failed: {e}") + return {} + + def _init_vault_reader(self) -> None: + """ + Initialize vault reader with current configuration. + """ + try: + from ..readers.vault import VaultReader # noqa: F401 + + # Set environment variables for VaultReader + os.environ['VAULT_URL'] = self.vault_config['url'] + os.environ['VAULT_TOKEN'] = self.vault_config['token'] + os.environ['VAULT_MOUNT_POINT'] = self.vault_config['mount_point'] + os.environ['VAULT_VERSION'] = str(self.vault_config['version']) + if self._vault_env_override: + # Push the resolved vault env so VaultReader (and any other + # consumer reading os.environ) sees the file-level override. + os.environ['VAULT_ENV'] = self.vault_env + + self.vault_reader = VaultReader(env=self.vault_env) + logging.debug(f"Vault reader initialized for environment: {self.vault_env}") + + except Exception as e: + logging.warning( + f"Failed to initialize vault reader: {e}" + ) + self.vault_reader = None + self.vault_enabled = False + + def _load_additional_env_files(self) -> Dict[str, Any]: + """ + Load additional .env.* files (excluding the base .env file). + """ + additional_data = {} + additional_patterns = [p for p in self.file_patterns if p != ".env"] + + for file_pattern in additional_patterns: + file_path = self.env_path / file_pattern + + if file_path.exists() and file_path.is_file(): + try: + if file_path.stat().st_size == 0: + logging.warning(f"Empty environment file: {file_path}") + continue + + # Load file data + file_data = dotenv_values(file_path) + + # Also load into environment + load_dotenv(dotenv_path=file_path, override=self.override) + + # Merge data + additional_data |= file_data + + self.loaded_files.append(file_path) + logging.debug(f"Loaded additional env file: {file_path}") + + except Exception as e: + logging.warning(f"Error loading env file {file_path}: {e}") + + return additional_data + + def _update_environment_variables(self, data: Dict[str, Any]) -> None: + """ + Update os.environ with loaded data (respecting override setting). + """ + for key, value in data.items(): + if self.override or key not in os.environ: + os.environ[key] = str(value) + + def get_variable(self, key: str, default: Any = None) -> Any: + """ + Get a single variable with vault-first, then file fallback. + """ + # Check vault first + if self.vault_enabled and self.vault_reader: + try: + value = self.vault_reader.get(key) + if value is not None: + return value + except Exception as e: + logging.debug(f"Vault lookup failed for {key}: {e}") + + # Check environment + return os.getenv(key, default) + + def set_environment(self, new_env: str) -> None: + """ + Switch to a different environment at runtime. + """ + if new_env == self.env: + return # No change needed + + old_env = self.env + old_vault_env = self.vault_env + self.env = new_env + self.vault_env = os.getenv('VAULT_ENV') or new_env + + try: + # Update environment path + new_env_path = self.env_path.parent / new_env + if new_env_path.exists(): + self.env_path = new_env_path + else: + logging.warning(f"Environment directory does not exist: {new_env_path}") + + # Reload configuration + self.load_environment() + + logging.info(f"Environment switched from {old_env} to {new_env}") + + except Exception as e: + # Rollback on error + self.env = old_env + self.vault_env = old_vault_env + logging.error(f"Failed to switch to environment {new_env}: {e}") + raise + + def get_loaded_files(self) -> List[Path]: + """Return list of successfully loaded files.""" + return self.loaded_files + + def get_vault_status(self) -> Dict[str, Any]: + """Get vault connection and loading status.""" + return { + 'enabled': self.vault_enabled, + 'connected': self.vault_reader is not None, + 'config': {k: v for k, v in self.vault_config.items() if k != 'token'}, + 'secrets_loaded': len(self.vault_data), + 'environment': self.env, + 'vault_environment': self.vault_env + } + + def save_environment(self): + """Save current environment (not implemented for vault loader).""" + raise NotImplementedError( + "Saving environment not supported by vault loader. " + "Use vault CLI or API directly to manage secrets." + ) diff --git a/navconfig/logging/__init__.py b/navconfig/logging/__init__.py index 00e5e035..cb5c514f 100644 --- a/navconfig/logging/__init__.py +++ b/navconfig/logging/__init__.py @@ -4,17 +4,19 @@ Logging configuration. Supports: - - Mail Critical Handler - - Rotating File handler - - Console (debug) Handler - - Error File Handler - TODO: add a Telegram Critical Handler. + - Mail Critical Handler + - Rotating File handler + - Console (debug) Handler with Colors + - Error File Handler + TODO: add a Telegram Critical Handler. """ from pathlib import Path from logging.config import dictConfig +from logging import setLoggerClass from navconfig import config, BASE_DIR from .logger import logging, Logger, LOGLEVEL, ColoredFormatter +from .formatter import VerboseLogger ### Logging loglevel = LOGLEVEL @@ -32,11 +34,11 @@ "logging_disable_other", section="logging", fallback=False ) -### Logging Echo (standard output) +### Logging Echo (standard output) - FIXED: Default to True for colored output logging_echo = config.getboolean( "logging_echo", section="logging", - fallback=False + fallback=True # Changed from False to True ) ## Mail Alerts: @@ -62,26 +64,30 @@ except OSError: logging.exception("Error Creating Logging Directory", exc_info=True) -HANDLERS = config.get("handlers", section="logging", fallback=["StreamHandler"]) +HANDLERS = config.get("handlers", section="logging", fallback=["console"]) if isinstance(HANDLERS, str): HANDLERS = HANDLERS.split(",") +# FIXED: Improved logging configuration to avoid duplicates logging_config = dict( version=1, disable_existing_loggers=logging_disable_other, formatters={ - "console": {"()": ColoredFormatter, "datefmt": "%Y-%m-%d %H:%M:%S"}, + "console": { + "()": ColoredFormatter, + "datefmt": "%Y-%m-%d %H:%M:%S" + }, "default": { - "format": "[%(levelname)s] %(asctime)s %(name)s|%(lineno)d :: \ - %(message)s" + "format": "[%(levelname)s] %(asctime)s %(name)s|%(lineno)d :: %(message)s", # noqa + "datefmt": "%Y-%m-%d %H:%M:%S" }, "error": { - "format": "%(asctime)s-%(levelname)s-%(name)s-%(process)d::\ - %(module)s|%(lineno)s:: %(message)s" + "format": "%(asctime)s-%(levelname)s-%(name)s-%(process)d::" + "%(module)s|%(lineno)s:: %(message)s" }, "file": { - "format": "%(asctime)s: [%(levelname)s]: %(pathname)s: %(lineno)d:\ - \n%(message)s\n" + "format": "%(asctime)s: [%(levelname)s]: %(pathname)s: %(lineno)d:" + "\n%(message)s\n" }, }, handlers={ @@ -97,45 +103,64 @@ "stream": "ext://sys.stdout", "level": loglevel, }, + "null": { + "class": "logging.NullHandler", + }, }, loggers={ - APP_NAME: {"handlers": HANDLERS, "level": loglevel, "propagate": False}, + APP_NAME: { + "handlers": ["console"] if logging_echo else ["null"], + "level": loglevel, + "propagate": False + }, "__main__": { # if __name__ == "__main__" - "handlers": ["StreamHandler"], + "handlers": ["console"] if logging_echo else ["null"], "level": "INFO", "propagate": False, }, - "": {"handlers": ["console"], "level": "INFO", "propagate": False}, + "": { # root logger + "handlers": ["console"] if logging_echo else ["null"], + "level": "INFO", + "propagate": False + }, }, root={ - "handlers": ["StreamHandler"], + "handlers": ["console"] if logging_echo else ["null"], "level": loglevel, - "propagate": True, + "propagate": False, # IMPORTANT: Set to False to avoid duplicates }, ) -logging_config[APP_NAME] = { - "handlers": ["StreamHandler"], - "level": loglevel, - "propagate": True, -} - -if logging_echo is True: - logging_config["root"]["handlers"].append("console") - +# FIXED: Don't add console handler to root if already using console +# This was causing the duplicate messages +# Remove the problematic line: +# if logging_echo is True: +# logging_config["root"]["handlers"].append("console") +# Instead, handle file logging separately if logging_enable_filehandler is True: from .handlers.file import FileHandler lf = FileHandler(config=config, loglevel=loglevel, application=APP_NAME) - logging_config["handlers"]["RotatingFileHandler"] = lf.handler(path=LOG_DIR) + logging_config["handlers"]["RotatingFileHandler"] = lf.handler( + path=LOG_DIR + ) ## Also Error Handler: logging_config["handlers"]["ErrorFileHandler"] = lf.handler( path=LOG_DIR, loglevel=logging.ERROR ) - logging_config["root"]["handlers"].append("RotatingFileHandler") - logging_config["root"]["handlers"].append("ErrorFileHandler") + # Add file handlers to all relevant loggers + for logger_name in [APP_NAME, "__main__", ""]: + if logger_name in logging_config["loggers"]: + logging_config["loggers"][logger_name]["handlers"].extend([ + "RotatingFileHandler", "ErrorFileHandler" + ]) + + # Add to root as well + logging_config["root"]["handlers"].extend([ + "RotatingFileHandler", "ErrorFileHandler" + ]) if logging_enable_mailer is True: from .handlers.mail import MailerHandler @@ -146,42 +171,64 @@ application=APP_NAME ) logging_config["handlers"]["CriticalMailHandler"] = lm.handler() - logging_config["root"]["handlers"].append("CriticalMailHandler") + # Add mail handler to all relevant loggers + for logger_name in [APP_NAME, "__main__", ""]: + if logger_name in logging_config["loggers"]: + logging_config["loggers"][logger_name]["handlers"].append( + "CriticalMailHandler" + ) + + logging_config["root"]["handlers"].append("CriticalMailHandler") if logging_enable_logstash is True: logging.debug("Logstash configuration Enabled.") ### Importing Logstash Handler and returning Logging Config: - from .handlers.logstash import LogstashHandler + from .handlers.logstash import LogstashHandler, LOGSTASH_AVAILABLE - logstash_logging = config.get( - "logstash_logging", section="logging", fallback="INFO" - ) - - lh = LogstashHandler( - config=config, - loglevel=logstash_logging, - application=APP_NAME - ) - - if lh.logstash_available(): - logging_config["formatters"]["logstash"] = lh.formatter(path=BASE_DIR) - logging_config["handlers"]["LogstashHandler"] = lh.handler( - enable_localdb=config.getboolean("LOGSTASH_ENABLE_DB", fallback=True), - logdir=LOG_DIR, + if not LOGSTASH_AVAILABLE: + logging.warning( + "Logstash Logging is enabled but 'python-logstash-async' " + "is not installed. Skipping Logstash handler. " + "Hint: run 'pip install python-logstash-async'." ) - logging_config["root"]["handlers"].append("LogstashHandler") - logging_config[APP_NAME]["handlers"].append("LogstashHandler") else: - logging.error( - ( - "Logstash Logging is enabled but Logstash server " - "is Unavailable, please start Logstash Server." - ) + logstash_logging = config.get( + "logstash_logging", section="logging", fallback="INFO" ) + lh = LogstashHandler( + config=config, + loglevel=logstash_logging, + application=APP_NAME + ) + + if lh.logstash_available(): + logging_config["formatters"]["logstash"] = lh.formatter(path=BASE_DIR) + logging_config["handlers"]["LogstashHandler"] = lh.handler( + enable_localdb=config.getboolean( + "LOGSTASH_ENABLE_DB", + fallback=True + ), + logdir=LOG_DIR, + ) + + # Add logstash handler to all relevant loggers + for logger_name in [APP_NAME, "__main__", ""]: + if logger_name in logging_config["loggers"]: + logging_config["loggers"][logger_name]["handlers"].append( + "LogstashHandler" + ) + + logging_config["root"]["handlers"].append("LogstashHandler") + else: + logging.warning( + "Logstash Logging is enabled but Logstash server " + "is unavailable, please start Logstash Server." + ) ### Load Logging Configuration: +setLoggerClass(VerboseLogger) dictConfig(logging_config) ### configure basic logger for navconfig diff --git a/navconfig/logging/handlers/logstash.py b/navconfig/logging/handlers/logstash.py index e2b7c899..badbe92e 100644 --- a/navconfig/logging/handlers/logstash.py +++ b/navconfig/logging/handlers/logstash.py @@ -1,13 +1,14 @@ # basic configuration of Logstash import socket + +# Check if logstash_async is available +LOGSTASH_AVAILABLE = False try: import logstash_async # pylint: disable=W0611 -except ImportError as ex: - raise RuntimeError( - "NavConfig: Logstash Logging is enabled but Logstash async \ - dependency is not installed.\ - Hint: run 'pip install logstash_async'." - ) from ex + LOGSTASH_AVAILABLE = True +except ImportError: + pass + from .abstract import AbstractLog diff --git a/navconfig/logging/logger.pyx b/navconfig/logging/logger.pyx index 6cd1a4a5..0e008295 100644 --- a/navconfig/logging/logger.pyx +++ b/navconfig/logging/logger.pyx @@ -20,11 +20,12 @@ else: LOGLEVEL = logging.INFO -cdef class Logger(object): - __slots__ = ['name', '_debug', '_logger', 'logger'] +cdef class Logger: + __slots__ = ['name', '_debug', '_logger', 'logger', '_console_added'] cdef bool_t _debug cdef str name cdef object _logger + cdef bool_t _console_added def __init__(self, name: str = '', config: dict = None, **kwargs) -> None: ### Logging handler: @@ -34,6 +35,8 @@ cdef class Logger(object): else: self.name = name self._debug = kwargs.get('debug', DEBUG) + self._console_added = False + if config: dictConfig(config) setLoggerClass(VerboseLogger) @@ -41,13 +44,43 @@ cdef class Logger(object): self._logger.setLevel(LOGLEVEL) def addConsole(self) -> None: - # create a stream handler with sys.stdout as the stream + """Add console handler with colors if not already added.""" + if self._console_added: + return + + # Check if console handler already exists + for handler in self._logger.handlers: + if isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout: + # Console handler already exists, just update formatter if needed + if not isinstance(handler.formatter, ColoredFormatter): + handler.setFormatter(ColoredFormatter()) + self._console_added = True + return + + # No console handler found, add one ch = logging.StreamHandler(stream=sys.stdout) ch.setLevel(logging.DEBUG) ch.setFormatter(ColoredFormatter()) self._logger.addHandler(ch) + + # Prevent propagation to avoid duplicates + self._logger.propagate = False + ## also, changing the logLevel of root logger self._logger.setLevel(logging.DEBUG) + self._console_added = True + + def removeConsole(self) -> None: + """Remove console handlers.""" + handlers_to_remove = [] + for handler in self._logger.handlers: + if isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout: + handlers_to_remove.append(handler) + + for handler in handlers_to_remove: + self._logger.removeHandler(handler) + + self._console_added = False @property def logger(self): diff --git a/navconfig/project.py b/navconfig/project.py index c67c028b..0ae10981 100644 --- a/navconfig/project.py +++ b/navconfig/project.py @@ -1,7 +1,12 @@ import sys import os +import logging from pathlib import Path, PurePath +class ProjectDetectionError(Exception): + """Raised when project root cannot be properly detected.""" + pass + def get_environment() -> str: """Returns the environment selected.""" return os.getenv("ENV", "") @@ -9,53 +14,150 @@ def get_environment() -> str: def get_env_type() -> str: # environment type can be a file (.env) an encrypted file (crypt) - return os.getenv("ENV_TYPE", "file") + return os.getenv("ENV_TYPE", "vault") def is_virtualenv() -> bool: + """Check if we're running inside a virtual environment.""" return ( - os.getenv("PYENV_VIRTUAL_ENV") or os.getenv("PYENV_VIRTUAL_ENV") - or hasattr(sys, "real_prefix") - or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix) + os.getenv( + "PYENV_VIRTUAL_ENV" + ) or os.getenv( + "VIRTUAL_ENV" + ) or hasattr(sys, "real_prefix") or ( + hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix + ) ) def is_notebook() -> bool: + """Check if we're running in a Jupyter notebook.""" try: - from IPython import get_ipython # pylint: disable=import-outside-toplevel - if 'IPKernelApp' not in get_ipython().config: # pragma: no cover - return False - return True - except ImportError: - return False - except AttributeError: + from IPython import get_ipython # pylint: disable=import-outside-toplevel # noqa + return 'IPKernelApp' in get_ipython().config + except (ImportError, AttributeError): return False def find_project_root( base_file: str, - markers=("etc/config.ini", ".env") + markers=("etc/config.ini", ".env", "pyproject.toml", "setup.py", ".git") ) -> PurePath: + """ + Find project root by looking for common project markers. + + Args: + base_file: Starting file path for search + markers: Tuple of file/directory names that indicate project root + + Returns: + Path to project root + + Raises: + ProjectDetectionError: If no project root can be found + """ current_dir = Path(base_file).resolve().parent + # Check current directory first for marker in markers: - for parent in current_dir.parents: + if (current_dir / marker).exists(): + return current_dir + + # Then check parent directories + for parent in current_dir.parents: + for marker in markers: if (parent / marker).exists(): return parent - return current_dir + checked_dirs = [ + str(current_dir) + ] + [str(p) for p in current_dir.parents[:5]] + raise ProjectDetectionError( + f"Could not find project root. Searched for markers {markers} in:\n" + f" {chr(10).join(checked_dirs)}\n" + f"Please ensure you're running from within a project directory or set SITE_ROOT explicitly." # noqa + ) + +def validate_project_environment() -> tuple[str, list[str]]: + """ + Validate the current environment setup and provide diagnostic information. + + Returns: + Tuple of (status, warnings) where status is 'ok', 'warning', or 'error' + and warnings is a list of diagnostic messages. + """ + warnings = [] + status = 'ok' + + # Check virtual environment + if not is_virtualenv(): + warnings.append( + "No virtual environment detected. " + "Consider using a virtual environment " + "for better dependency isolation." + ) + status = 'warning' + + # Check working directory + cwd = Path.cwd() + if cwd.name in ('site-packages', 'dist-packages'): + warnings.append( + f"Current working directory appears to be a Python package directory: {cwd}. " # noqa + f"This might indicate you're not running from your project root." + ) + status = 'error' + + return status, warnings + +def project_root(base_file: str) -> tuple[PurePath, PurePath]: + """ + Determine project root and base directory with improved error handling. + + Args: + base_file: File path to start search from (usually __file__) + + Returns: + Tuple of (site_root, base_dir) + + Raises: + ProjectDetectionError: If project structure cannot be determined + """ + # First, validate the environment and log any warnings + env_status, warnings = validate_project_environment() + + # Log warnings + for warning in warnings: + logging.warning(f"NavConfig: {warning}") -def project_root(base_file: str) -> PurePath: site_root = os.getenv("SITE_ROOT", None) base_dir = os.getenv("BASE_DIR", None) + if site_root is not None: site_root = Path(site_root).resolve() + if not site_root.exists(): + raise ProjectDetectionError( + f"SITE_ROOT points to non-existent directory: {site_root}" + ) + elif is_virtualenv(): + site_root = Path(sys.prefix).resolve().parent else: - if is_virtualenv(): - site_root = Path(sys.prefix).resolve().parent - else: + try: site_root = find_project_root(base_file) + except ProjectDetectionError as e: + cwd = Path.cwd() + base_file_path = Path(base_file).resolve() - if base_dir is None: - base_dir = site_root - else: - base_dir = Path(base_dir).resolve() + error_msg = ( + f"NavConfig could not determine project root:\n\n" + f"Current working directory: {cwd}\n" + f"NavConfig location: {base_file_path.parent}\n" + f"Virtual environment: {'Yes' if is_virtualenv() else 'No'}\n\n" # noqa + f"To fix this issue, you can:\n" + f"1. Set SITE_ROOT environment variable: export SITE_ROOT=/path/to/your/project\n" # noqa + f"2. Run from your project directory (containing pyproject.toml, setup.py, or .git)\n" # noqa + f"3. Activate a virtual environment in your project directory\n" # noqa + f"4. Create an 'etc' directory with config.ini in your project root\n\n" # noqa + f"Original error: {e}" + ) + raise ProjectDetectionError(error_msg) from e + + base_dir = site_root if base_dir is None else Path(base_dir).resolve() return site_root, base_dir diff --git a/navconfig/readers/memcache.py b/navconfig/readers/memcache.py deleted file mode 100644 index a1f4346c..00000000 --- a/navconfig/readers/memcache.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -import logging -import pylibmc -from ..exceptions import ReaderNotSet -from .abstract import AbstractReader - - -class mcache(AbstractReader): - """ - Basic Connector for Memcached. - Future-proof. - """ - - _args = {"tcp_nodelay": True, "ketama": True} - - def __init__(self) -> None: - host = os.getenv("MEMCACHE_HOST", "localhost") - port = int(os.getenv("MEMCACHE_PORT", "11211")) - try: - self._server = [f"{host}:{port}"] - self._memcached = pylibmc.Client( - self._server, binary=True, behaviors=self._args - ) - # Set a temporary value - self._memcached.set("ping", "pong", time=1) - except pylibmc.ConnectionError as err: - self.enabled = False - raise ReaderNotSet( - f"Unable to Connect: {err} :: Memcached Disabled ::" - ) from err - except Exception as err: # pylint: disable=W0703 - self.enabled = False - logging.exception(err, stack_info=True) - - def get(self, key, default=None): - if self.enabled is False: - raise ReaderNotSet() - try: - result = self._memcached.get(bytes(key, "utf-8"), default) - if result: - return result.decode("utf-8") - else: - return None - except Exception as err: - raise Exception(f"Memcache Get Error: {err!s}") from err - - def exists(self, key: str) -> bool: - if self.enabled is False: - raise ReaderNotSet() - try: - result = self._memcached.get(bytes(key, "utf-8")) - if result: - return True - else: - return False - except Exception: # pylint: disable=W0703 - return False - - def set(self, key, value, timeout: int = None): - if self.enabled is False: - raise ReaderNotSet() - try: - if timeout: - return self._memcached.set( - bytes(key, "utf-8"), bytes(value, "utf-8"), time=timeout - ) - else: - return self._memcached.set( - bytes(key, "utf-8"), - bytes(value, "utf-8") - ) - except Exception as err: - raise Exception(f"Memcache Set Error: {err!s}") from err - - def multi_get(self, *keys): - try: - return self._memcached.multi_get( - *[bytes(v, "utf-8") for v in keys] - ) - except Exception as err: - raise Exception(f"Memcache Multi Error: {err!s}") from err - - def delete(self, key): - try: - self._memcached.delete(bytes(key, "utf-8")) - except Exception as err: - raise Exception(f"Memcache Delete Error: {err!s}") from err - - def close(self): - try: - self._memcached.disconnect_all() - except Exception as err: # pylint: disable=W0703 - logging.exception(err, stack_info=False) diff --git a/navconfig/readers/vault.py b/navconfig/readers/vault.py index 81de50c2..3558dc2b 100644 --- a/navconfig/readers/vault.py +++ b/navconfig/readers/vault.py @@ -15,27 +15,31 @@ class VaultReader(AbstractReader): """VaultReader. Description: Class for HashiCorp Vault Reader. + + The secret path segment under the mount point is resolved as: + ``VAULT_ENV`` (if set and non-empty) > ``env`` argument > ``ENV``. + This allows pointing to a custom vault environment without + changing the ``ENV`` used by the rest of the application. """ def __init__(self, env: str = None) -> None: url = os.getenv( - "VAULT_HVAC_URL", + "VAULT_URL", "http://localhost:8200" ) - token = os.getenv("VAULT_HVAC_TOKEN") - self.version = int(os.getenv("VAULT_HVAC_VERSION", 1)) - self._mount = os.getenv("VAULT_HVAC_MOUNT_POINT", "navigator") - self._env = env - if not self._env: - self._env = os.getenv("ENV", "") + token = os.getenv("VAULT_TOKEN") + self.version = int(os.getenv("VAULT_VERSION", 2)) + self._mount = os.getenv("VAULT_MOUNT_POINT", "navigator") + self._env = os.getenv("VAULT_ENV") or env or os.getenv("ENV", "") if not token: - raise ValueError("VAULT_HVAC_TOKEN is not set") + raise ValueError("VAULT_TOKEN is not set") try: self.client = hvac.Client(url=url, token=token) self.open() except Exception as err: # pylint: disable=W0703 self.enabled = False - raise ReaderNotSet(f"Vault Error: {err}") + raise ReaderNotSet(f"Vault Error: {err}") from err + self.enabled = True def open(self) -> bool: if self.client.is_authenticated(): @@ -67,7 +71,6 @@ def get( secret_key = key try: if self.version == 1: - print('GET > ', secret_path, self._mount) response = self.client.secrets.kv.v1.read_secret( path=secret_path, mount_point=self._mount ) @@ -79,6 +82,9 @@ def get( data = response["data"]["data"] except hvac.exceptions.InvalidPath: return default + except Exception as e: + logging.debug(f"Vault get error for {key}: {e}") + return default if secret_key == "*": return data @@ -117,6 +123,10 @@ def exists( data = response["data"]["data"] except hvac.exceptions.InvalidPath: return False + except Exception as e: + logging.debug(f"Vault exists error for {key}: {e}") + return False + if secret_key == "*": return True return secret_key in data @@ -229,34 +239,81 @@ def delete(self, key: str, secret_path: str = None) -> bool: ) return False - def list(self, path: str = None, filter: str = None) -> list: + def list(self, path: str = None, filter: str = None) -> dict: + """ + List and return all secrets from the specified path. + + This method now returns the actual secret data (key-value pairs) + instead of just listing secret names, making it compatible with + the unified vault loader. + """ if self.enabled is False: raise ReaderNotSet() - data = {} - secret_path = path if path else self._env + + secret_path = path or self._env + try: if self.version == 1: + # For KV v1, read the secret directly response = self.client.secrets.kv.v1.read_secret( path=secret_path, mount_point=self._mount ) data = response["data"] + elif self.version == 2: - response = self.client.secrets.kv.v2.list_secrets( + # For KV v2, read the secret version + response = self.client.secrets.kv.v2.read_secret_version( path=secret_path, mount_point=self._mount ) data = response["data"]["data"] else: raise ValueError("Invalid KV version specified") - result = data + # Apply filter if specified if filter: - result = { - k: v for k, v in data.items() if k.startswith(filter) + data = { + k: v for k, v in data.items() + if k.startswith(filter) } - return result - except hvac.exceptions.InvalidPath as ex: + logging.debug(f"Retrieved {len(data)} secrets from vault path '{secret_path}'") + return data + + except hvac.exceptions.InvalidPath: + logging.debug(f"No secrets found at vault path '{secret_path}'") return {} except Exception as e: - print(f"Error listing keys at path '{secret_path}': {e}") + logging.warning(f"Error listing secrets at path '{secret_path}': {e}") return {} + + def list_paths(self, path: str = None) -> list: + """ + List secret paths (directories/keys) at the specified path. + + This is separate from list() which returns actual secret data. + Useful for discovering what secret paths are available. + """ + if self.enabled is False: + raise ReaderNotSet() + + secret_path = path or "" + + try: + if self.version == 1: + # KV v1 doesn't have a list operation, return empty list + logging.debug("KV v1 doesn't support path listing") + return [] + + elif self.version == 2: + # For KV v2, use list_secrets to get paths + response = self.client.secrets.kv.v2.list_secrets( + path=secret_path, mount_point=self._mount + ) + return response["data"]["keys"] + + except hvac.exceptions.InvalidPath: + logging.debug(f"No paths found at '{secret_path}'") + return [] + except Exception as e: + logging.debug(f"Error listing paths at '{secret_path}': {e}") + return [] diff --git a/navconfig/samples/.env.api.sample b/navconfig/samples/.env.api.sample new file mode 100644 index 00000000..99eca438 --- /dev/null +++ b/navconfig/samples/.env.api.sample @@ -0,0 +1,15 @@ +# NavConfig -- API configuration. +# Non-secret directives for the HTTP layer. + +# Interface and port the application binds to. +API_HOST=0.0.0.0 +API_PORT=5000 + +# Request timeout, in seconds. +API_TIMEOUT=60 + +# Comma-separated list, read back with config.getlist("ALLOWED_HOSTS"). +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Base URL of the API, used to build absolute links. +API_BASE_URL=http://localhost:5000 diff --git a/navconfig/samples/.env.cache.sample b/navconfig/samples/.env.cache.sample new file mode 100644 index 00000000..85ececea --- /dev/null +++ b/navconfig/samples/.env.cache.sample @@ -0,0 +1,10 @@ +# NavConfig -- Cache configuration. +# NavConfig can read configuration values from Redis as an external reader. + +# Cache backend: "redis", or empty to disable the cache reader entirely. +CACHE_BACKEND= + +# -- Redis -- +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=1 diff --git a/navconfig/samples/.env.databases.sample b/navconfig/samples/.env.databases.sample new file mode 100644 index 00000000..2ea82f8f --- /dev/null +++ b/navconfig/samples/.env.databases.sample @@ -0,0 +1,17 @@ +# NavConfig -- Database configuration. +# Connection settings for the databases used by the application. +# Credentials belong in HashiCorp Vault whenever possible. + +# -- PostgreSQL -- +DBHOST=localhost +DBPORT=5432 +DBNAME=navigator +DBUSER= +DBPWD= + +# Optional full DSN. When set it takes precedence over the values above. +# DATABASE_URL=postgresql://user:password@localhost:5432/navigator + +# Connection pool sizing. +DB_MIN_SIZE=2 +DB_MAX_SIZE=10 diff --git a/navconfig/samples/.env.local.sample b/navconfig/samples/.env.local.sample new file mode 100644 index 00000000..6aea93f4 --- /dev/null +++ b/navconfig/samples/.env.local.sample @@ -0,0 +1,6 @@ +# NavConfig -- Local overrides. +# This file is loaded last, so anything defined here wins over every other +# .env file of the environment. Keep it out of version control. + +# DEBUG=true +# LOGLEVEL=DEBUG diff --git a/navconfig/samples/.env.resources.sample b/navconfig/samples/.env.resources.sample new file mode 100644 index 00000000..1981b80f --- /dev/null +++ b/navconfig/samples/.env.resources.sample @@ -0,0 +1,14 @@ +# NavConfig -- Resource configuration. +# Loaded after ".env". Keep non-secret, resource-level directives here. + +# Directory served for static assets (relative to the project root). +STATIC_DIR=static + +# Directory where user uploads are stored. +UPLOAD_DIR=uploads + +# Directory used for temporary files. +TEMP_DIR=/tmp + +# Directory where templates are read from. +TEMPLATE_DIR=templates diff --git a/navconfig/samples/.env.sample b/navconfig/samples/.env.sample new file mode 100644 index 00000000..37bf3578 --- /dev/null +++ b/navconfig/samples/.env.sample @@ -0,0 +1,20 @@ +# NavConfig Environment File +# Copy this file to env/<environment>/.env and adjust values. + +# Environment name (dev, staging, prod) +ENV=dev + +# Debug mode (true/false) +DEBUG=true + +# Path to the INI configuration file (relative to project root) +CONFIG_FILE=etc/config.ini + +# Application name +APP_NAME=MyApp + +# Production mode (true/false) +PRODUCTION=false + +# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +LOGLEVEL=DEBUG diff --git a/navconfig/samples/.env.vault.sample b/navconfig/samples/.env.vault.sample new file mode 100644 index 00000000..5fd2726c --- /dev/null +++ b/navconfig/samples/.env.vault.sample @@ -0,0 +1,28 @@ +# --- navconfig:vault --- managed by `kardex vault create`, do not rename +# NavConfig -- HashiCorp Vault configuration. +# NavConfig reads these directives from the base "env/<environment>/.env" +# file, connects to Vault and merges the secrets stored under +# <VAULT_MOUNT_POINT>/<environment>/ on top of the file-based values. + +# Enable the Vault loader (true/false). +VAULT_ENABLED=true + +# Address of the Vault server. +VAULT_URL=http://localhost:8200 + +# Token used to authenticate against Vault. +VAULT_TOKEN= + +# KV mount point holding the application secrets. +VAULT_MOUNT_POINT=navigator + +# KV engine version (1 or 2). +VAULT_VERSION=2 + +# Optional: read secrets from a different path segment than ENV. +# Only Vault lookups are affected; file loading keeps using ENV. +# VAULT_ENV= + +# Optional: when true, .env files override the values coming from Vault. +# NAVCONFIG_FILE_OVERRIDE_ENABLED=false +# --- navconfig:vault:end --- diff --git a/navconfig/samples/__init__.py b/navconfig/samples/__init__.py new file mode 100644 index 00000000..6e5f33ac --- /dev/null +++ b/navconfig/samples/__init__.py @@ -0,0 +1,12 @@ +"""Sample configuration files for bootstrapping NavConfig projects.""" +from pathlib import Path + +SAMPLES_DIR = Path(__file__).parent + + +def get_sample_path(name: str) -> Path: + """Return the absolute path to a sample file by name.""" + path = SAMPLES_DIR / name + if not path.exists(): + raise FileNotFoundError(f"Sample file not found: {name}") + return path diff --git a/navconfig/samples/config.ini.sample b/navconfig/samples/config.ini.sample new file mode 100644 index 00000000..4df3edef --- /dev/null +++ b/navconfig/samples/config.ini.sample @@ -0,0 +1,42 @@ +[navconfig] +# NavConfig core settings. +# Application/project name used by NavConfig. +APP_NAME = MyApp +# Default environment used when ENV is not set. +ENV = dev + +[general] +# General application settings. +DEBUG = true +APP_NAME = MyApp + +[logging] +# Logging configuration. +# Log directory (relative or absolute path). +logdir = logs +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL +loglevel = DEBUG +# Echo log output to the console (true/false). +logging_echo = true +# Enable rotating file handler (true/false). +filehandler_enabled = false +# Enable Logstash handler (true/false). +logstash_enabled = false +# Logstash server host. +# logging_host = localhost +# Logstash server TCP port. +# logging_port = 5044 +# Minimum level forwarded to Logstash. +# logstash_logging = INFO +# Seconds the Logstash handler waits when flushing its queue. +# logstash_flush_timeout = 10 +# Enable email alerts on CRITICAL errors (true/false). +mailer_enabled = false +# Disable loggers from third-party libraries (true/false). +logging_disable_other = false +# Comma-separated list of handlers to use. +handlers = console + +[temp] +# Temporary file directory. +temp_path = /tmp diff --git a/navconfig/utils/__init__.py b/navconfig/utils/__init__.py index e69de29b..369fd761 100644 --- a/navconfig/utils/__init__.py +++ b/navconfig/utils/__init__.py @@ -0,0 +1,16 @@ +from .uvl import install_uvloop +from .functions import strtobool +from .types import Singleton +from .json import json_encoder, json_decoder, JSONContent +from .settings import ensure_settings_priority + + +__all__ = ( + "install_uvloop", + "strtobool", + "Singleton", + "json_encoder", + "json_decoder", + "ensure_settings_priority", + "JSONContent", +) diff --git a/navconfig/utils/settings.py b/navconfig/utils/settings.py new file mode 100644 index 00000000..c0d6a4ca --- /dev/null +++ b/navconfig/utils/settings.py @@ -0,0 +1,69 @@ +import sys +from pathlib import Path + + +def ensure_settings_priority(settings_dir: Path) -> bool: + """ + Ensure that the settings directory is at the beginning of sys.path. + + Args: + settings_dir (Path): Path to the project's settings directory + """ + # Check if the settings directory exists + if not settings_dir.exists(): + return False + + settings_path = str(settings_dir) + + # Remove project settings path from sys.path if it's already there + if settings_path in sys.path: + sys.path.remove(settings_path) + + # Add project settings Path + sys.path.append(settings_path) + + # Also check for conflicts and silently adjust sys.path: + has_conflict, _conflict_path = check_settings_conflicts(settings_dir) + if has_conflict: + # Silently adjust sys.path to prioritize the project's settings. + # Insert at position 0 so the project settings take precedence. + if settings_path in sys.path: + sys.path.remove(settings_path) + sys.path.insert(0, settings_path) + + return True + +def check_settings_conflicts(settings_dir: Path) -> tuple: + """Check for conflicts with other settings modules in sys.path. + + Args: + settings_dir (Path): Path to the project's settings directory. + + Returns: + tuple: (has_conflict, conflicting_path) + """ + # Check if there are other settings modules in sys.path that might conflict + settings_dir = settings_dir.resolve() # Resolve to absolute path + + for path in sys.path: + path_obj = Path(path).resolve() # Resolve to absolute path + + # Skip if this is the project's settings directory we just added + if path_obj == settings_dir: + continue + + # Check for a settings package in this path + if path_obj.name == "settings" and path_obj.is_dir() and (path_obj / "__init__.py").exists(): + return True, str(path_obj) + + # Check for settings package as a subdirectory + settings_subdir = path_obj / "settings" + if settings_subdir.exists() and settings_subdir != settings_dir and settings_subdir.is_dir() and (settings_subdir / "__init__.py").exists(): + return True, str(settings_subdir) + + # Check for settings.py file directly in the path + settings_file = path_obj / "settings.py" + if settings_file.exists() and settings_file.parent != settings_dir: + return True, str(settings_file) + + return False, None diff --git a/navconfig/utils/uvl.py b/navconfig/utils/uvl.py new file mode 100644 index 00000000..b5458fee --- /dev/null +++ b/navconfig/utils/uvl.py @@ -0,0 +1,14 @@ +import sys + + +def install_uvloop() -> None: + """Lazily install uvloop when available on a supported platform.""" + if sys.platform == "win32": + return + + try: + import uvloop # noqa # pylint: disable=import-outside-toplevel + except ImportError: + return + + uvloop.install() diff --git a/navconfig/version.py b/navconfig/version.py index c075e188..cef5b252 100644 --- a/navconfig/version.py +++ b/navconfig/version.py @@ -5,7 +5,8 @@ "Configuration tool for all Navigator Services " "Tool for accessing Config info from different sources." ) -__version__ = "1.7.7" +__version__ = "2.5.2" +__copyright__ = "Copyright (c) 2020-2024 Jesus Lara" __author__ = "Jesus Lara" __author_email__ = "jesuslarag@gmail.com" __license__ = "MIT" diff --git a/pyproject.toml b/pyproject.toml index 2b9b3e46..cdfed194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,52 +1,255 @@ [build-system] requires = [ - 'setuptools==74.0.0', - 'Cython==3.0.11', - 'wheel==0.44.0', + "setuptools>=74.0.0", + "Cython>=3.1.4", + "wheel>=0.44.0", ] - build-backend = "setuptools.build_meta" -[metadata] +[project] name = "navconfig" description = "Universal Configuration Management for Python Projects." -author = "Jesus Lara Gimenez" -author_email = "jesuslarag@gmail.com" -url = "https://github.com/phenobarbital/navconfig" -license = "MIT" +authors = [ + {name = "Jesus Lara Gimenez", email = "jesuslarag@gmail.com"}, +] +maintainers = [ + {name = "Jesus Lara", email = "jesuslara@phenobarbital.info"}, +] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.10.1" classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python", - "Typing :: Typed", - "Environment :: Web Environment", - "Framework :: AsyncIO", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Software Development :: Build Tools", - "License :: OSI Approved :: MIT License", -] -long_description = "file:README.md" -long_description_content_type = "text/markdown" + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python", + "Typing :: Typed", + "Environment :: Web Environment", + "Framework :: AsyncIO", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: MIT License", +] keywords = ["aiohttp", "settings", "configuration", "conf", "configuration-management"] -python_requires = ">=3.9.13" +dependencies = [ + "Cython>=3.1.4", + "python-dotenv>=1.2.2", + "configparser>=6.0.0", + "python-dateutil>=2.8.2", + "objectpath>=0.6.1", + "iso8601>=2.1.0", + "pycparser>=2.21", + "orjson>=3.11.6", + "pycryptodomex>=3.20.0", + "cryptography>=46.0.7", + "aiofiles>=23.2.1,<=24.1.0", + "jsonpickle>=1.2,<=3.0.2", + "pytomlpp>=1.0.13", + "PyYAML>=6.0", + "requests>=2.33.0", + "hvac>=2.3.0", +] +dynamic = ["version"] + +[project.scripts] +kardex = "navconfig.cli:main" + +[project.urls] +Homepage = "https://github.com/phenobarbital/navconfig" +Repository = "https://github.com/phenobarbital/navconfig" +Documentation = "https://phenobarbital.github.io/navconfig/" +"Bug Tracker" = "https://github.com/phenobarbital/navconfig/issues" +Funding = "https://paypal.me/phenobarbital" +"Say Thanks!" = "https://saythanks.io/to/phenobarbital" + +[project.optional-dependencies] +uvloop = [ + "uvloop>=0.22.1; sys_platform != 'win32'" +] +logstash = [ + "python-logstash-async>=2.7.2,<=3.0.0", +] +redis = [ + "redis~=5.2.1", +] +hvac = [ + "hvac>=2.3.0" +] +# Build dependencies (needed for Cython compilation) +build = [ + "setuptools>=74.0.0", + "Cython>=3.1.4", + "wheel>=0.44.0", +] +# Development dependencies +dev = [ + "pytest>=9.0.3", + "pytest-cov>=4.0.0", + "pytest-asyncio>=0.21.0", + "ruff>=0.1.0", + "black>=26.3.1", + "mypy>=1.0.0", + "pre-commit>=3.0.0", +] +# Convenience groups +default = [ + "pytomlpp>=1.0.13", + "redis~=5.2.1", + "python-logstash-async>=2.7.2", + "PyYAML>=6.0", + "hvac>=2.3.0" +] +all = [ + "pytomlpp>=1.0.13", + "redis~=5.2.1", + "python-logstash-async>=2.7.2", + "PyYAML>=6.0", + "hvac>=2.3.0", + "uvloop>=0.22.1; sys_platform != 'win32'" +] + +[tool.setuptools] +zip-safe = false +include-package-data = true +[tool.setuptools.dynamic] +version = {attr = "navconfig.version.__version__"} +[tool.setuptools.packages.find] +include = ["navconfig*"] +exclude = ["tests*", "docs*", "examples*"] + +[tool.setuptools.package-data] +navconfig = [ + "py.typed", + "*.pyi", + "**/*.pyi", + "*.py", + "**/*.py", + "*.yaml", + "*.yml", + "*.toml", + "*.ini", + "*.sample", + "**/*.sample", +] + +# Testing configuration [tool.pytest.ini_options] addopts = [ - "--strict-config", - "--strict-markers", + "--strict-config", + "--strict-markers", + "--tb=short", + "-v" ] filterwarnings = [ "error", - 'ignore:The loop argument is deprecated since Python 3\.8, and scheduled for removal in Python 3\.10:DeprecationWarning:asyncio', + "ignore::DeprecationWarning", +] +asyncio_default_fixture_loop_scope = "session" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +# Coverage configuration +[tool.coverage.run] +source = ["navconfig"] +omit = [ + "*/tests/*", + "*/test_*", + "setup.py", + "*/venv/*", + "*/.venv/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +# Linting and formatting +[tool.ruff] +target-version = "py310" +line-length = 88 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by formatter + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] # Allow unused imports in __init__.py + +[tool.ruff.isort] +known-first-party = ["navconfig"] + +# Black formatting (if you want to use it alongside ruff) +[tool.black] +target-version = ["py310", "py311", "py312"] +line-length = 88 +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +# MyPy type checking +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ + "Cython.*", + "uvloop.*", + "redis.*", + "hvac.*", + "pytomlpp.*", ] +ignore_missing_imports = true -[navconfig] +# NavConfig specific configuration (your own tool config) +[tool.navconfig] keywords = ["aiohttp", "settings", "configuration", "conf", "configuration-management"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 01dbd3be..00000000 --- a/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -# pytest.ini -[pytest] -filterwarnings = - ignore::DeprecationWarning -asyncio_default_fixture_loop_scope = session diff --git a/run.py b/run.py new file mode 100644 index 00000000..85c6cfb5 --- /dev/null +++ b/run.py @@ -0,0 +1,26 @@ +from aiohttp import web +from navconfig import config + +app = web.Application() + +print('ENV > ', config.get('ENV')) + +async def ping(request): + return web.Response(text='pong') + +async def home(request): + return web.Response(text='Hello, World!') + + +# Add routes for the home and ping endpoints +app.router.add_get('/', home) +app.router.add_get('/ping', ping) + + +# Run the application +if __name__ == '__main__': + web.run_app( + app, + host=config.get('HOST', '0.0.0.0'), + port=config.getint('PORT', fallback=9090) + ) diff --git a/scripts/analyze_sdd_coder_usage.py b/scripts/analyze_sdd_coder_usage.py new file mode 100644 index 00000000..56572e7f --- /dev/null +++ b/scripts/analyze_sdd_coder_usage.py @@ -0,0 +1,281 @@ +"""Turn sdd-coder telemetry into a recommended token_budget (FEAT-554). + +Reads the append-only JSONL dataset written by +`parrot.flows.dev_loop.sdd_coder.telemetry`, joins attempt rows to their +outcome events, and reports consumption percentiles plus estimation error per +(seat, task-size bucket). + +Usage: + python scripts/analyze_sdd_coder_usage.py --root artifacts/logs/sdd-coder-usage +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Tuple + +import pandas as pd + +MIN_SAMPLES: int = 12 +"""Merged attempts required before a segment gets a recommended ceiling. + +Below this the percentiles are printed and marked unreliable, but no ceiling is +suggested: a p95 over five samples is noise wearing the costume of a number. +""" + +SIZE_BUCKETS: Tuple[Tuple[str, int, int], ...] = (("1-2", 1, 2), ("3-4", 3, 4), ("5+", 5, 10**6)) +RESERVE_MULTIPLIER: int = 2 +"""Recommended absolute final_answer_reserve = RESERVE_MULTIPLIER * max_tokens (spec §7).""" + + +def load_rows(root: Path) -> pd.DataFrame: + """Glob `<root>/*.jsonl`, parse, and join attempt rows to outcome events. + + Three hazards, all silent if unhandled: + + * the join key is `attempt_uid` — `(feature_id, task_id, attempt)` recurs + across jobs because attempt numbering restarts at 1 (spec §10 R3); + * several outcome rows per attempt are EXPECTED (conflict then re-merge, + engine.py:407) — the highest `event_seq` is effective (§10 R4); + * an outcome with no attempt row is an incomplete pair: report and exclude, + never count as zero tokens. + + A duplicate `attempt_uid` among ATTEMPT rows is a bug, not a retry: raise. + """ + root = Path(root) + + # Read all JSONL files from root + attempt_rows = [] + outcome_rows = [] + + for jsonl_file in root.glob("*.jsonl"): + with open(jsonl_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + if row.get("kind") == "attempt": + attempt_rows.append(row) + elif row.get("kind") == "outcome": + outcome_rows.append(row) + except (json.JSONDecodeError, ValueError): + # Skip malformed lines + continue + + if not attempt_rows: + # Return empty dataframe with expected columns + return pd.DataFrame() + + # Convert to DataFrames + df_attempts = pd.DataFrame(attempt_rows) + df_outcomes = pd.DataFrame(outcome_rows) if outcome_rows else pd.DataFrame() + + # Check for duplicate attempt_uids in attempts + if not df_attempts.empty: + duplicates = df_attempts[df_attempts.duplicated(subset=["attempt_uid"], keep=False)] + if not duplicates.empty: + dup_uid = duplicates["attempt_uid"].iloc[0] + raise ValueError(f"Duplicate attempt_uid in attempt rows: {dup_uid}") + + # Deduplicate outcomes: keep the one with the highest event_seq per attempt_uid + if not df_outcomes.empty: + df_outcomes = df_outcomes.sort_values("event_seq").drop_duplicates(subset=["attempt_uid"], keep="last") + + # Left join outcomes onto attempts + merged = df_attempts.copy() + if not df_outcomes.empty: + merged = df_attempts.merge(df_outcomes[["attempt_uid", "outcome"]], on="attempt_uid", how="left") + else: + merged["outcome"] = None + + # Report orphaned outcomes (outcomes with no matching attempt) + if not df_outcomes.empty: + outcome_uids = set(df_outcomes["attempt_uid"]) + attempt_uids = set(df_attempts["attempt_uid"]) + orphaned = outcome_uids - attempt_uids + if orphaned: + print(f"WARNING: {len(orphaned)} outcome row(s) with no matching attempt excluded") + + # Filter to only keep rows that have an outcome (complete pairs) + # Actually, per spec §10 R5, we should keep consumption samples even if no outcome + # But per the task, we exclude orphaned outcomes. So we keep all attempts + # (whether or not they have an outcome), but we filter outcomes to only those + # that have a matching attempt. + + return merged + + +def bucket_of(declared_files: Any, known: Any) -> str: + """Return the task-size bucket label, or "unknown" when the count is absent.""" + if not known or declared_files is None: + return "unknown" + + declared_files = int(declared_files) + + for label, min_val, max_val in SIZE_BUCKETS: + if min_val <= declared_files <= max_val: + return label + + # Fallback if somehow out of range + return "unknown" + + +def recommend(df: pd.DataFrame, *, max_tokens: int) -> pd.DataFrame: + """Per (seat_label, bucket): sample counts, percentiles, error, ceiling. + + Reports TWO sample counts on purpose: `n_consumption` (all merged rows) and + `n_calibration` (rows with `calibration_eligible`). An attempt whose + provider skipped a round's usage is a valid consumption sample and an + invalid calibration sample (spec §10 R5), and merging the two would bias the + margin the ceiling is built from. + + Estimation error uses `ledger_settled_estimate_input_tokens - + ledger_input_tokens` — both sides describe the SAME settled requests. + Released estimates are excluded by construction upstream. + + A segment with fewer than MIN_SAMPLES merged attempts gets percentiles + marked unreliable and NO suggested ceiling. Each ceiling is printed with the + absolute final_answer_reserve that should accompany it. + """ + if df.empty: + return pd.DataFrame() + + # Add bucket column + df["bucket"] = df.apply(lambda row: bucket_of(row.get("declared_files"), row.get("declared_files_known")), axis=1) + + # Compute total tokens + def total_tokens(row): + # Use ledger if available, else provider. `row.get(...)` on a pandas + # Series returns `NaN` (a float), not `None`, for a missing value in + # a mixed-null column — `NaN is not None` is True in Python, so an + # `is not None` check here would treat a missing ledger field as + # present and return `NaN + NaN = NaN`, which then gets silently + # dropped by `.notna()` below instead of falling through to the + # provider-total fallback. This is exactly how the `gemini` seat + # (provider-totals-only by design, AC-15) would vanish from every + # percentile instead of contributing its baseline data. + ledger_in = row.get("ledger_input_tokens") + ledger_out = row.get("ledger_output_tokens") + + if pd.notna(ledger_in) and pd.notna(ledger_out): + return ledger_in + ledger_out + + provider_in = row.get("provider_input_tokens") + provider_out = row.get("provider_output_tokens") + + if pd.notna(provider_in) and pd.notna(provider_out): + return provider_in + provider_out + + return None + + df["total_tokens"] = df.apply(total_tokens, axis=1) + + # Compute estimation error for calibration-eligible rows + def estimation_error(row): + if not row.get("calibration_eligible"): + return None + + settled = row.get("ledger_settled_estimate_input_tokens") + actual = row.get("ledger_input_tokens") + + if pd.notna(settled) and pd.notna(actual) and actual != 0: + return (settled - actual) / actual + + return None + + df["estimation_error"] = df.apply(estimation_error, axis=1) + + # Group by (seat_label, bucket) + grouped = df.groupby(["seat_label", "bucket"]) + + results = [] + + for (seat, bucket), group in grouped: + # AC-13 gates the recommendation on MERGED attempts specifically + # ("withholds a recommendation below 12 merged attempts") — a + # failed/fidelity_violation/merge_conflict row still burned tokens + # (it stays a valid consumption sample above, per calibration + # eligibility separately), but it must not count toward the + # merged-sample threshold that makes a ceiling trustworthy. + group = group[group["outcome"] == "merged"] + if group.empty: + continue + + # Sample counts + n_consumption = len(group) + n_calibration = len(group[group["calibration_eligible"]]) + + # Filter out rows with None total_tokens for percentiles + tokens_valid = group[group["total_tokens"].notna()]["total_tokens"].dropna() + + if len(tokens_valid) == 0: + continue + + # Percentiles + p50 = tokens_valid.quantile(0.50) + p95 = tokens_valid.quantile(0.95) + p99 = tokens_valid.quantile(0.99) + + # Estimation error (median over calibration-eligible) + errors = group[group["calibration_eligible"]]["estimation_error"].dropna() + median_error = errors.median() if len(errors) > 0 else 0.0 + + # Ceiling recommendation + if bucket == "unknown": + # Unknown buckets never get a ceiling recommendation + ceiling_str = "N/A" + reserve_str = "N/A" + reliable = "unknown bucket (no recommendation)" + elif n_consumption >= MIN_SAMPLES: + ceiling = p95 * (1 + median_error) + reserve = RESERVE_MULTIPLIER * max_tokens + ceiling_str = f"{ceiling:.0f}" + reserve_str = f"{reserve:.0f}" + reliable = "OK" + else: + ceiling = None + reserve = None + ceiling_str = "N/A" + reserve_str = "N/A" + reliable = f"unreliable ({n_consumption} samples)" + + results.append( + { + "seat": seat, + "bucket": bucket, + "n_consumption": n_consumption, + "n_calibration": n_calibration, + "p50": f"{p50:.0f}", + "p95": f"{p95:.0f}", + "p99": f"{p99:.0f}", + "median_error": f"{median_error * 100:+.1f}%", + "ceiling": ceiling_str, + "final_answer_reserve": reserve_str, + "status": reliable, + } + ) + + return pd.DataFrame(results) + + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True, help="telemetry root directory") + parser.add_argument( + "--max-tokens", type=int, default=8192, help="profile max_tokens, for the reserve recommendation" + ) + args = parser.parse_args() + + frame = load_rows(args.root) + result = recommend(frame, max_tokens=args.max_tokens) + print(result.to_string(index=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_site.py b/scripts/check_site.py new file mode 100644 index 00000000..b9ded9b0 --- /dev/null +++ b/scripts/check_site.py @@ -0,0 +1,92 @@ +"""Check static documentation links and Python snippet syntax without dependencies.""" + +import ast +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +class Page(HTMLParser): + """Collect local link targets and executable Python examples.""" + + def __init__(self, path: Path) -> None: + super().__init__(convert_charrefs=True) + self.ids: set[str] = set() + self.links: list[str] = [] + self.errors: list[str] = [] + self.snippets: list[str] = [] + self._python = False + self._code: list[str] = [] + self.feed(path.read_text(encoding="utf-8")) + self.close() + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if identifier := attributes.get("id"): + if identifier in self.ids: + self.errors.append(f"duplicate id: {identifier}") + self.ids.add(identifier) + for key in ("href", "src"): + if target := attributes.get(key): + self.links.append(target) + if ( + tag == "code" + and "language-python" in (attributes.get("class") or "").split() + ): + self._python = True + self._code = [] + + def handle_data(self, data: str) -> None: + if self._python: + self._code.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "code" and self._python: + self.snippets.append("".join(self._code)) + self._python = False + + +def main() -> int: + root = Path(__file__).resolve().parents[1] / "site" + pages = {path.resolve(): Page(path) for path in sorted(root.rglob("*.html"))} + errors: list[str] = [] + if root / "index.html" not in pages: + errors.append("site/index.html is missing") + for path, page in pages.items(): + label = path.relative_to(root) + errors.extend(f"{label}: {error}" for error in page.errors) + for snippet in page.snippets: + try: + ast.parse(snippet) + except SyntaxError as error: + errors.append(f"{label}: invalid Python example: {error}") + for link in page.links: + url = urlsplit(link) + if url.scheme or url.netloc: + continue + if url.path.startswith("/"): + errors.append(f"{label}: use a project-relative URL: {link}") + continue + target = (path.parent / unquote(url.path)).resolve() if url.path else path + if not target.is_relative_to(root): + errors.append(f"{label}: link escapes the published site: {link}") + continue + if target.is_dir(): + target /= "index.html" + if not target.is_file(): + errors.append(f"{label}: missing target: {link}") + elif url.fragment and target in pages: + if unquote(url.fragment) not in pages[target].ids: + errors.append(f"{label}: missing fragment: {link}") + if errors: + print("\n".join(errors)) + return 1 + snippets = sum(len(page.snippets) for page in pages.values()) + print( + f"Checked {len(pages)} HTML pages: local links, IDs and {snippets} Python snippets OK." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/remove_worktree.py b/scripts/remove_worktree.py new file mode 100755 index 00000000..16c6312a --- /dev/null +++ b/scripts/remove_worktree.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python +"""Inspect and safely remove ai-parrot git worktrees. + +Two kinds of leftovers accumulate under ``.claude/worktrees/``: + +* **registered** worktrees — present in ``git worktree list``, holding a real + feature branch; +* **orphan directories** — a directory git no longer tracks (its admin data + under ``.git/worktrees/`` was pruned, or the checkout was moved). These are + invisible to ``git worktree list`` yet still occupy disk. + +Removing either while a ``claude --agent sdd-worker`` process is live inside +is destructive: the worker commits as it goes, and a removal mid-task drops +whatever it has not pushed. Liveness is therefore checked by resolving every +process's real working directory via ``/proc/<pid>/cwd`` — a substring match +on the command line alone misses a worker that ``cd``-ed in, and flags an +unrelated process that merely mentions the path. + +Usage:: + + source .venv/bin/activate + + python scripts/remove_worktree.py list + python scripts/remove_worktree.py remove feat-310-eventbus-v2 --dry-run + python scripts/remove_worktree.py remove FEAT-417 + python scripts/remove_worktree.py remove --stale # orphan dirs only + python scripts/remove_worktree.py remove <target> --delete-branch + +Safety gates (each overridable only with an explicit flag): + +* never the primary worktree, never ``main`` / ``dev`` / ``staging``; +* a live process inside the worktree aborts the removal (no override — + stop the worker first); +* uncommitted changes abort unless ``--force``; +* commits not present on the upstream branch abort unless ``--force``. +""" +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +WORKTREE_DIR = REPO_ROOT / ".claude" / "worktrees" +PROTECTED_BRANCHES = {"main", "dev", "staging", "master"} + + +def run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: + """Run a command, capturing stdout/stderr as text.""" + return subprocess.run( + cmd, cwd=str(cwd or REPO_ROOT), text=True, capture_output=True + ) + + +@dataclass +class Worktree: + """A worktree candidate for removal. + + Attributes: + path: Absolute checkout path. + branch: Branch name, or None for a detached / orphan checkout. + registered: True when git still tracks it in ``git worktree list``. + primary: True for the main repository checkout. + """ + + path: Path + branch: str | None + registered: bool + primary: bool = False + live_processes: list[tuple[int, str]] = field(default_factory=list) + + @property + def name(self) -> str: + return self.path.name + + def dirty_files(self) -> list[str]: + """Return porcelain status lines, empty when clean.""" + if not self.registered or not self.path.exists(): + return [] + proc = run(["git", "status", "--porcelain"], cwd=self.path) + return [ln for ln in proc.stdout.splitlines() if ln.strip()] + + def unpushed(self) -> list[str]: + """Return commits on this branch that origin does not have. + + Falls back to comparing against ``origin/dev`` when the branch has no + upstream configured — a worker branch that was never pushed has no + ``@{u}``, and that is exactly the case worth warning about. + """ + if not self.registered or not self.path.exists(): + return [] + upstream = run( + ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + cwd=self.path, + ) + rev = ( + f"{upstream.stdout.strip()}..HEAD" + if upstream.returncode == 0 + else "origin/dev..HEAD" + ) + proc = run(["git", "log", "--oneline", rev], cwd=self.path) + if proc.returncode != 0: + return [] + return [ln for ln in proc.stdout.splitlines() if ln.strip()] + + def pr_state(self) -> str | None: + """Return the PR state for this branch via gh, or None if unknown.""" + if not self.branch: + return None + proc = run([ + "gh", "pr", "list", "--head", self.branch, + "--state", "all", "--json", "state,number", + "--jq", '.[0] | "\\(.state) #\\(.number)"', + ]) + if proc.returncode != 0: + return None + out = proc.stdout.strip() + return out or None + + +def _proc_cwd(pid: int) -> Path | None: + """Resolve a process's working directory, or None if unreadable.""" + try: + return Path(os.readlink(f"/proc/{pid}/cwd")) + except (OSError, PermissionError): + return None + + +def _proc_cmdline(pid: int) -> str: + try: + with open(f"/proc/{pid}/cmdline", "rb") as fh: + return fh.read().replace(b"\0", b" ").decode(errors="replace").strip() + except OSError: + return "" + + +def live_processes_in(path: Path) -> list[tuple[int, str]]: + """Return ``(pid, cmdline)`` for every process running inside ``path``. + + A process counts as "inside" when its resolved cwd is the worktree or a + descendant of it. Own PID and this script's parents are skipped so a + ``/remove-worktree`` run from inside the target does not flag itself + without cause — it is still caught by the primary-worktree guard. + """ + found: list[tuple[int, str]] = [] + try: + resolved = path.resolve() + except OSError: + return found + self_pid = os.getpid() + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + pid = int(entry.name) + if pid == self_pid: + continue + cwd = _proc_cwd(pid) + if cwd is None: + continue + if cwd == resolved or resolved in cwd.parents: + found.append((pid, _proc_cmdline(pid)[:160])) + return found + + +def discover() -> list[Worktree]: + """Enumerate registered worktrees plus orphan directories.""" + proc = run(["git", "worktree", "list", "--porcelain"]) + if proc.returncode != 0: + sys.exit(f"error: not a git repository?\n{proc.stderr}") + + worktrees: list[Worktree] = [] + current: dict[str, str] = {} + for line in proc.stdout.splitlines() + [""]: + if not line.strip(): + if current.get("worktree"): + path = Path(current["worktree"]) + branch = current.get("branch") + if branch: + branch = branch.replace("refs/heads/", "") + worktrees.append(Worktree( + path=path, + branch=branch, + registered=True, + primary=path.resolve() == REPO_ROOT.resolve(), + )) + current = {} + continue + key, _, value = line.partition(" ") + current[key] = value + + registered_paths = {w.path.resolve() for w in worktrees} + if WORKTREE_DIR.is_dir(): + for child in sorted(WORKTREE_DIR.iterdir()): + if not child.is_dir(): + continue + if child.resolve() in registered_paths: + continue + worktrees.append(Worktree(path=child, branch=None, registered=False)) + + for wt in worktrees: + if not wt.primary: + wt.live_processes = live_processes_in(wt.path) + return worktrees + + +def cmd_list(args: argparse.Namespace) -> int: + """Print every worktree with its safety-relevant state.""" + worktrees = discover() + print(f"{'WORKTREE':<44} {'BRANCH':<38} STATE") + print("-" * 110) + for wt in worktrees: + if wt.primary: + print(f"{'(primary) ' + wt.name:<44} {wt.branch or '-':<38} protected") + continue + flags = [] + if not wt.registered: + flags.append("ORPHAN-DIR") + if wt.live_processes: + flags.append(f"LIVE({len(wt.live_processes)})") + dirty = wt.dirty_files() + if dirty: + flags.append(f"dirty:{len(dirty)}") + unpushed = wt.unpushed() + if unpushed: + flags.append(f"unpushed:{len(unpushed)}") + if args.check_pr: + state = wt.pr_state() + if state: + flags.append(f"PR:{state}") + if not flags: + flags.append("clean") + print(f"{wt.name:<44} {wt.branch or '-':<38} {', '.join(flags)}") + for pid, cmdline in wt.live_processes: + print(f"{'':<44} {'':<38} pid {pid}: {cmdline}") + print("\nORPHAN-DIR = directory present but not in `git worktree list`.") + print("LIVE(n) = n process(es) have their cwd inside — do NOT remove.") + return 0 + + +def _match(worktrees: list[Worktree], target: str) -> list[Worktree]: + """Resolve a user target to worktrees (exact, then substring). + + Exact matching considers the primary checkout too, so ``remove dev`` + resolves to the protected primary and is refused by name — rather than + falling through to a substring hit on an unrelated worktree such as + ``feat-FEAT-378-devloop-enhancement``. + """ + needle = target.lower() + exact = [ + w for w in worktrees + if w.name.lower() == needle or (w.branch or "").lower() == needle + ] + if exact: + return exact + candidates = [w for w in worktrees if not w.primary] + try: + resolved = Path(target).resolve() + by_path = [w for w in candidates if w.path.resolve() == resolved] + if by_path: + return by_path + except OSError: + pass + return [ + w for w in candidates + if needle in w.name.lower() or needle in (w.branch or "").lower() + ] + + +def _remove_one(wt: Worktree, args: argparse.Namespace) -> int: + """Apply the safety gates and remove one worktree. Returns an exit code.""" + print(f"\n=== {wt.name}") + print(f" path: {wt.path}") + print(f" branch: {wt.branch or '(orphan directory, no git record)'}") + sys.stdout.flush() # keep the header ahead of any stderr refusal below + + if wt.primary: + print(" REFUSED: this is the primary repository checkout.", + file=sys.stderr) + return 1 + if wt.branch in PROTECTED_BRANCHES: + print(f" REFUSED: {wt.branch} is a protected branch.", file=sys.stderr) + return 1 + + if wt.live_processes: + print(" REFUSED: a live process is running inside this worktree.", + file=sys.stderr) + for pid, cmdline in wt.live_processes: + print(f" pid {pid}: {cmdline}", file=sys.stderr) + print(" Stop it first (the worker commits as it goes; removing now " + "drops anything unpushed).", file=sys.stderr) + print(f" kill {wt.live_processes[0][0]} # or detach the tmux " + "session running it", file=sys.stderr) + return 1 + + dirty = wt.dirty_files() + if dirty and not args.force: + print(f" REFUSED: {len(dirty)} uncommitted change(s).", file=sys.stderr) + for line in dirty[:10]: + print(f" {line}", file=sys.stderr) + if len(dirty) > 10: + print(f" ... and {len(dirty) - 10} more", file=sys.stderr) + print(" Commit and push them, or re-run with --force to discard.", + file=sys.stderr) + return 1 + + unpushed = wt.unpushed() + if unpushed and not args.force: + print(f" REFUSED: {len(unpushed)} commit(s) not on origin.", + file=sys.stderr) + for line in unpushed[:10]: + print(f" {line}", file=sys.stderr) + print(f" Push them first: git -C {wt.path} push -u origin " + f"{wt.branch}", file=sys.stderr) + print(" Or re-run with --force to discard them.", file=sys.stderr) + return 1 + + if args.dry_run: + print(" [dry-run] would remove this worktree" + + (" and delete its branch" if args.delete_branch else "")) + return 0 + + if wt.registered: + cmd = ["git", "worktree", "remove", str(wt.path)] + if args.force: + cmd.append("--force") + result = run(cmd) + if result.returncode != 0: + print(f" git worktree remove failed: {result.stderr.strip()}", + file=sys.stderr) + return 1 + print(" removed (git worktree remove)") + else: + import shutil + shutil.rmtree(wt.path) + print(" removed (orphan directory)") + + run(["git", "worktree", "prune"]) + + if args.delete_branch and wt.branch: + flag = "-D" if args.force else "-d" + deleted = run(["git", "branch", flag, wt.branch]) + if deleted.returncode == 0: + print(f" branch {wt.branch} deleted") + else: + print(f" branch {wt.branch} NOT deleted: " + f"{deleted.stderr.strip()}") + print(" (unmerged — pass --force to force-delete)") + return 0 + + +def cmd_remove(args: argparse.Namespace) -> int: + """Remove one or more worktrees after the safety gates pass.""" + worktrees = discover() + + if args.stale: + targets = [w for w in worktrees if not w.registered] + if not targets: + print("No orphan directories under .claude/worktrees/.") + return 0 + print(f"Orphan directories to remove: {len(targets)}") + elif args.target: + if args.target.lower() in PROTECTED_BRANCHES: + print(f"error: {args.target} is a protected branch — refusing to " + "resolve it to any worktree.", file=sys.stderr) + return 1 + targets = _match(worktrees, args.target) + if not targets: + print(f"error: no worktree matches {args.target!r}.\n", + file=sys.stderr) + return cmd_list(argparse.Namespace(check_pr=False)) or 1 + if len(targets) > 1: + print(f"error: {args.target!r} is ambiguous — matches: " + + ", ".join(t.name for t in targets), file=sys.stderr) + return 1 + else: + print("error: pass a target, or --stale for orphan directories.", + file=sys.stderr) + return 1 + + failures = 0 + for wt in targets: + failures += _remove_one(wt, args) + + print("\nRemaining worktrees:") + run(["git", "worktree", "prune"]) + print(run(["git", "worktree", "list"]).stdout, end="") + return 1 if failures else 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_list = sub.add_parser("list", help="Show worktrees and their state.") + p_list.add_argument("--check-pr", action="store_true", + help="Also query PR state via gh (slower).") + p_list.set_defaults(func=cmd_list) + + p_rm = sub.add_parser("remove", help="Remove a worktree safely.") + p_rm.add_argument("target", nargs="?", + help="Branch name, directory name, FEAT id, or path.") + p_rm.add_argument("--stale", action="store_true", + help="Remove every orphan directory (no git record).") + p_rm.add_argument("--delete-branch", action="store_true", + help="Also delete the local branch.") + p_rm.add_argument("--force", action="store_true", + help="Discard uncommitted/unpushed work. Never " + "overrides the live-process gate.") + p_rm.add_argument("--dry-run", action="store_true") + p_rm.set_defaults(func=cmd_remove) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sdd/.collision_baseline.json b/scripts/sdd/.collision_baseline.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/scripts/sdd/.collision_baseline.json @@ -0,0 +1 @@ +[] diff --git a/scripts/sdd/.task_state_baseline.json b/scripts/sdd/.task_state_baseline.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/scripts/sdd/.task_state_baseline.json @@ -0,0 +1 @@ +[] diff --git a/scripts/sdd/__init__.py b/scripts/sdd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/sdd/backfill_taxonomy.py b/scripts/sdd/backfill_taxonomy.py new file mode 100644 index 00000000..a0454ef2 --- /dev/null +++ b/scripts/sdd/backfill_taxonomy.py @@ -0,0 +1,184 @@ +"""Infer ``projects`` frontmatter for existing SDD docs (FEAT-576). Dry-run by default. + +Usage:: + + python -m scripts.sdd.backfill_taxonomy [--root .] [--kind spec|brainstorm|proposal|all] + [--limit N] [--apply] +""" + +from __future__ import annotations + +import argparse +import logging +import re +import sys +from pathlib import Path + +from pydantic import ValidationError + +from scripts.sdd.sdd_meta import KNOWN_PROJECTS, parse_taxonomy + +logger = logging.getLogger(__name__) + +_GLOBS: dict[str, str] = { + "spec": "sdd/specs/*.spec.md", + "brainstorm": "sdd/proposals/*.brainstorm.md", + "proposal": "sdd/proposals/*.proposal.md", +} + +#: A ``packages/<dist>/`` mention. Only kept when ``<dist>`` is in ``KNOWN_PROJECTS``. +_DIST_RE = re.compile(r"packages/([A-Za-z0-9_-]+)/") + +#: Top-level ``parrot_*`` distributions imported without the ``packages/`` prefix. +_TOOLS_PKG_RE = re.compile(r"\b(parrot_tools|parrot_loaders|parrot_pipelines)\b") +_TOOLS_PKG_PROJECT: dict[str, str] = { + "parrot_tools": "ai-parrot-tools", + "parrot_loaders": "ai-parrot-loaders", + "parrot_pipelines": "ai-parrot-pipelines", +} + +#: SDD tooling paths that aren't under any ``packages/`` distribution. +_SDD_TOOLING_RE = re.compile(r"scripts/sdd/|\.claude/commands/|sdd/templates/") + +_DEV_LOOP_RE = re.compile(r"flows/dev_loop") + +#: A bare ``parrot/<x>`` mention, i.e. NOT the ``src/parrot/`` tail of a ``packages/<dist>/`` path. +_BARE_PARROT_RE = re.compile(r"(?<!src/)parrot/(?=[A-Za-z0-9_])") + + +def infer_projects(text: str) -> list[str]: + """Map code paths mentioned in ``text`` to canonical projects, ordered by first mention. + + Only values in ``KNOWN_PROJECTS`` are returned. + """ + events: list[tuple[int, str]] = [] + + for match in _DIST_RE.finditer(text): + dist = match.group(1) + if dist not in KNOWN_PROJECTS: + continue + offset = match.start() + events.append((offset, dist)) + if dist == "ai-parrot-server" and text[match.end() : match.end() + 3] == "ui/": + events.append((offset, "admin-ui")) + + for match in _TOOLS_PKG_RE.finditer(text): + events.append((match.start(), _TOOLS_PKG_PROJECT[match.group(1)])) + + for match in _SDD_TOOLING_RE.finditer(text): + events.append((match.start(), "sdd-tooling")) + + for match in _DEV_LOOP_RE.finditer(text): + events.append((match.start(), "dev-loop")) + + for match in _BARE_PARROT_RE.finditer(text): + events.append((match.start(), "ai-parrot")) + + events.sort(key=lambda event: event[0]) + + result: list[str] = [] + for _, project in events: + if project not in result: + result.append(project) + return result + + +_EMPTY_PROJECTS_RE = re.compile(r"^projects:\s*\[\s*\]\s*$") +_TAGS_KEY_RE = re.compile(r"^tags:\s*") + + +def plan_edit(doc_path: Path) -> str | None: + """Return the new file text, or ``None`` when no change is needed. + + ``None`` when ``projects`` is already non-empty or nothing was inferred. Inserts lines as + text before the closing ``---``; never rewrites any other byte. + """ + text = doc_path.read_text(encoding="utf-8") + try: + taxonomy = parse_taxonomy(doc_path) + except ValidationError as exc: + logger.warning("Skipping %s: invalid taxonomy frontmatter (%s)", doc_path, exc) + return None + if taxonomy.projects: + return None + + inferred = infer_projects(text) + if not inferred: + return None + projects_line = f"projects: [{', '.join(inferred)}]\n" + + # Same byte-0 '---' rule as parse()/parse_taxonomy(): a real block needs a leading '---' + # AND a closing '---' later in the file. + has_block = text.startswith("---") and len(text.split("---", 2)) == 3 + if not has_block: + prefix = "---\ntype: feature\nbase_branch: dev\n" + projects_line + "tags: []\n---\n" + return prefix + text + + _, block_body, rest = text.split("---", 2) + lines = block_body.split("\n") + + new_lines: list[str] = [] + replaced = False + has_tags = False + for line in lines: + stripped = line.strip() + if _EMPTY_PROJECTS_RE.match(stripped): + new_lines.append(projects_line.rstrip("\n")) + replaced = True + continue + if _TAGS_KEY_RE.match(stripped): + has_tags = True + new_lines.append(line) + + new_block_body = "\n".join(new_lines) + if not replaced: + if not new_block_body.endswith("\n"): + new_block_body += "\n" + new_block_body += projects_line + if not has_tags: + if not new_block_body.endswith("\n"): + new_block_body += "\n" + new_block_body += "tags: []\n" + + return "---" + new_block_body + "---" + rest + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point; returns 0. Writes only with ``--apply``; never commits.""" + parser = argparse.ArgumentParser(prog="python -m scripts.sdd.backfill_taxonomy", description=__doc__) + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--kind", choices=["spec", "brainstorm", "proposal", "all"], default="all") + parser.add_argument("--limit", type=int, default=0, help="stop after N proposed edits (0 = no limit)") + parser.add_argument("--apply", action="store_true", help="write the edits (default: dry run)") + args = parser.parse_args(argv) + + kinds = list(_GLOBS) if args.kind == "all" else [args.kind] + doc_paths: list[Path] = [] + for kind in kinds: + doc_paths.extend(sorted(args.root.glob(_GLOBS[kind]))) + + changed = 0 + for doc_path in doc_paths: + if args.limit and changed >= args.limit: + break + original_text = doc_path.read_text(encoding="utf-8") + new_text = plan_edit(doc_path) + if new_text is None: + continue + inferred = infer_projects(original_text) + try: + rel = doc_path.relative_to(args.root) + except ValueError: + rel = doc_path + sys.stdout.write(f"{rel}: projects = [{', '.join(inferred)}]\n") + if args.apply: + doc_path.write_text(new_text, encoding="utf-8") + changed += 1 + + verb = "changed" if args.apply else "would change" + sys.stdout.write(f"{changed} docs {verb}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sdd/calibrate_rlimit_as.py b/scripts/sdd/calibrate_rlimit_as.py new file mode 100644 index 00000000..6b3f3640 --- /dev/null +++ b/scripts/sdd/calibrate_rlimit_as.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python +"""Empirical calibration of ``WorkerConfig.rlimit_as_bytes`` (FEAT-380 TASK-1946) +and ``memory_soft/hard_limit_bytes`` (FEAT-521 TASK-2783). + +Spawns REAL REPL workers — the exact deployed code path (spawn + preexec +rlimits, `parrot.tools.repl_worker.handle.WorkerHandle`) — and measures each +workload's peak *virtual* memory (`VmPeak`) alongside peak *resident* memory +(`VmRSS` — the metric FEAT-521's `memory_soft/hard_limit_bytes` actually +guards, via `psutil`'s `memory_info().rss` inside `ProcessObserver`) and +`VmHWM` (the kernel's own "high water mark" resident-memory record, a +useful independent cross-check on the sampled `VmRSS` peak). All three are +read from `/proc/<worker-pid>/status`, polled from THIS host process, never +from inside the sandboxed worker, while running under a generous ceiling so +the workload isn't clipped mid-measurement. + +Linux/POSIX only (`/proc/<pid>/status` + `resource.setrlimit`), matching the +feature's own POSIX-only rlimit enforcement (spec AC16 — Windows has no +rlimits at all). + +Workloads (spec §3 Module 8): + - bootstrap: pandas/numpy/matplotlib/seaborn import cost only. + - load_<N>mb: an in-memory DataFrame sized to ~N MB. + - merge_groupby: a multi-key merge + groupby().agg() over two loaded frames. + - plot: a seaborn/matplotlib plot saved via save_current_plot(). + +Note on workload realism: the spec calls for "CSV/parquet load", but +`pd.read_csv`/`pd.read_parquet` are on the sandbox's categorical data-IO +denylist (`python_sanitizer.py` `_PANDAS_IO_NAMES`) — deliberately +unmodified here (out of this task's scope). Workloads instead construct an +in-memory DataFrame of the target size directly (seeded `numpy` random +generation, already-preloaded `pd`/`np`) — the dominant peak-memory driver +either way is holding the *materialized* DataFrame, which this reproduces +faithfully; only the transient CSV-parsing staging-buffer overhead is not +exercised. See the evidence file for the full write-up. + +Usage: + python scripts/sdd/calibrate_rlimit_as.py [--sizes 100,500] [--limit-gib 8] +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import platform +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Optional + +_SRC = Path(__file__).resolve().parents[2] / "packages" / "ai-parrot" / "src" +sys.path.insert(0, str(_SRC)) + +from parrot.tools.repl_worker.handle import WorkerHandle # noqa: E402 +from parrot.tools.repl_worker.protocol import WorkerConfig # noqa: E402 + +if sys.platform != "linux": + raise SystemExit("calibrate_rlimit_as.py is Linux-only (reads /proc/<pid>/status)") + + +#: /proc/<pid>/status fields tracked as running peaks across the workload's +#: whole lifetime (spec Q1: RSS is the metric `memory_soft/hard_limit_bytes` +#: actually enforces; VmPeak/VmHWM stay for the RLIMIT_AS calibration this +#: script already did, and as an independent cross-check on the RSS peak). +_TRACKED_FIELDS = ("VmPeak", "VmHWM", "VmRSS") + + +@dataclass +class Measurement: + workload: str + vm_peak_kb: int + vm_hwm_kb: int + vm_rss_peak_kb: int + survived: bool + detail: str = "" + + @property + def vm_peak_mb(self) -> float: + return self.vm_peak_kb / 1024 + + @property + def vm_hwm_mb(self) -> float: + return self.vm_hwm_kb / 1024 + + @property + def vm_rss_peak_mb(self) -> float: + return self.vm_rss_peak_kb / 1024 + + +def _read_proc_status(pid: int) -> dict[str, int]: + """Read VmPeak/VmHWM/VmRSS (KiB) for `pid` from /proc — host-side, unsandboxed.""" + out: dict[str, int] = {} + try: + with open(f"/proc/{pid}/status") as f: + for line in f: + for key in _TRACKED_FIELDS: + if line.startswith(f"{key}:"): + out[key] = int(line.split()[1]) + except FileNotFoundError: + pass + return out + + +async def _run_with_peak_tracking(handle: WorkerHandle, code: Optional[str], poll_interval: float = 0.05): + """Run `code` in the worker (or just wait if `code` is None, for the + bootstrap-only workload) while polling its VmPeak/VmHWM/VmRSS from the host. + """ + pid = handle._proc.pid + peaks = {key: 0 for key in _TRACKED_FIELDS} + stop = asyncio.Event() + + async def poll() -> None: + while not stop.is_set(): + for key, value in _read_proc_status(pid).items(): + peaks[key] = max(peaks[key], value) + await asyncio.sleep(poll_interval) + + poll_task = asyncio.create_task(poll()) + try: + result = await handle.execute(code) if code else await handle.ping() + finally: + # One last sample after the workload returns, before stopping. + for key, value in _read_proc_status(pid).items(): + peaks[key] = max(peaks[key], value) + stop.set() + await poll_task + return result, peaks + + +def _size_to_shape(target_mb: int, n_cols: int = 8) -> tuple[int, int]: + """Row/col shape of a float64 DataFrame whose payload is ~target_mb MB.""" + total_cells = (target_mb * 1024 * 1024) // 8 + rows = max(1, total_cells // n_cols) + return rows, n_cols + + +async def _spawn(config: WorkerConfig, output_dir: str) -> WorkerHandle: + handle = WorkerHandle(config, output_dir=output_dir) + await handle.start() + return handle + + +async def calibrate(sizes_mb: list[int], limit_gib: float, output_dir: str) -> list[Measurement]: + config = WorkerConfig( + rlimit_as_bytes=int(limit_gib * 1024**3), + deadline_ms=120_000, + max_workers=2, + idle_ttl_seconds=30, + prewarm_pool_size=0, + ) + measurements: list[Measurement] = [] + + # 1. bootstrap only + handle = await _spawn(config, output_dir) + try: + _, peaks = await _run_with_peak_tracking(handle, None) + measurements.append( + Measurement( + "bootstrap", + peaks["VmPeak"], + peaks["VmHWM"], + peaks["VmRSS"], + True, + "pandas/numpy/matplotlib/seaborn import", + ) + ) + + # 2. load_<N>mb for each requested size (same worker, cumulative state + # like a real session — subsequent workloads build on prior ones). + # `key` cardinality is sized to the LARGEST requested workload so the + # merge step below stays a realistic ~1-to-few join, not a low- + # cardinality many-to-many cross-product blowup (e.g. `% 1000` on + # multi-million-row frames multiplies out to tens of billions of + # merged rows — an artifact of the test data, not a real workload). + key_space = max(_size_to_shape(size_mb)[0] for size_mb in sizes_mb) or 1 + loaded_names = [] + for size_mb in sizes_mb: + rows, cols = _size_to_shape(size_mb) + var = f"df_{size_mb}mb" + code = ( + f"{var} = pd.DataFrame(np.random.default_rng(42).random(({rows}, {cols})))\n" + f"{var}['key'] = np.arange({rows}) % {key_space}\n" + f"{var}['cat'] = (np.arange({rows}) % 5).astype(str)\n" + f"result = {var}.shape" + ) + output, peaks = await _run_with_peak_tracking(handle, code) + survived = isinstance(output, str) + measurements.append( + Measurement( + f"load_{size_mb}mb", + peaks["VmPeak"], + peaks["VmHWM"], + peaks["VmRSS"], + survived, + f"shape=({rows},{cols}) + key/cat cols" if survived else str(output), + ) + ) + if survived: + loaded_names.append(var) + + # 3. merge + groupby.agg over the loaded frames (needs >= 2) + if len(loaded_names) >= 2: + a, b = loaded_names[0], loaded_names[1] + code = ( + f"merged = {a}.merge({b}, on='key', suffixes=('_a', '_b'))\n" + "result = merged.groupby('cat_a').agg({'0_a': 'sum'})" + ) + output, peaks = await _run_with_peak_tracking(handle, code) + survived = isinstance(output, str) + measurements.append( + Measurement( + "merge_groupby", + peaks["VmPeak"], + peaks["VmHWM"], + peaks["VmRSS"], + survived, + f"{a} x {b}" if survived else str(output), + ) + ) + + # 4. plot + first = loaded_names[0] if loaded_names else None + if first: + code = f"plt.plot({first}.iloc[:2000, 0])\nresult = save_current_plot()" + output, peaks = await _run_with_peak_tracking(handle, code) + survived = isinstance(output, str) + measurements.append(Measurement("plot", peaks["VmPeak"], peaks["VmHWM"], peaks["VmRSS"], survived)) + finally: + await handle.kill() + + return measurements + + +def _print_table(measurements: list[Measurement]) -> None: + print( + f"{'workload':<16} {'VmPeak (MB)':>12} {'VmHWM (MB)':>12} {'VmRSS peak (MB)':>16} " f"{'survived':>9} detail" + ) + for m in measurements: + print( + f"{m.workload:<16} {m.vm_peak_mb:>12.1f} {m.vm_hwm_mb:>12.1f} {m.vm_rss_peak_mb:>16.1f} " + f"{str(m.survived):>9} {m.detail}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sizes", default="100,500", help="Comma-separated target DataFrame sizes in MB") + parser.add_argument("--limit-gib", type=float, default=8.0, help="Ceiling RLIMIT_AS (GiB) used while measuring") + parser.add_argument("--output-json", default=None, help="Optional path to dump raw measurements as JSON") + args = parser.parse_args() + + sizes_mb = [int(s) for s in args.sizes.split(",")] + + import os + import tempfile + + with tempfile.TemporaryDirectory(prefix="rlimit-calibration-") as tmp: + # `AbstractTool.__init__`'s output_dir guard requires `output_dir` + # to fall under `STATIC_DIR`/`OUTPUT_DIR` (parrot.conf). The WORKER + # subprocess re-imports `parrot.conf` fresh and inherits this + # process' environment (`subprocess.Popen` default), so setting the + # env var here — before any worker is spawned — is what lets the + # freshly-created temp dir pass the guard inside the child. + os.environ["STATIC_DIR"] = tmp + measurements = asyncio.run(calibrate(sizes_mb, args.limit_gib, tmp)) + + _print_table(measurements) + + if args.output_json: + Path(args.output_json).write_text( + json.dumps( + { + "python_version": platform.python_version(), + "platform": platform.platform(), + "measurements": [asdict(m) for m in measurements], + }, + indent=2, + ) + ) + print(f"\nWrote raw measurements to {args.output_json}") + + +if __name__ == "__main__": + main() diff --git a/scripts/sdd/check_id_collisions.py b/scripts/sdd/check_id_collisions.py new file mode 100644 index 00000000..f5e0a829 --- /dev/null +++ b/scripts/sdd/check_id_collisions.py @@ -0,0 +1,327 @@ +"""``check_id_collisions.py`` — defense-in-depth SDD ID collision scanner. + +Independent, read-only backstop for the git-native compare-and-swap +allocator (``reserve_ids.py``, FEAT-387): scans the repo's current SDD +state for any ``TASK-<NNN>`` number associated with more than one distinct +feature slug. This is the check that would have caught the six real +collisions found during FEAT-380's closeout +(TASK-1939/1940/1941/1942/1944/1946, each shared with an unrelated +feature) had it existed at the time. + +``TASK-<NNN>`` collisions across different slugs are treated as failures. +``FEAT-<NNN>`` reuse across specs is an accepted, pre-existing pattern +(``migrate_index.py``'s own documented convention — see FEAT-380 itself, +split across three specs sharing one FEAT-ID by design) and is reported +informationally only, never as a failure. + +Usage: + python -m scripts.sdd.check_id_collisions [--index-dir DIR] [--active-dir DIR] + [--completed-dir DIR] [--specs-dir DIR] +""" + +from __future__ import annotations + +import argparse +import json +import re +from collections import defaultdict +from pathlib import Path + +from pydantic import BaseModel + +#: Matches a TASK filename prefix, e.g. "TASK-1939-repl-dedicated-executor.md". +_TASK_FILENAME_RE = re.compile(r"^(TASK-\d+)-") + +#: Matches the spec header line, e.g. "**Feature ID**: FEAT-387". +_FEATURE_ID_HEADER_RE = re.compile(r"\*\*Feature ID\*\*:\s*FEAT-(\d+)") + + +class CollisionReport(BaseModel): + """One colliding numeric ID and every distinct slug/source using it.""" + + id: str # e.g. "TASK-1939" + kind: str # "task" | "feature" + slugs: list[str] # every distinct feature slug found using this id + sources: list[str] # file paths where each was found + + +def _id_sort_key(task_id: str) -> tuple[int, int, str]: + """Order ids for reporting, tolerating shapes other than ``TASK-<NNN>``. + + Not every id in a per-spec index is ``TASK-<NNN>``: hotfix indexes use + ``HOTFIX-<slug>-<n>`` (e.g. ``HOTFIX-chromemanager-async-migration-1``) + and some legacy features use ``TASK-<NNN>-<NNN>``. The previous key, + ``int(t.split("-")[1])``, raised ValueError on the first hotfix id and + aborted the run — so this checker, whose entire job is to catch TASK-ID + collisions, was detecting nothing at all. + + Ordering is presentational only; it never affects detection. + + Args: + task_id: The identifier to order. + + Returns: + A sort key placing numeric ids first (in numeric order), then any + other shape, deterministically by string. + """ + parts = task_id.split("-") + if len(parts) > 1 and parts[1].isdigit(): + return (0, int(parts[1]), task_id) + return (1, 0, task_id) + + +def _slug_from_filename(path: Path) -> str | None: + """Best-effort slug extraction from a `TASK-<NNN>-<slug>.md` filename.""" + match = _TASK_FILENAME_RE.match(path.name) + if not match: + return None + return path.stem[len(match.group(1)) + 1 :] + + +def find_collisions( + index_dir: Path = Path("sdd/tasks/index"), + active_dir: Path = Path("sdd/tasks/active"), + completed_dir: Path = Path("sdd/tasks/completed"), + specs_dir: Path = Path("sdd/specs"), +) -> list[CollisionReport]: + """Scan SDD state for TASK-ID collisions and FEAT-ID reuse. + + Scans ``index_dir/*.json`` (task ``id`` fields + their owning + ``feature``/``feature_id``), ``active_dir/*.md``, and + ``completed_dir/*.md`` (filenames, matched by ``TASK-<NNN>-`` prefix) + for any ``TASK-<NNN>`` number used by more than one distinct feature + slug. Separately tallies ``FEAT-<NNN>`` -> spec slug from + ``specs_dir/*.md`` headers for an informational (never-failing) report. + + Args: + index_dir: Directory containing per-spec index JSON files. + active_dir: Directory containing in-progress task markdown files. + completed_dir: Directory containing completed task markdown files. + specs_dir: Directory containing spec markdown files. + + Returns: + One ``CollisionReport`` per colliding ``TASK-<NNN>`` number (kind + ``"task"``) PLUS one per shared ``FEAT-<NNN>`` number (kind + ``"feature"``, informational only — callers must not treat these + as failures). Empty list overall = a fully clean tree. + """ + # Two INDEPENDENT namespaces, tracked separately and never + # cross-compared directly against each other — a per-task index entry's + # "feature" slug (e.g. "sandbox-hardening") and a task file's own + # descriptive slug (e.g. "repl-dedicated-executor", the `<slug>` in + # `TASK-<NNN>-<slug>.md`) are never expected to be equal even for a + # single, correctly-owned task, so mixing them into one dict produces + # a false "collision" for nearly every healthy task in the repo. + feature_owners: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list)) + file_slugs: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list)) + + # 1. Walk sdd/tasks/index/*.json (including _orphans.json — it carries + # real task entries with real IDs, even though it has no meaningful + # feature_id header), read each task's own "id"/"feature" fields + # (authoritative — defends against a task somehow being appended to + # the wrong index file). This alone already detects a real, + # different-feature TASK-ID collision (confirmed empirically: the + # six known FEAT-380-era collisions are each present in TWO + # different per-spec index files under the same TASK-<NNN>). + if index_dir.is_dir(): + for index_file in sorted(index_dir.glob("*.json")): + try: + doc = json.loads(index_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + for task in doc.get("tasks", []) or []: + task_id = task.get("id") + slug = task.get("feature") + if not isinstance(task_id, str) or not slug: + continue + feature_owners[task_id][slug].append(str(index_file)) + + # 2. Independently scan sdd/tasks/active/*.md + completed/*.md + # filenames: if the SAME TASK-<NNN> prefix is used by files with + # genuinely DIFFERENT descriptive slugs, that is direct evidence of + # two distinct task instances sharing one number — regardless of + # whether the index disambiguates them by feature. This is a + # defense-in-depth fallback for the case where the index itself is + # missing or incomplete (e.g. a task file exists but was never + # recorded in any per-spec index). + for task_dir in (active_dir, completed_dir): + if not task_dir.is_dir(): + continue + for task_file in sorted(task_dir.glob("TASK-*.md")): + match = _TASK_FILENAME_RE.match(task_file.name) + if not match: + continue + task_id = match.group(1) + slug = _slug_from_filename(task_file) + if not slug: + continue + file_slugs[task_id][slug].append(str(task_file)) + + all_task_ids = set(feature_owners) | set(file_slugs) + reports: list[CollisionReport] = [] + for task_id in sorted(all_task_ids, key=_id_sort_key): + owners = feature_owners.get(task_id, {}) + if len(owners) > 1: + # Authoritative: two different features' indexes both claim + # this TASK-ID. + reports.append( + CollisionReport( + id=task_id, + kind="task", + slugs=sorted(owners), + sources=[src for slug in sorted(owners) for src in owners[slug]], + ) + ) + continue + + slugs = file_slugs.get(task_id, {}) + if len(slugs) > 1: + # Fallback: the index doesn't disambiguate (missing/incomplete), + # but the filenames themselves reveal two distinct task + # instances sharing this number. + reports.append( + CollisionReport( + id=task_id, + kind="task", + slugs=sorted(slugs), + sources=[src for slug in sorted(slugs) for src in slugs[slug]], + ) + ) + + # 3. Separately tally FEAT-<NNN> -> spec slug from sdd/specs/*.md + # headers for the informational (non-failing) report. Deliberately a + # DIFFERENT dict from `feature_owners` above (which tracks TASK-ID + # ownership) — reusing the same name for this unrelated FEAT-ID + # namespace would be a readability/maintenance hazard. + feat_id_owners: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list)) + if specs_dir.is_dir(): + for spec_file in sorted(specs_dir.glob("*.md")): + try: + text = spec_file.read_text(encoding="utf-8") + except OSError: + continue + match = _FEATURE_ID_HEADER_RE.search(text) + if not match: + continue + feature_id = f"FEAT-{match.group(1)}" + slug = spec_file.stem.removesuffix(".spec") + feat_id_owners[feature_id][slug].append(str(spec_file)) + + for feature_id in sorted(feat_id_owners, key=lambda f: int(f.split("-")[1])): + owners = feat_id_owners[feature_id] + if len(owners) > 1: + reports.append( + CollisionReport( + id=feature_id, + kind="feature", + slugs=sorted(owners), + sources=[src for slug in sorted(owners) for src in owners[slug]], + ) + ) + + return reports + + +def _load_baseline(path: Path | None) -> set[str]: + """Load a baseline allowlist of pre-existing, non-fatal TASK-ID collisions. + + Supports the one-time baseline exception (spec §5 Acceptance Criteria, + last bullet): the six FEAT-380-era collisions — and, empirically, many + more pre-``per-spec-index`` historical ``TASK-<NNN>`` reuses predating + the global counter (FEAT-145) — must NOT retroactively fail CI. IDs + listed here are still printed in the report (never hidden), just + excluded from the failing set. + + Args: + path: Path to a JSON file containing a list of ``TASK-<NNN>`` + strings (e.g. ``["TASK-1939", "TASK-1940"]``), or ``None``. + + Returns: + The set of baselined IDs; empty if ``path`` is ``None`` or the + file is missing/unreadable. + """ + if path is None: + return set() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return set() + if isinstance(data, list): + return {str(item) for item in data} + return set() + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point. + + Exits 1 and prints a human-readable report naming every offending + file/slug if any NEW (non-baselined) ``TASK-<NNN>`` collision is + found; exits 0 otherwise. ``FEAT-<NNN>`` reuse is always printed + informationally and never affects the exit code. Baselined + collisions (``--baseline``) are still printed, but marked as + non-fatal and excluded from the exit-code decision. + """ + parser = argparse.ArgumentParser( + description="Scan sdd/ for TASK-<NNN> collisions across features (FEAT-387).", + ) + parser.add_argument("--index-dir", type=Path, default=Path("sdd/tasks/index")) + parser.add_argument("--active-dir", type=Path, default=Path("sdd/tasks/active")) + parser.add_argument("--completed-dir", type=Path, default=Path("sdd/tasks/completed")) + parser.add_argument("--specs-dir", type=Path, default=Path("sdd/specs")) + parser.add_argument( + "--baseline", + type=Path, + default=None, + help=( + "Path to a JSON allowlist of pre-existing TASK-<NNN> collisions " + "to report but NOT fail on (one-time baseline exception)." + ), + ) + args = parser.parse_args(argv) + + collisions = find_collisions( + index_dir=args.index_dir, + active_dir=args.active_dir, + completed_dir=args.completed_dir, + specs_dir=args.specs_dir, + ) + baseline = _load_baseline(args.baseline) + + task_collisions = [c for c in collisions if c.kind == "task"] + feature_reuse = [c for c in collisions if c.kind == "feature"] + new_task_collisions = [c for c in task_collisions if c.id not in baseline] + baselined_task_collisions = [c for c in task_collisions if c.id in baseline] + + if task_collisions: + print("TASK-<NNN> collisions found:") + for report in task_collisions: + baseline_note = " (baselined — non-fatal)" if report.id in baseline else "" + print(f" {report.id}{baseline_note}: slugs={report.slugs}") + for source in report.sources: + print(f" - {source}") + + if feature_reuse: + print("FEAT-<NNN> reuse across specs (informational, not a failure):") + for report in feature_reuse: + print(f" {report.id}: slugs={report.slugs}") + for source in report.sources: + print(f" - {source}") + + if new_task_collisions: + print( + f"\nFAIL: {len(new_task_collisions)} new TASK-<NNN> collision(s) found " + f"({len(baselined_task_collisions)} pre-existing, baselined " + "collision(s) ignored)." + ) + return 1 + + print( + f"OK: no new TASK-<NNN> collisions " + f"({len(baselined_task_collisions)} pre-existing, baselined collision(s) " + f"ignored, {len(feature_reuse)} informational FEAT-<NNN> reuse note(s))." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/check_task_graph.py b/scripts/sdd/check_task_graph.py new file mode 100644 index 00000000..5d14a507 --- /dev/null +++ b/scripts/sdd/check_task_graph.py @@ -0,0 +1,461 @@ +"""``check_task_graph.py`` — deterministic lint for a per-spec task graph. + +``depends_on`` is the ONLY thing that orders tasks; ``parallel: false`` marks a +task as *exclusive* (never dispatched alongside another task). Both are written +by an LLM in ``/sdd-task``, which is how querysource FEAT-147 shipped 18 tasks +chained 1→2→…→18 with one copy-pasted rationale, so the sdd-coder engine ran a +parallelizable feature strictly in series. This script checks the graph against +what the task files actually declare, instead of trusting the prose: + +Errors (exit 1): + * ``unknown-dependency`` — a ``depends_on`` id that is not in the index. + * ``cycle`` — the ``depends_on`` graph has a cycle. + * ``file-overlap`` — two tasks declare the same file and neither depends + (transitively) on the other, so they could run concurrently and conflict. + * ``missing-validation-commands`` — a task has no ``## Validation Commands`` + section (error only when the index header declares + ``"validation_contract": "required"``; warning otherwise — FEAT-563). + * ``broad-validation-command`` — a task's validation command is an + over-broad ``pytest`` invocation (FEAT-563). + * ``directory-validation-target`` — a task's validation command targets an + existing directory instead of a file or node id (FEAT-563). + * ``non-pytest-validation-command`` — a task's declared validation command is + not a pytest invocation at all (e.g. ``true`` or ``ruff check .``); it is + silently invisible to ``select_tests.py``'s declared-command handling, so + the task effectively has no enforced test coverage (FEAT-563). + +Warnings (exit 0): + * ``unjustified-edge`` — B depends on A, but they share no file, B's task + file never references a file A declares (by path or dotted module), and + B's ``parallelism_notes`` do not name A. + * ``possible-missing-dependency`` — B references a file A creates, yet B does + not depend on A. + * ``duplicate-notes`` — the same ``parallelism_notes`` on 3+ tasks. + * ``exclusive-without-notes`` — ``parallel: false`` with no named resource. + * ``legacy-semantics`` — the header lacks ``"parallel_semantics": + "exclusive"``, so the engine ignores every ``parallel`` flag. + * ``missing-validation-commands`` — see above; warning when the header does + not declare ``"validation_contract": "required"`` (legacy indexes). + * ``validation-path-unknown`` — a validation command's operand neither + exists under ``root`` nor is declared in the task's own + ``## Files to Create / Modify`` (FEAT-563). + +Usage: + python -m scripts.sdd.check_task_graph sdd/tasks/index/<feature>.json [--root .] [--json] +""" + +from __future__ import annotations + +import argparse +import json +import re +from collections import Counter +from pathlib import Path, PurePosixPath + +from pydantic import BaseModel, Field + +PARALLEL_SEMANTICS = "exclusive" +VALIDATION_CONTRACT = "required" +_KERNEL_DIR = Path(__file__).resolve().parents[2] / "packages/ai-parrot/src/parrot/flows/dev_loop" +_FLAG_WITH_VALUE = frozenset({"-m", "-k", "-o", "-p", "-c", "--rootdir", "--confcutdir"}) + +_HEADING = re.compile(r"^## Files to Create ?/ ?Modify\s*$", re.M) +_NEXT_HEADING = re.compile(r"^## ", re.M) +_BACKTICK_PATH = re.compile(r"`([^`\s]+)`") +_SEPARATOR_ROW = re.compile(r"^\|?[\s|:-]+\|?$") +_CREATE = re.compile(r"\bcreate\b", re.I) + + +class Finding(BaseModel): + """One graph problem.""" + + level: str # "error" | "warning" + code: str + tasks: list[str] = Field(default_factory=list) + message: str + + +class GraphReport(BaseModel): + """Result of checking one per-spec index.""" + + index: str + task_count: int + waves: list[list[str]] = Field(default_factory=list) + exclusive: list[str] = Field(default_factory=list) + findings: list[Finding] = Field(default_factory=list) + + @property + def errors(self) -> list[Finding]: + """Findings that fail the check.""" + return [f for f in self.findings if f.level == "error"] + + +class _Task(BaseModel): + id: str + depends_on: list[str] = Field(default_factory=list) + parallel: bool = True + notes: str = "" + text: str = "" + files: dict[str, bool] = Field(default_factory=dict) # path -> is CREATE + + +def parse_declared_files(task_md: str) -> dict[str, bool]: + """Map each path under '## Files to Create / Modify' to whether its row says CREATE. + + Args: + task_md: The task markdown. + + Returns: + ``{path: is_create}`` in declaration order. + """ + m = _HEADING.search(task_md) + if not m: + return {} + body = task_md[m.end() :] + n = _NEXT_HEADING.search(body) + body = body[: n.start()] if n else body + files: dict[str, bool] = {} + for raw in body.splitlines(): + line = raw.strip() + if not line.startswith(("|", "-")) or _SEPARATOR_ROW.match(line): + continue + match = _BACKTICK_PATH.search(line) + if match and match.group(1) not in files: + files[match.group(1)] = bool(_CREATE.search(line[match.end() :])) + return files + + +def module_of(path: str) -> str | None: + """Dotted import path of a ``.py`` file (src-layout aware), or None when too generic to match.""" + p = PurePosixPath(path) + if p.suffix != ".py": + return None + parts = list(p.with_suffix("").parts) + if "src" in parts: + parts = parts[parts.index("src") + 1 :] + if parts and parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) if len(parts) >= 2 else None + + +def _references(text: str, path: str) -> bool: + if path in text: + return True + module = module_of(path) + return bool(module and re.search(rf"(?<![\w.]){re.escape(module)}(?![\w])", text)) + + +def _read_task_text(root: Path, file: str) -> str: + candidates = [root / file] if file else [] + if file: + name = PurePosixPath(file).name + candidates += [root / "sdd/tasks/active" / name, root / "sdd/tasks/completed" / name] + for candidate in candidates: + if candidate.is_file(): + return candidate.read_text(encoding="utf-8") + return "" + + +def _ancestors(tasks: dict[str, _Task]) -> dict[str, set[str]]: + """Transitive ``depends_on`` closure per task (unknown ids and cycles tolerated).""" + memo: dict[str, set[str]] = {} + + def visit(tid: str, stack: frozenset[str]) -> set[str]: + if tid in memo: + return memo[tid] + acc: set[str] = set() + for dep in tasks[tid].depends_on: + if dep in tasks and dep not in stack: + acc.add(dep) + acc |= visit(dep, stack | {dep}) + memo[tid] = acc + return acc + + for tid in tasks: + visit(tid, frozenset({tid})) + return memo + + +def _waves(tasks: dict[str, _Task]) -> tuple[list[list[str]], list[str]]: + """Kahn levels; returns (waves, ids stuck in a cycle).""" + remaining = {tid: {d for d in t.depends_on if d in tasks} for tid, t in tasks.items()} + waves: list[list[str]] = [] + while remaining: + ready = sorted(tid for tid, deps in remaining.items() if not deps) + if not ready: + return waves, sorted(remaining) + waves.append(ready) + for tid in ready: + del remaining[tid] + for deps in remaining.values(): + deps.difference_update(ready) + return waves, [] + + +def _load_contract(): + """Import ``test_scope.contract`` by path (top-level name), never via ``parrot``. + + Returns: + The ``test_scope.contract`` module. + """ + import importlib + import sys + + if str(_KERNEL_DIR) not in sys.path: + sys.path.insert(0, str(_KERNEL_DIR)) + return importlib.import_module("test_scope.contract") + + +def _pytest_operands(argv: list[str]) -> list[str]: + """Positional operands of a pytest argv, ``::node`` suffix stripped.""" + operands: list[str] = [] + skip_next = False + for token in argv[1:]: + if skip_next: + skip_next = False + continue + if token.startswith("-"): + if token in _FLAG_WITH_VALUE: + skip_next = True + continue + operands.append(token.split("::", 1)[0]) + return operands + + +def _check_validation_contract(tasks: dict[str, _Task], root: Path, required: bool) -> list[Finding]: + """Emit the four validation-contract findings for every task. + + Args: + tasks: Parsed tasks (``text`` and ``files`` populated). + root: Repository root for existence checks. + required: True when the index header declares ``"validation_contract": "required"``. + + Returns: + Findings: missing-validation-commands, non-pytest-validation-command, + broad-validation-command, directory-validation-target, validation-path-unknown. + """ + contract = _load_contract() + findings: list[Finding] = [] + for tid, task in tasks.items(): + if not task.text: + continue + commands = contract.parse_validation_commands(task.text) + if not commands: + findings.append( + Finding( + level="error" if required else "warning", + code="missing-validation-commands", + tasks=[tid], + message=f"{tid} has no '## Validation Commands' section", + ) + ) + continue + for argv in commands: + if not contract.is_pytest_invocation(argv): + findings.append( + Finding( + level="error", + code="non-pytest-validation-command", + tasks=[tid], + message=( + f"{tid}: validation command is not a pytest invocation, so it is " + f"invisible to select_tests.py's declared-command handling: {' '.join(argv)}" + ), + ) + ) + continue + if contract.is_broad_pytest(argv): + findings.append( + Finding( + level="error", + code="broad-validation-command", + tasks=[tid], + message=f"{tid}: over-broad validation command: {' '.join(argv)}", + ) + ) + continue + for operand in _pytest_operands(argv): + candidate = root / operand + if candidate.is_dir(): + findings.append( + Finding( + level="error", + code="directory-validation-target", + tasks=[tid], + message=f"{tid}: validation command targets a directory: {operand}", + ) + ) + elif not candidate.exists() and operand not in task.files: + findings.append( + Finding( + level="warning", + code="validation-path-unknown", + tasks=[tid], + message=f"{tid}: validation path neither exists nor is declared: {operand}", + ) + ) + return findings + + +def check_graph(index_path: Path, root: Path) -> GraphReport: + """Check one per-spec index against its task files. + + Args: + index_path: ``sdd/tasks/index/<feature>.json``. + root: Repository root the index's ``file`` paths are relative to. + + Returns: + The graph report. + """ + data = json.loads(index_path.read_text(encoding="utf-8")) + tasks: dict[str, _Task] = {} + for entry in data.get("tasks", []): + text = _read_task_text(root, entry.get("file", "")) + tasks[entry["id"]] = _Task( + id=entry["id"], + depends_on=list(entry.get("depends_on") or []), + parallel=bool(entry.get("parallel", True)), + notes=(entry.get("parallelism_notes") or "").strip(), + text=text, + files=parse_declared_files(text), + ) + report = GraphReport(index=str(index_path), task_count=len(tasks)) + add = report.findings.append + exclusive_semantics = data.get("parallel_semantics") == PARALLEL_SEMANTICS + if not exclusive_semantics: + add( + Finding( + level="warning", + code="legacy-semantics", + message='index header lacks "parallel_semantics": "exclusive" — the engine ignores `parallel` flags', + ) + ) + else: + report.exclusive = sorted(tid for tid, t in tasks.items() if not t.parallel) + + for tid, task in tasks.items(): + for dep in task.depends_on: + if dep not in tasks: + add( + Finding( + level="error", + code="unknown-dependency", + tasks=[tid, dep], + message=f"{tid} depends on unknown {dep}", + ) + ) + + report.waves, cyclic = _waves(tasks) + if cyclic: + add(Finding(level="error", code="cycle", tasks=cyclic, message="depends_on cycle among " + ", ".join(cyclic))) + ancestors = _ancestors(tasks) + ids = sorted(tasks) + + for i, a in enumerate(ids): + for b in ids[i + 1 :]: + shared = sorted(set(tasks[a].files) & set(tasks[b].files)) + if shared and a not in ancestors[b] and b not in ancestors[a]: + add( + Finding( + level="error", + code="file-overlap", + tasks=[a, b], + message=f"{a} and {b} both declare {', '.join(shared)} but neither depends on the other", + ) + ) + + for tid, task in tasks.items(): + direct = [d for d in task.depends_on if d in tasks] + for dep in direct: + if any(dep in ancestors[other] for other in direct if other != dep): + continue # implied by another path: harmless, and already justified (or not) there + shares = set(task.files) & set(tasks[dep].files) + cites = any(_references(task.text, path) for path in tasks[dep].files) + # A rationale that names the dependency ("needs the accessors from TASK-734") is + # evidence too: some real dependencies (a Cython rebuild, a doc of a finished + # surface) are not visible as shared or referenced files. + argued = re.search(rf"\b{re.escape(dep)}\b", task.notes) is not None + if not shares and not cites and not argued and task.text and tasks[dep].text: + add( + Finding( + level="warning", + code="unjustified-edge", + tasks=[tid, dep], + message=f"{tid} → {dep}: no shared file and {tid} never references a file {dep} declares", + ) + ) + for other_id, other in tasks.items(): + if other_id == tid or other_id in ancestors[tid] or tid in ancestors.get(other_id, set()): + continue + created = [p for p, is_create in other.files.items() if is_create and p not in task.files] + cited = [p for p in created if _references(task.text, p)] + if cited: + add( + Finding( + level="warning", + code="possible-missing-dependency", + tasks=[tid, other_id], + message=f"{tid} references {', '.join(cited)} created by {other_id} but does not depend on it", + ) + ) + + for notes, count in Counter(t.notes for t in tasks.values() if t.notes).items(): + if count >= 3: + same = sorted(tid for tid, t in tasks.items() if t.notes == notes) + add( + Finding( + level="warning", + code="duplicate-notes", + tasks=same, + message=f"{count} tasks share one parallelism_notes — rationale must be per task", + ) + ) + if exclusive_semantics: + for tid in report.exclusive: + if not tasks[tid].notes: + add( + Finding( + level="warning", + code="exclusive-without-notes", + tasks=[tid], + message=f"{tid} is exclusive but names no resource", + ) + ) + report.findings.extend( + _check_validation_contract(tasks, root, data.get("validation_contract") == VALIDATION_CONTRACT) + ) + return report + + +def format_report(report: GraphReport) -> str: + """Human-readable rendering of a report.""" + width = max((len(w) for w in report.waves), default=0) + lines = [ + f"{report.index}: {report.task_count} tasks, {len(report.waves)} waves, max width {width}", + ] + for n, wave in enumerate(report.waves, 1): + lines.append(f" wave {n}: {', '.join(wave)}") + if report.exclusive: + lines.append(f" exclusive: {', '.join(report.exclusive)}") + for finding in sorted(report.findings, key=lambda f: (f.level != "error", f.code)): + lines.append(f" {finding.level.upper():7} {finding.code}: {finding.message}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: exit 1 when any index has errors.""" + parser = argparse.ArgumentParser(description="Lint per-spec task graphs (depends_on / parallel).") + parser.add_argument("indexes", nargs="+", type=Path) + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--json", action="store_true", help="Emit JSON reports instead of text.") + args = parser.parse_args(argv) + reports = [check_graph(path, args.root) for path in args.indexes] + if args.json: + print(json.dumps([r.model_dump() for r in reports], indent=2)) # noqa: T201 - CLI output + else: + print("\n\n".join(format_report(r) for r in reports)) # noqa: T201 - CLI output + return 1 if any(r.errors for r in reports) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/check_task_state.py b/scripts/sdd/check_task_state.py new file mode 100644 index 00000000..a19e89bb --- /dev/null +++ b/scripts/sdd/check_task_state.py @@ -0,0 +1,205 @@ +"""``check_task_state.py`` — read-only backstop for stalled SDD task files. + +A task is *closed* by moving its file from ``sdd/tasks/active/`` to +``sdd/tasks/completed/`` and marking its per-spec index entry ``done`` +(``scripts/sdd/close_task.sh`` / ``scripts/sdd/finalize_task.py``). When a +lane copies instead of moving, or closes on one branch while a merge brings +the old ``active/`` copy back, the base branch ends up with a *stalled* +``active/`` file for a task that is already finished. ``heal_orphans.sh`` +reaps those, but only when ``/sdd-done --merge`` runs it — the default PR +flow never did, so orphans accumulated silently. + +This check fails CI on the two states that are unambiguous by construction: + +* ``twin`` — the same task file exists in both ``active/`` and + ``completed/`` (identical basename). +* ``closed-but-active`` — a per-spec index entry marks the task ``done`` / + ``done-with-issues`` while its file still lives in ``active/``. + +It deliberately does NOT flag ``pending`` / ``in-progress`` tasks: whether +one of those is abandoned depends on branches and worktrees this script +cannot see. Fix a reported violation with +``scripts/sdd/heal_orphans.sh <feature-slug>`` (twins) or +``scripts/sdd/close_task.sh <TASK-ID> <feature-slug>`` (closed-but-active). + +Usage: + python -m scripts.sdd.check_task_state [--index-dir DIR] [--active-dir DIR] + [--completed-dir DIR] [--baseline FILE] +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel + +#: Matches a TASK filename prefix, e.g. "TASK-1939-repl-dedicated-executor.md". +_TASK_FILENAME_RE = re.compile(r"^(TASK-\d+)-") + +#: Index statuses that mean the task is finished and must not stay in active/. +_CLOSED_STATUSES = frozenset({"done", "done-with-issues"}) + + +class TaskStateViolation(BaseModel): + """One stalled ``active/`` task file and why it is stalled.""" + + task_id: str + kind: Literal["twin", "closed-but-active"] + active_file: str + detail: str + + +def _closed_entries(index_dir: Path) -> dict[str, list[tuple[str, str, str]]]: + """Map each active-file basename to the closed index entries that own it. + + An entry owns a basename when its ``id`` matches the filename's TASK + prefix AND its ``file`` field names that same basename (in ``active/`` + or ``completed/``). Matching on the basename, not the bare id, keeps + historical cross-feature ``TASK-<NNN>`` reuse (see + ``check_id_collisions.py``) from producing false positives. + + Args: + index_dir: Directory holding the per-spec ``<feature>.json`` indexes. + + Returns: + ``{basename: [(index_path, feature, status), ...]}`` for entries whose + status is in ``_CLOSED_STATUSES``. + """ + owners: dict[str, list[tuple[str, str, str]]] = {} + if not index_dir.is_dir(): + return owners + for index_file in sorted(index_dir.glob("*.json")): + try: + data = json.loads(index_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(data, dict): + continue + for task in data.get("tasks") or []: + if not isinstance(task, dict): + continue + status = task.get("status") + file_field = task.get("file") + task_id = task.get("id") + if status not in _CLOSED_STATUSES or not file_field or not task_id: + continue + basename = Path(str(file_field)).name + if not basename.startswith(f"{task_id}-"): + continue + feature = str(task.get("feature") or data.get("feature") or index_file.stem) + owners.setdefault(basename, []).append((str(index_file), feature, str(status))) + return owners + + +def find_violations(*, index_dir: Path, active_dir: Path, completed_dir: Path) -> list[TaskStateViolation]: + """Scan ``active/`` for task files that should already be closed. + + Args: + index_dir: Directory holding the per-spec indexes. + active_dir: ``sdd/tasks/active``. + completed_dir: ``sdd/tasks/completed``. + + Returns: + Every violation found, ordered by active filename. + """ + violations: list[TaskStateViolation] = [] + if not active_dir.is_dir(): + return violations + closed = _closed_entries(index_dir) + for active_file in sorted(active_dir.glob("TASK-*.md")): + match = _TASK_FILENAME_RE.match(active_file.name) + if not match: + continue + task_id = match.group(1) + twin = completed_dir / active_file.name + if twin.is_file(): + violations.append( + TaskStateViolation( + task_id=task_id, + kind="twin", + active_file=str(active_file), + detail=f"completed copy also exists at {twin}", + ) + ) + continue + for index_path, feature, status in closed.get(active_file.name, []): + violations.append( + TaskStateViolation( + task_id=task_id, + kind="closed-but-active", + active_file=str(active_file), + detail=f"{index_path} ({feature}) marks it {status!r}", + ) + ) + return violations + + +def _load_baseline(path: Path | None) -> set[str]: + """Load a JSON list of pre-existing stalled ``active/`` basenames to report but not fail on. + + Keyed on the file basename (e.g. ``TASK-2500-replace-sha1-with-sha256.md``), + not the bare ``TASK-<NNN>``: ids are reused across features, so an id key + would also silence an unrelated future violation sharing that number. + + Args: + path: JSON file holding a list of ``active/`` basenames, or ``None``. + + Returns: + The baselined basenames; empty when ``path`` is ``None`` or unreadable. + """ + if path is None: + return set() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return set() + return {str(item) for item in data} if isinstance(data, list) else set() + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: exit 1 on any non-baselined violation, 0 otherwise. + + Args: + argv: Optional argument list (defaults to ``sys.argv[1:]``). + + Returns: + Process exit code. + """ + parser = argparse.ArgumentParser(description="Fail on stalled sdd/tasks/active/ task files.") + parser.add_argument("--index-dir", type=Path, default=Path("sdd/tasks/index")) + parser.add_argument("--active-dir", type=Path, default=Path("sdd/tasks/active")) + parser.add_argument("--completed-dir", type=Path, default=Path("sdd/tasks/completed")) + parser.add_argument( + "--baseline", + type=Path, + default=None, + help="JSON list of pre-existing stalled active/ basenames to report but NOT fail on.", + ) + args = parser.parse_args(argv) + + violations = find_violations(index_dir=args.index_dir, active_dir=args.active_dir, completed_dir=args.completed_dir) + baseline = _load_baseline(args.baseline) + new = [v for v in violations if Path(v.active_file).name not in baseline] + + if violations: + print("Stalled task files in active/:") + for v in violations: + note = " (baselined — non-fatal)" if Path(v.active_file).name in baseline else "" + print(f" {v.task_id} [{v.kind}]{note}: {v.active_file} — {v.detail}") + if new: + print( + f"\nFAIL: {len(new)} stalled active/ task file(s). Twins: " + "scripts/sdd/heal_orphans.sh <feature-slug>; closed-but-active: " + "scripts/sdd/close_task.sh <TASK-ID> <feature-slug>." + ) + return 1 + print(f"OK: no stalled active/ task files ({len(violations) - len(new)} baselined).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/close_task.sh b/scripts/sdd/close_task.sh new file mode 100755 index 00000000..1039ae29 --- /dev/null +++ b/scripts/sdd/close_task.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# +# close_task.sh — Deterministically close a single SDD task. +# +# Moves a task file from sdd/tasks/active/ to sdd/tasks/completed/, marks it +# "done" in its per-spec index (status, completed_at, file path), and stages +# the change — then HARD-VERIFIES that no active/ copy survives. +# +# This exists because agents executing /sdd-start or /sdd-done tend to +# paraphrase the `mv` step as a Write/copy, leaving the active/ file behind. +# When the feature branch merges into the base branch, BOTH copies land → +# "stalled" orphan task files. A script removes that execution drift: there is +# nothing to paraphrase. +# +# Usage: +# scripts/sdd/close_task.sh <TASK-ID> <feature-slug> [verification] +# +# Example: +# scripts/sdd/close_task.sh TASK-1429 structured-table verified +# +# Arguments: +# TASK-ID e.g. TASK-1429 (the active file is matched by this prefix) +# feature-slug per-spec index basename under sdd/tasks/index/<slug>.json +# verification optional: verified|partial|forced (default: verified) +# +# Exit codes: +# 0 task closed (or already closed — idempotent) +# 1 usage error +# 2 active file not found AND no completed twin (nothing to close) +# 3 post-condition failed (active copy still present) — must never happen +set -euo pipefail + +TASK_ID="${1:-}" +FEATURE_SLUG="${2:-}" +VERIFICATION="${3:-verified}" + +if [[ -z "$TASK_ID" || -z "$FEATURE_SLUG" ]]; then + echo "usage: $0 <TASK-ID> <feature-slug> [verified|partial|forced]" >&2 + exit 1 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +ACTIVE_DIR="sdd/tasks/active" +COMPLETED_DIR="sdd/tasks/completed" +INDEX="sdd/tasks/index/${FEATURE_SLUG}.json" +NOW="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" + +mkdir -p "$COMPLETED_DIR" + +# Resolve the active file by TASK-ID prefix (slug may vary). +shopt -s nullglob +active_matches=("$ACTIVE_DIR/${TASK_ID}-"*.md) +completed_matches=("$COMPLETED_DIR/${TASK_ID}-"*.md) +shopt -u nullglob + +if [[ ${#active_matches[@]} -eq 0 && ${#completed_matches[@]} -gt 0 ]]; then + echo "ℹ️ ${TASK_ID}: already in completed/ (no active copy) — nothing to move." + # Still ensure the index reflects done + correct path below. +elif [[ ${#active_matches[@]} -eq 0 ]]; then + echo "✗ ${TASK_ID}: no file in active/ and no twin in completed/." >&2 + exit 2 +fi + +basename_md="" +for src in "${active_matches[@]}"; do + basename_md="$(basename "$src")" + dest="$COMPLETED_DIR/$basename_md" + if [[ -e "$dest" ]]; then + # Twin already exists in completed/ → the active copy is a pure orphan. + # git rm --ignore-unmatch exits 0 for untracked files without touching the + # working tree, so rm -f afterwards to guarantee removal either way. + git rm -q --ignore-unmatch "$src" >/dev/null 2>&1 || true + rm -f "$src" + echo "✓ ${TASK_ID}: removed orphan active copy (twin already in completed/)." + else + git mv "$src" "$dest" 2>/dev/null || { mv "$src" "$dest"; } + echo "✓ ${TASK_ID}: moved active → completed/$basename_md" + fi +done + +# Fall back to the completed twin's basename if there was no active match. +if [[ -z "$basename_md" && ${#completed_matches[@]} -gt 0 ]]; then + basename_md="$(basename "${completed_matches[0]}")" +fi + +# Update the per-spec index for this task: status, completed_at, verification, file. +if [[ -f "$INDEX" && -n "$basename_md" ]]; then + tmp="$(mktemp)" + jq --arg id "$TASK_ID" --arg now "$NOW" --arg ver "$VERIFICATION" \ + --arg file "$COMPLETED_DIR/$basename_md" ' + (.tasks[] | select(.id == $id) | .status) = "done" | + (.tasks[] | select(.id == $id) | .completed_at) = $now | + (.tasks[] | select(.id == $id) | .verification) = $ver | + (.tasks[] | select(.id == $id) | .file) = $file | + (if all(.tasks[]; .status == "done") then .completed_at = $now else . end) + ' "$INDEX" > "$tmp" && mv "$tmp" "$INDEX" + git add "$INDEX" +fi + +# Stage the moves explicitly (never `git add .`). +git add "$COMPLETED_DIR/$basename_md" 2>/dev/null || true +git add -u "$ACTIVE_DIR" 2>/dev/null || true + +# HARD POST-CONDITION: no active copy of this task may survive. +shopt -s nullglob +survivors=("$ACTIVE_DIR/${TASK_ID}-"*.md) +shopt -u nullglob +if [[ ${#survivors[@]} -gt 0 ]]; then + echo "✗ ${TASK_ID}: active copy STILL present after close: ${survivors[*]}" >&2 + exit 3 +fi + +echo "✅ ${TASK_ID} closed (verification=${VERIFICATION}). active/ is clean." + +# FEAT-566 Module 12: emit task.closed to the shared work ledger, AFTER the +# hard post-condition above has already passed. This only ever appends to +# the durable, lock-free events.jsonl log (never touches ledger.db/SQLite), +# so there is no writer contention to handle here — a ledger index sync +# later applies the event. Emission is best-effort: any failure (missing +# parrot.knowledge.wiki package, unwritable shared root, ...) is logged and +# swallowed, never turning an otherwise-successful closure into a failure. +python3 - "$TASK_ID" "$FEATURE_SLUG" "$VERIFICATION" <<'PYEOF' || true +import sys +from pathlib import Path + +task_id, feature_slug, verification = sys.argv[1:4] +# Resolve the cwd BEFORE importing parrot: parrot.knowledge.wiki.project pulls +# in parrot.bots -> navigator, and navconfig os.chdir()s to its own project +# root on import, which would redirect the event to the wrong ledger. +start_dir = Path.cwd() + +try: + from parrot.knowledge.wiki.ledger.events import LedgerEvent + from parrot.knowledge.wiki.ledger.log import LedgerLog + from parrot.knowledge.wiki.project import find_shared_root + + shared_root = find_shared_root(start_dir) or start_dir + ledger_dir = shared_root / ".parrot" / "ledger" + ledger_dir.mkdir(parents=True, exist_ok=True) + + event = LedgerEvent( + kind="task.closed", + subject=f"task:{task_id}", + actor="agent:close_task.sh", + payload={"feature": feature_slug, "verification": verification}, + ) + LedgerLog(str(ledger_dir / "events.jsonl")).append(event) +except Exception as exc: # noqa: BLE001 — ledger emission never blocks closure + print(f"⚠️ task.closed ledger emission skipped: {exc}", file=sys.stderr) +PYEOF diff --git a/scripts/sdd/codex_hook.sh b/scripts/sdd/codex_hook.sh new file mode 100755 index 00000000..e814b072 --- /dev/null +++ b/scripts/sdd/codex_hook.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# FEAT-563 — portable launcher for the codex PreToolUse hook. +# Resolves the MAIN checkout (the one owning the shared .venv) from any linked +# worktree and runs the parrot_tools guard with the hook payload on stdin. +# Never breaks codex: any missing piece exits 0 silently (no decision). +common_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || exit 0 +main_checkout=$(dirname "$common_dir") +python_bin="$main_checkout/.venv/bin/python" +[ -x "$python_bin" ] || exit 0 +exec "$python_bin" -m parrot_tools.tool_optimizations.hooks --host codex diff --git a/scripts/sdd/doc_taxonomy.py b/scripts/sdd/doc_taxonomy.py new file mode 100644 index 00000000..c1c06071 --- /dev/null +++ b/scripts/sdd/doc_taxonomy.py @@ -0,0 +1,123 @@ +"""List SDD docs by ``projects``/``tags`` frontmatter (FEAT-576). + +Usage:: + + python -m scripts.sdd.doc_taxonomy [--root .] [--kind spec|brainstorm|proposal|all] + [--project P ...] [--tag T ...] [--paths-only | --json | --summary] +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from collections import Counter +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ValidationError + +from scripts.sdd.sdd_meta import normalize_project, normalize_tag, parse_taxonomy + +logger = logging.getLogger(__name__) + +DocKind = Literal["spec", "brainstorm", "proposal", "all"] + +_GLOBS: dict[str, str] = { + "spec": "sdd/specs/*.spec.md", + "brainstorm": "sdd/proposals/*.brainstorm.md", + "proposal": "sdd/proposals/*.proposal.md", +} + + +class TaxonomyRow(BaseModel): + """One SDD document and its taxonomy.""" + + path: str + kind: DocKind + projects: list[str] + tags: list[str] + + +def collect(root: Path, kind: DocKind = "all") -> list[TaxonomyRow]: + """Scan sdd/specs/*.spec.md and sdd/proposals/*.{brainstorm,proposal}.md under ``root``. + + A doc whose frontmatter fails validation is skipped with a logged warning. + """ + doc_kinds = _GLOBS.keys() if kind == "all" else [kind] + rows: list[TaxonomyRow] = [] + for doc_kind in doc_kinds: + pattern = _GLOBS[doc_kind] + for path in root.glob(pattern): + try: + taxonomy = parse_taxonomy(path) + except ValidationError as exc: + logger.warning("Skipping %s: invalid taxonomy (%s)", path, exc) + continue + rows.append( + TaxonomyRow( + path=path.relative_to(root).as_posix(), + kind=doc_kind, + projects=taxonomy.projects, + tags=taxonomy.tags, + ) + ) + rows.sort(key=lambda row: row.path) + return rows + + +def filter_rows(rows: list[TaxonomyRow], projects: list[str], tags: list[str]) -> list[TaxonomyRow]: + """AND across the two flags, OR within each; filter values are normalized first.""" + norm_projects = {normalize_project(value) for value in projects} + norm_tags = {normalize_tag(value) for value in tags} + return [ + row + for row in rows + if (not norm_projects or norm_projects & set(row.projects)) and (not norm_tags or norm_tags & set(row.tags)) + ] + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point. Returns 0 on a successful scan, 2 on bad arguments.""" + parser = argparse.ArgumentParser(prog="python -m scripts.sdd.doc_taxonomy", description=__doc__) + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--kind", choices=["spec", "brainstorm", "proposal", "all"], default="all") + parser.add_argument("--project", action="append", default=[]) + parser.add_argument("--tag", action="append", default=[]) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--paths-only", action="store_true") + mode.add_argument("--json", action="store_true") + mode.add_argument("--summary", action="store_true") + args = parser.parse_args(argv) + + rows = collect(args.root, args.kind) + try: + rows = filter_rows(rows, args.project, args.tag) + except ValueError as exc: + sys.stderr.write(f"error: invalid --project/--tag value: {exc}\n") + return 2 + + if args.paths_only: + for row in rows: + sys.stdout.write(f"{row.path}\n") + elif args.json: + sys.stdout.write(json.dumps([row.model_dump() for row in rows], indent=2) + "\n") + elif args.summary: + project_counts: Counter[str] = Counter(project for row in rows for project in row.projects) + tag_counts: Counter[str] = Counter(tag for row in rows for tag in row.tags) + sys.stdout.write(f"{len(rows)} docs\n\n") + sys.stdout.write("PROJECTS\n") + for value, count in sorted(project_counts.items(), key=lambda kv: (-kv[1], kv[0])): + sys.stdout.write(f"{count}\t{value}\n") + sys.stdout.write("\nTAGS\n") + for value, count in sorted(tag_counts.items(), key=lambda kv: (-kv[1], kv[0])): + sys.stdout.write(f"{count}\t{value}\n") + else: + for row in rows: + sys.stdout.write(f"{row.path}\t{row.kind}\t{','.join(row.projects)}\t{','.join(row.tags)}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sdd/ensure_worktree.py b/scripts/sdd/ensure_worktree.py new file mode 100644 index 00000000..9d72f758 --- /dev/null +++ b/scripts/sdd/ensure_worktree.py @@ -0,0 +1,324 @@ +"""Idempotent feature/hotfix worktree provisioning for SDD commands (FEAT-552). + +Replaces the hand-rolled ``git worktree add`` blocks that used to live in +``/sdd-task``, ``/sdd-start``, ``sdd-worker``, ``sdd-planner``, +``sdd-research`` and ``sdd-autopilot``. Naming and base ref come from +``scripts.sdd.sdd_meta.plan_worktree`` — never built here. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Sequence + +from scripts.sdd.sdd_meta import WorktreePlan, plan_worktree, resolve_flow + +logger = logging.getLogger(__name__) + + +class EnsureWorktreeError(RuntimeError): + """Raised when the worktree cannot be provisioned (CLI exit code 1).""" + + +def _git(*args: str, cwd: Path) -> str: + """Run a git command, returning stdout; raise EnsureWorktreeError on failure.""" + res = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + if res.returncode != 0: + cmd_str = " ".join(["git", *args]) + raise EnsureWorktreeError( + f"Command '{cmd_str}' failed with exit code {res.returncode}.\n" + f"stdout: {res.stdout.strip()}\n" + f"stderr: {res.stderr.strip()}" + ) + return res.stdout + + +def ensure( + plan: WorktreePlan, + *, + repo_root: Path, + sync: bool = True, + require_paths: Sequence[str] = (), + dry_run: bool = False, +) -> tuple[Path, bool]: + """Create the worktree if absent, reuse it if present, and verify it. + + Steps, in order: + 1. Reuse — if ``git worktree list --porcelain`` already lists a worktree + whose path basename is ``plan.name``, confirm its checked-out branch + is ``plan.name`` and return it. A path holding a DIFFERENT branch is + an error, never a silent reuse. + 2. Sync (skipped when ``sync`` is False) — ``git fetch origin + <base_branch>``. No local branch is checked out; no local commit moves. + 3. Refuse when a branch named ``plan.name`` exists but is checked out + nowhere — the operator decides (reuse it, or pick another slug). + 4. Create — ``git worktree add -b <name> <path> <base_ref>``. + 5. Verify — every ``require_paths`` entry must exist inside the worktree. + A miss means the base does not carry the task artifacts yet. + 6. Return ``(absolute_path, created)``. + + Args: + plan: The naming/base-ref decision from ``plan_worktree``. + repo_root: Absolute path to the main clone. + sync: Fetch ``origin/<base_branch>`` before creating. + require_paths: Repo-relative paths that must exist in the worktree. + dry_run: Resolve and report without running any mutating git command. + + Returns: + ``(path, created)`` — ``created`` is False when an existing worktree + was reused. + + Raises: + EnsureWorktreeError: On any refusal above, or a failing git command. + """ + _reject_absolute_paths(require_paths) + target_path = (repo_root / plan.path).resolve() + + # Step 1: Reuse + reused_path = _find_reusable_worktree(plan, target_path, repo_root=repo_root) + if reused_path is not None: + missing = _missing_paths(reused_path, require_paths) + if missing: + raise EnsureWorktreeError(f"Required path {missing[0]!r} does not exist in reused worktree {reused_path}.") + return reused_path, False + + if dry_run: + return target_path, True + + # Step 2: Sync — base_ref is origin/<base_branch> + if sync: + _git("fetch", "origin", plan.base_ref.split("/", 1)[1], cwd=repo_root) + + # Step 3: Refuse a branch that exists but is checked out nowhere. Step 1 + # already walked every active worktree, so none of them holds it. + if _branch_exists(plan.name, repo_root=repo_root): + raise EnsureWorktreeError(f"Branch {plan.name!r} already exists but is not checked out in any worktree.") + + # Step 4: Create + _create_worktree(plan, target_path, repo_root=repo_root) + + # Step 5: Verify + missing = _missing_paths(target_path, require_paths) + if missing: + _discard_new_worktree(plan, target_path, repo_root=repo_root) + raise EnsureWorktreeError( + f"Verification failed: the following required paths were missing from the new worktree: " + f"{', '.join(missing)}" + ) + + return target_path, True + + +def _reject_absolute_paths(require_paths: Sequence[str]) -> None: + """Refuse absolute ``require_paths`` entries. + + ``Path(x) / y`` silently discards ``x`` when ``y`` is absolute, which would + make the Step 5 verification report success for a path that was never + inside the new worktree. ``require_paths`` is documented as repo-relative — + enforce that instead of failing open. + """ + for req in require_paths: + if Path(req).is_absolute(): + raise EnsureWorktreeError(f"require_paths entries must be repo-relative, got absolute path: {req!r}") + + +def _worktree_branches(repo_root: Path) -> dict[Path, str]: + """Map every registered worktree path to its checked-out local branch. + + Parses ``git worktree list --porcelain``, whose blocks look like:: + + worktree /path/to/worktree + branch refs/heads/branch-name + + A worktree with no ``branch`` line (detached HEAD, bare) is left out. + """ + out = _git("worktree", "list", "--porcelain", cwd=repo_root) + current: Path | None = None + branches: dict[Path, str] = {} + for raw in out.splitlines(): + line = raw.strip() + if line.startswith("worktree "): + current = Path(line[len("worktree ") :]).resolve() + elif line.startswith("branch refs/heads/") and current is not None: + branches[current] = line[len("branch refs/heads/") :] + return branches + + +def _find_reusable_worktree(plan: WorktreePlan, target_path: Path, *, repo_root: Path) -> Path | None: + """Return the existing worktree for ``plan``, ``None`` when there is none. + + A registered worktree matches by exact path or by basename ``plan.name``. + One that is checked out on a DIFFERENT branch is an error, never a silent + reuse. + """ + for wt_path, branch_name in _worktree_branches(repo_root).items(): + if wt_path != target_path and wt_path.name != plan.name: + continue + if branch_name != plan.name: + raise EnsureWorktreeError( + f"Worktree at {wt_path} is checked out on branch {branch_name!r}, " + f"but expected branch {plan.name!r}." + ) + return wt_path + return None + + +def _missing_paths(root: Path, require_paths: Sequence[str]) -> list[str]: + """Return the ``require_paths`` entries that do not exist under ``root``.""" + return [req for req in require_paths if not (root / req).exists()] + + +def _branch_exists(name: str, *, repo_root: Path) -> bool: + """True when a local branch called ``name`` exists (checked out or not).""" + out = _git("branch", "--list", name, cwd=repo_root) + return any(line.strip().replace("*", "").strip() == name for line in out.splitlines()) + + +def _create_worktree(plan: WorktreePlan, target_path: Path, *, repo_root: Path) -> None: + """``git worktree add -b <name> <path> <base_ref>``, pruning on failure.""" + target_path.parent.mkdir(parents=True, exist_ok=True) + try: + _git("worktree", "add", "-b", plan.name, str(target_path), plan.base_ref, cwd=repo_root) + except EnsureWorktreeError: + # Drop the half-registered entry git may have left behind. + if target_path.exists(): + try: + _git("worktree", "prune", cwd=repo_root) + except Exception: # noqa: BLE001 — best effort, the original error is what matters + pass + raise + + +def _discard_new_worktree(plan: WorktreePlan, target_path: Path, *, repo_root: Path) -> None: + """Undo a worktree ``_create_worktree`` just made, after verification failed. + + Delete the directory and let ``git worktree prune`` drop the now-stale + registration, then remove the branch we created — a plain, non-forced + delete of an unpushed branch that carries no commits of its own, never a + forced delete of a branch that might hold real work. ``prune`` is + repo-wide by git's own design but harmless here: it only forgets + registrations whose directory is already gone from disk, so a concurrent + process's still-live worktree is never touched (verified empirically — an + unrelated live worktree/branch survives a run that hits this path). + """ + try: + if target_path.is_dir(): + shutil.rmtree(target_path) + _git("worktree", "prune", cwd=repo_root) + _git("branch", "-d", plan.name, cwd=repo_root) + except Exception as cleanup_err: # noqa: BLE001 — never mask the verification error + logger.warning("Failed to clean up worktree/branch after verification failure: %s", cleanup_err) + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point. Prints the worktree path to stdout; 0 on success. + + With ``--json``, prints one object instead — + ``{"name": …, "path": …, "base_ref": …, "created": bool}`` — so + ``sdd-planner``/``sdd-research`` can lift ``worktree_path`` straight into + their ``PlannerOutput``/``ResearchOutput`` contracts (spec §8). + """ + parser = argparse.ArgumentParser(description="Idempotent feature/hotfix worktree provisioning for SDD commands.") + parser.add_argument("--slug", required=True, help="Feature slug, kebab-case.") + parser.add_argument("--feature-id", help="FEAT-<NNN>; required for feature runs.") + parser.add_argument( + "--jira-key", + help="Jira issue key; required for hotfix runs. Implies --type hotfix " + "and --base-branch main unless either is passed explicitly.", + ) + parser.add_argument("--spec", help="Path to spec markdown file.") + parser.add_argument("--index", help="Path to index JSON file.") + parser.add_argument("--base-branch", help="Override base branch.") + parser.add_argument("--type", choices=["feature", "hotfix"], help="Override flow type.") + parser.add_argument("--no-sync", action="store_true", help="Skip fetching origin/<base_branch>.") + parser.add_argument("--dry-run", action="store_true", help="Resolve and report without mutating git.") + parser.add_argument("--json", action="store_true", help="Output JSON instead of bare path.") + + args = parser.parse_args(argv if argv is not None else sys.argv[1:]) + + try: + # Resolve flow. `--jira-key` with no explicit `--type` implies a + # hotfix (and therefore `origin/main`) — without this, the + # documented invocation `--slug <slug> --jira-key <KEY>` (see + # CLAUDE.md and sdd-research.md) would fail with "feature_id is + # required", since resolve_flow() defaults to type="feature" and + # has no other signal here that this is a hotfix run. + type_override = args.type + base_branch_override = args.base_branch + if args.jira_key and not type_override: + type_override = "hotfix" + if not base_branch_override: + base_branch_override = "main" + + doc_path = Path(args.spec) if args.spec else None + meta = resolve_flow( + doc_path=doc_path, + type_override=type_override, + base_branch_override=base_branch_override, + ) + + # Plan worktree + plan = plan_worktree( + meta, + slug=args.slug, + feature_id=args.feature_id, + jira_key=args.jira_key, + ) + + # Determine require_paths + require_paths = [] + if args.spec: + require_paths.append(args.spec) + if args.index: + require_paths.append(args.index) + + # Run ensure + repo_root = Path.cwd() + path, created = ensure( + plan, + repo_root=repo_root, + sync=not args.no_sync, + require_paths=require_paths, + dry_run=args.dry_run, + ) + + if args.json: + print( + json.dumps( + { + "name": plan.name, + "path": str(path), + # Alias of "path" — sdd-planner/sdd-research read this + # key name into their PlannerOutput/ResearchOutput + # `worktree_path` field (spec §8); both keys always + # carry the same value. + "worktree_path": str(path), + "base_ref": plan.base_ref, + "created": created, + } + ) + ) + else: + print(str(path)) + + return 0 + + except Exception as e: + sys.stderr.write(f"Error: {e}\n") + return 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/sdd/finalize_task.py b/scripts/sdd/finalize_task.py new file mode 100644 index 00000000..d22ab33f --- /dev/null +++ b/scripts/sdd/finalize_task.py @@ -0,0 +1,629 @@ +"""Finalize a verified task deterministically without committing or pushing. + +Spec `sdd/specs/sdd-execution-optimization.spec.md` (FEAT-584), module M4 +(R4 "Cierre de tarea mecánico"). Replaces the multi-step Edit/Write/jq/mv +dance a worker performs to close a task with one verifiable call: + +1. Validate identity/evidence/HEAD (never mutates on a stale or incomplete + input) and reject a divergent active/completed twin before doing + anything destructive. +2. Render a deterministic Completion Note + metrics table and persist a + resumable journal, OUTSIDE the worktree, under the same durable root + `parrot.flows.dev_loop.sdd_coder.telemetry.resolve_durable_root` + resolves for telemetry/evidence -- a crash between phases resumes from + observed disk/git state instead of duplicating effects. +3. Invoke the existing `scripts/sdd/close_task.sh` primitive under an + isolated `GIT_INDEX_FILE`, so its internal `git add -u sdd/tasks/active` + can never stage another task's pending edits, then transfer ONLY this + task's own index entries (its active/completed `.md` file and its + per-spec index) into the real index with a compare-and-swap guard + against a concurrent change to those same paths. +4. Verify the close_task.sh postconditions itself (no active survivor, a + completed copy carrying the rendered note, index status "done") before + reporting success. + +This module never runs `git add .`, `git reset`, `git commit`, `git push` +or closes any task other than the one named by the evidence. It returns +staged paths, the removed `active/` path and a suggested commit message; the worker performs the +commit and any semantic review decisions. +""" + +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, Sequence + +from pydantic import ValidationError + +from parrot.flows.dev_loop.sdd_coder.fidelity import check_fidelity, parse_task_files +from parrot.flows.dev_loop.sdd_coder.optimization_models import EvidenceRef, TaskCompletionEvidence +from parrot.flows.dev_loop.sdd_coder.telemetry import resolve_durable_root + +try: # POSIX only -- degrades to a no-op lock, matching evidence.py's convention. + import fcntl +except ImportError: # pragma: no cover - non-POSIX platform + fcntl = None # type: ignore[assignment] + +_FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_NOTE_HEADING = re.compile(r"^## Completion Note\s*$", re.M) +_NEXT_HEADING = re.compile(r"^## ", re.M) +_TELEMETRY_ROOT_ENV = "SDD_CODER_TELEMETRY_DIR" +_VERIFICATION = "verified" + + +class FinalizeTaskError(RuntimeError): + """Base class for every domain error `finalize_task`/`main` can raise.""" + + error_code = "operation_error" + + +class InvalidEvidenceError(FinalizeTaskError): + """Malformed input: bad SHA shape, missing evidence, unresolvable task file.""" + + error_code = "invalid_input" + + +class TaskEvidenceStaleError(FinalizeTaskError): + """HEAD moved, evidence does not match `expected_head`, or a closed task's + journal already recorded a different operation (a new SHA/evidence + payload requires a deliberate new operation, never a silent redo).""" + + error_code = "task_evidence_stale" + + +class TaskTwinConflictError(FinalizeTaskError): + """An active and a completed copy of the same task disagree on content.""" + + error_code = "task_twin_conflict" + + +class ConcurrentChangeError(FinalizeTaskError): + """The real git index changed for this task's own paths mid-operation.""" + + error_code = "concurrent_index_change" + + +class OperationError(FinalizeTaskError): + """close_task.sh failed, or a postcondition did not hold afterward.""" + + error_code = "operation_error" + + +# --------------------------------------------------------------------------- +# Small git/process helpers. +# --------------------------------------------------------------------------- + + +def _run_git( + args: Sequence[str], *, cwd: Path, env: dict[str, str] | None = None, check: bool = True +) -> subprocess.CompletedProcess[str]: + """Run one `git` subcommand and capture text output.""" + return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=check) + + +def _git_toplevel(worktree: Path) -> Path: + """Resolve the git repository root for *worktree*, or raise `InvalidEvidenceError`.""" + if not worktree.is_dir(): + raise InvalidEvidenceError(f"worktree path does not exist or is not a directory: {worktree}") + try: + out = _run_git(["rev-parse", "--show-toplevel"], cwd=worktree).stdout.strip() + except subprocess.CalledProcessError as exc: + raise InvalidEvidenceError(f"worktree is not inside a git repository: {worktree} ({exc.stderr})") from exc + if not out: + raise InvalidEvidenceError(f"worktree is not inside a git repository: {worktree}") + return Path(out).resolve() + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") + + +# --------------------------------------------------------------------------- +# Evidence/identity/HEAD validation -- never mutates anything. +# --------------------------------------------------------------------------- + + +def _validate_expected_head(evidence: TaskCompletionEvidence, expected_head: str, repo_root: Path) -> None: + """Reject a malformed, mismatched or stale `expected_head` before any mutation.""" + if not _FULL_SHA_RE.match(expected_head or ""): + raise InvalidEvidenceError(f"expected_head must be a full 40-hex-char git SHA, got {expected_head!r}") + if evidence.implementation_sha != expected_head: + raise TaskEvidenceStaleError( + f"evidence.implementation_sha {evidence.implementation_sha!r} != expected_head {expected_head!r}" + ) + current_head = _run_git(["rev-parse", "HEAD"], cwd=repo_root).stdout.strip() + if current_head != expected_head: + raise TaskEvidenceStaleError( + f"worktree HEAD {current_head!r} has moved away from expected_head {expected_head!r}" + ) + + +def _verify_evidence_refs(durable_root: Path, evidence: TaskCompletionEvidence) -> None: + """Resolve every evidence reference against durable storage; never trust a claim. + + Rejects an empty `validation_refs` (no settled validation recorded) and + any `EvidenceRef` whose content does not durably exist with matching + hash and size under *durable_root* -- `review_evidence` is never + fabricated. NOTE: this only proves each ref is a settled, tamper-evident + record (existence + hash/size match) -- it does NOT inspect or require + a passing (`outcome == "completed"`) validation. The checkpoint stays + neutral raw evidence by design (see engine.py's `_assert_no_pending_validations` + docstring); a downstream reviewer must open the referenced logs to judge + pass/fail, per AC9's "checkpoint neutral y diff completo por referencias". + """ + if not evidence.validation_refs: + raise InvalidEvidenceError("evidence.validation_refs must include at least one settled validation reference") + for ref in (evidence.review_evidence, *evidence.validation_refs): + _verify_one_ref(durable_root, ref) + + +def _verify_one_ref(durable_root: Path, ref: EvidenceRef) -> None: + resolved_root = durable_root.resolve() + candidate = (durable_root / ref.relative_path).resolve() + if candidate != resolved_root and resolved_root not in candidate.parents: + raise InvalidEvidenceError(f"evidence ref escapes the durable root: {ref.relative_path!r}") + if candidate.is_symlink() or not candidate.is_file(): + raise InvalidEvidenceError(f"evidence ref does not resolve to a durable artifact: {ref.relative_path!r}") + data = candidate.read_bytes() + if len(data) != ref.size_bytes or hashlib.sha256(data).hexdigest() != ref.sha256: + raise InvalidEvidenceError(f"evidence ref content does not match its declared hash/size: {ref.relative_path!r}") + + +def _strip_note_section(text: str) -> str: + """Return *text* with everything from '## Completion Note' onward removed.""" + match = _NOTE_HEADING.search(text) + return text if not match else text[: match.start()] + + +def _resolve_task_md(active_matches: list[Path], completed_matches: list[Path]) -> tuple[Path | None, Path | None, str]: + """Locate the task's active/completed `.md` file(s) and reject a divergent twin. + + Returns `(active_path_or_None, completed_path_or_None, source_text)`. + Comparison ignores each file's own '## Completion Note' section, since + that section legitimately differs while a close is in flight. + """ + if len(active_matches) > 1 or len(completed_matches) > 1: + raise InvalidEvidenceError("ambiguous task file matches: more than one active/completed candidate") + active = active_matches[0] if active_matches else None + completed = completed_matches[0] if completed_matches else None + if active is None and completed is None: + raise InvalidEvidenceError("no active or completed task file found for this task_id") + + if active is not None and completed is not None: + active_text = active.read_text(encoding="utf-8") + completed_text = completed.read_text(encoding="utf-8") + if _strip_note_section(active_text) != _strip_note_section(completed_text): + raise TaskTwinConflictError( + f"divergent active/completed twins for this task: {active} != {completed}; refusing to close" + ) + return active, completed, completed_text + + source_path = active if active is not None else completed + assert source_path is not None + return active, completed, source_path.read_text(encoding="utf-8") + + +def _collect_changed_files(repo_root: Path, implementation_sha: str, fix_commits: Iterable[str]) -> list[str]: + """Union of files changed by `implementation_sha` and every fix commit.""" + changed: set[str] = set() + for sha in (implementation_sha, *fix_commits): + out = _run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", sha], cwd=repo_root).stdout + changed.update(line.strip() for line in out.splitlines() if line.strip()) + return sorted(changed) + + +# --------------------------------------------------------------------------- +# Deterministic Completion Note rendering. +# --------------------------------------------------------------------------- + + +def _render_completion_note(evidence: TaskCompletionEvidence, *, closed_at: str) -> str: + """Render a stable Completion Note + metrics table from evidence alone. + + Deterministic across resumed calls: no wall-clock read here, only the + journaled `closed_at` and sorted evidence fields. + """ + fix_commits = ", ".join(evidence.fix_commits) if evidence.fix_commits else "none" + lines = [ + f"- Task: {evidence.task_id}", + f"- Feature: {evidence.feature_slug}", + f"- Implementation SHA: {evidence.implementation_sha}", + f"- Closed at (UTC): {closed_at}", + f"- Fix commits: {fix_commits}", + "", + "| Metric | Value |", + "|---|---|", + f"| validation_refs | {len(evidence.validation_refs)} |", + f"| fix_commits | {len(evidence.fix_commits)} |", + ] + for key in sorted(evidence.completion_facts): + lines.append(f"| {key} | {evidence.completion_facts[key]} |") + return "\n".join(lines) + "\n" + + +def _apply_completion_note(task_md: str, note: str) -> str: + """Replace the '## Completion Note' section body with *note* (idempotent).""" + match = _NOTE_HEADING.search(task_md) + if not match: + sep = "" if task_md.endswith("\n") else "\n" + return f"{task_md}{sep}\n## Completion Note\n\n{note}" + head = task_md[: match.end()] + rest = task_md[match.end() :] + tail_match = _NEXT_HEADING.search(rest) + tail = rest[tail_match.start() :] if tail_match else "" + return f"{head}\n\n{note}{tail}" + + +def _ensure_note_applied(path: Path, note: str) -> None: + """Write the rendered note into *path* only if it is not already present.""" + text = path.read_text(encoding="utf-8") + new_text = _apply_completion_note(text, note) + if new_text != text: + path.write_text(new_text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Journal: resumable operation state, outside any worktree/code file. +# --------------------------------------------------------------------------- + + +def _hash_evidence(evidence: TaskCompletionEvidence) -> str: + canonical = json.dumps(evidence.model_dump(mode="json"), sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _journal_paths(durable_root: Path, feature_slug: str, task_id: str) -> tuple[Path, Path]: + journal_dir = durable_root / "finalize_task" / feature_slug + return journal_dir / f"{task_id}.json", journal_dir / f".{task_id}.lock" + + +def _load_journal(path: Path) -> dict[str, object] | None: + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def _write_journal(path: Path, **fields: object) -> dict[str, object]: + path.parent.mkdir(parents=True, exist_ok=True) + existing = _load_journal(path) or {} + existing.update(fields) + existing["updated_at"] = _utcnow_iso() + existing.setdefault("created_at", existing["updated_at"]) + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(existing, sort_keys=True, indent=2), encoding="utf-8") + os.replace(tmp, path) + return existing + + +@contextlib.contextmanager +def _serialized(lock_path: Path): + """Hold an exclusive, blocking lock on *lock_path* -- one operation per task/index at a time.""" + lock_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644) + try: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_EX) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +# --------------------------------------------------------------------------- +# close_task.sh, invoked under an isolated GIT_INDEX_FILE. +# --------------------------------------------------------------------------- + + +def _stage_snapshot(repo_root: Path, real_index_path: Path, paths: Iterable[str]) -> dict[str, str | None]: + """One `git ls-files --stage` line per owned path in the REAL index (CAS baseline).""" + env = {**os.environ, "GIT_INDEX_FILE": str(real_index_path)} + snapshot: dict[str, str | None] = {} + for path in sorted(paths): + out = _run_git(["ls-files", "--stage", "--", path], cwd=repo_root, env=env, check=False).stdout.strip() + snapshot[path] = out or None + return snapshot + + +def _parse_name_status(diff_output: str) -> list[tuple[str, str]]: + entries: list[tuple[str, str]] = [] + for line in diff_output.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0][0] + entries.append((status, parts[1])) + return entries + + +def _belongs_to_task(path: str, task_id: str, feature_slug: str) -> bool: + if path == f"sdd/tasks/index/{feature_slug}.json": + return True + name = Path(path).name + if path.startswith("sdd/tasks/active/") and name.startswith(f"{task_id}-"): + return True + if path.startswith("sdd/tasks/completed/") and name.startswith(f"{task_id}-"): + return True + return False + + +def _ls_tree_entry(repo_root: Path, tree: str, path: str) -> tuple[str, str]: + out = _run_git(["ls-tree", tree, "--", path], cwd=repo_root).stdout.strip() + if not out: + raise OperationError(f"path {path!r} unexpectedly missing from tree {tree}") + meta, _, _ = out.partition("\t") + mode, _obj_type, blob_sha = meta.split() + return mode, blob_sha + + +def _close_task_isolated(repo_root: Path, task_id: str, feature_slug: str) -> tuple[list[str], list[str]]: + """Run `close_task.sh` under a throwaway index and transfer only this task's entries. + + `close_task.sh` internally runs `git add -u sdd/tasks/active`, which + would stage any OTHER task's pending edits if invoked directly against + the real index. Running it with `GIT_INDEX_FILE` pointed at a private + copy confines every index mutation there; only the entries this task + owns (its own active/completed `.md` and its per-spec index) are then + replayed onto the real index via targeted `git update-index` calls, + guarded by a compare-and-swap check against a concurrent change. + + Returns: + ``(staged, removed)``: paths added/updated in the real index, and + paths deleted from it (the task's old ``active/`` copy). Both are + already applied to the real index; ``removed`` is reported separately + because ``git add`` on a path gone from disk and index fails, yet a + pathspec-limited ``git commit -- <staged>`` would silently leave the + deletion out and the ``active/`` copy alive in HEAD. + """ + close_script = repo_root / "scripts" / "sdd" / "close_task.sh" + if not close_script.is_file(): + raise OperationError(f"close_task.sh not found at {close_script}") + + real_index_path = Path( + _run_git(["rev-parse", "--path-format=absolute", "--git-path", "index"], cwd=repo_root).stdout.strip() + ) + real_env = {**os.environ, "GIT_INDEX_FILE": str(real_index_path)} + + active_dir = repo_root / "sdd" / "tasks" / "active" + completed_dir = repo_root / "sdd" / "tasks" / "completed" + index_json = repo_root / "sdd" / "tasks" / "index" / f"{feature_slug}.json" + + owned_paths = { + p.relative_to(repo_root).as_posix() + for p in (*active_dir.glob(f"{task_id}-*.md"), *completed_dir.glob(f"{task_id}-*.md")) + } + owned_paths.add(index_json.relative_to(repo_root).as_posix()) + + before_snapshot = _stage_snapshot(repo_root, real_index_path, owned_paths) + orig_tree = _run_git(["write-tree"], cwd=repo_root, env=real_env).stdout.strip() + + with tempfile.TemporaryDirectory(prefix="sdd-finalize-index-") as tmp_dir: + tmp_index_path = Path(tmp_dir) / "index" + if real_index_path.exists(): + shutil.copyfile(real_index_path, tmp_index_path) + tmp_env = {**os.environ, "GIT_INDEX_FILE": str(tmp_index_path)} + + proc = subprocess.run( + ["bash", str(close_script), task_id, feature_slug, _VERIFICATION], + cwd=repo_root, + env=tmp_env, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise OperationError( + f"close_task.sh failed (exit {proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}" + ) + + new_tree = _run_git(["write-tree"], cwd=repo_root, env=tmp_env).stdout.strip() + + diff_output = _run_git(["diff", "--no-renames", "--name-status", orig_tree, new_tree], cwd=repo_root).stdout + changed = _parse_name_status(diff_output) + own_changed = [(status, path) for status, path in changed if _belongs_to_task(path, task_id, feature_slug)] + foreign_changed = [(status, path) for status, path in changed if not _belongs_to_task(path, task_id, feature_slug)] + if foreign_changed: + sys.stderr.write( + "finalize_task: ignoring foreign index entries close_task.sh staged in the isolated index: " + f"{foreign_changed}\n" + ) + + after_snapshot = _stage_snapshot(repo_root, real_index_path, owned_paths) + if after_snapshot != before_snapshot: + raise ConcurrentChangeError("the real git index changed concurrently for this task's own paths") + + staged: list[str] = [] + removed: list[str] = [] + for status, path in own_changed: + if status == "D": + _run_git(["update-index", "--force-remove", "--", path], cwd=repo_root, env=real_env) + removed.append(path) + else: + mode, blob_sha = _ls_tree_entry(repo_root, new_tree, path) + _run_git( + ["update-index", "--add", "--cacheinfo", f"{mode},{blob_sha},{path}"], + cwd=repo_root, + env=real_env, + ) + staged.append(path) + return sorted(staged), sorted(removed) + + +def _verify_postconditions(repo_root: Path, task_id: str, feature_slug: str, note: str) -> None: + """Hard-verify no active survivor, a completed copy with the note, and index status done.""" + active_dir = repo_root / "sdd" / "tasks" / "active" + completed_dir = repo_root / "sdd" / "tasks" / "completed" + + survivors = list(active_dir.glob(f"{task_id}-*.md")) + if survivors: + raise OperationError(f"postcondition failed: active copy still present: {survivors}") + + completed_matches = list(completed_dir.glob(f"{task_id}-*.md")) + if not completed_matches: + raise OperationError(f"postcondition failed: no completed copy found for {task_id}") + completed_text = completed_matches[0].read_text(encoding="utf-8") + if note.strip() not in completed_text: + raise OperationError("postcondition failed: rendered completion note missing from completed file") + + index_path = repo_root / "sdd" / "tasks" / "index" / f"{feature_slug}.json" + data = json.loads(index_path.read_text(encoding="utf-8")) + entry = next((t for t in data.get("tasks", []) if t.get("id") == task_id), None) + if entry is None or entry.get("status") != "done": + raise OperationError(f"postcondition failed: index status for {task_id} is not 'done'") + + +# --------------------------------------------------------------------------- +# Public entry point. +# --------------------------------------------------------------------------- + + +def finalize_task(*, evidence: TaskCompletionEvidence, worktree: Path, expected_head: str) -> dict[str, object]: + """Validate evidence, journal intent, close one task and return staged paths. + + Never `git add .`, reset, commit or push, and never closes a task other + than `evidence.task_id`. Idempotent replay for an identical + `task_id + implementation_sha + evidence` triple; a different SHA or + evidence for an already-closed task raises `TaskEvidenceStaleError` + instead of silently redoing the close. + """ + worktree = Path(worktree) + repo_root = _git_toplevel(worktree) + _validate_expected_head(evidence, expected_head, repo_root) + + configured_root = os.environ.get(_TELEMETRY_ROOT_ENV) or None + durable_root = resolve_durable_root(configured_root, worktree_base_path=str(repo_root)) + journal_path, lock_path = _journal_paths(durable_root, evidence.feature_slug, evidence.task_id) + + evidence_hash = _hash_evidence(evidence) + operation_key = f"{evidence.task_id}:{evidence.implementation_sha}:{evidence_hash}" + + with _serialized(lock_path): + journal = _load_journal(journal_path) + + if journal is not None and journal.get("phase") == "done": + if journal.get("operation_key") != operation_key: + raise TaskEvidenceStaleError( + f"{evidence.task_id} was already closed under a different implementation_sha/evidence; " + "a changed SHA or evidence requires a new, deliberate operation" + ) + _verify_postconditions(repo_root, evidence.task_id, evidence.feature_slug, str(journal["note"])) + result = dict(journal["result"]) # type: ignore[arg-type] + result["replayed"] = True + return result + + # -- validation: never mutates anything below this point until it passes. -- + _verify_evidence_refs(durable_root, evidence) + + index_path = repo_root / "sdd" / "tasks" / "index" / f"{evidence.feature_slug}.json" + if not index_path.is_file(): + raise InvalidEvidenceError(f"no per-spec index for feature_slug {evidence.feature_slug!r}: {index_path}") + + active_dir = repo_root / "sdd" / "tasks" / "active" + completed_dir = repo_root / "sdd" / "tasks" / "completed" + active_matches = sorted(active_dir.glob(f"{evidence.task_id}-*.md")) + completed_matches = sorted(completed_dir.glob(f"{evidence.task_id}-*.md")) + active_path, completed_path, task_md_text = _resolve_task_md(active_matches, completed_matches) + + expected_files = parse_task_files(task_md_text) + changed_files = _collect_changed_files(repo_root, evidence.implementation_sha, evidence.fix_commits) + report = check_fidelity(expected_files, changed_files) + if not report.ok: + raise InvalidEvidenceError( + f"fidelity check failed for {evidence.task_id}: unexpected={report.unexpected} " + f"sdd_touched={report.sdd_touched}" + ) + + closed_at = journal.get("closed_at") if journal and journal.get("operation_key") == operation_key else None + closed_at = str(closed_at) if closed_at else _utcnow_iso() + note = _render_completion_note(evidence, closed_at=closed_at) + + journal = _write_journal( + journal_path, + operation_key=operation_key, + phase="validated", + note=note, + closed_at=closed_at, + feature_slug=evidence.feature_slug, + task_id=evidence.task_id, + ) + + # -- mutation: apply the note to whichever copy currently exists. -- + source_path = active_path if active_path is not None else completed_path + assert source_path is not None + _ensure_note_applied(source_path, note) + + staged_paths, removed_paths = _close_task_isolated(repo_root, evidence.task_id, evidence.feature_slug) + _verify_postconditions(repo_root, evidence.task_id, evidence.feature_slug, note) + + result: dict[str, object] = { + "task_id": evidence.task_id, + "feature_slug": evidence.feature_slug, + "implementation_sha": evidence.implementation_sha, + "staged_paths": staged_paths, + "removed_paths": removed_paths, + "note": note, + "message": f"sdd: complete {evidence.task_id} for {evidence.feature_slug}", + "replayed": False, + } + _write_journal( + journal_path, + operation_key=operation_key, + phase="done", + note=note, + closed_at=closed_at, + feature_slug=evidence.feature_slug, + task_id=evidence.task_id, + result=result, + ) + return result + + +def main(argv: list[str] | None = None) -> int: + """Exit 0 success/replay, 1 operation error, 2 invalid input, 3 stale/busy.""" + parser = argparse.ArgumentParser(prog="python -m scripts.sdd.finalize_task", description=__doc__) + parser.add_argument("--evidence", required=True, help="Path to a JSON file with a TaskCompletionEvidence payload") + parser.add_argument("--worktree", required=True, help="Path to the task's git worktree") + parser.add_argument("--expected-head", required=True, help="Full 40-hex git SHA the caller expects HEAD to be") + args = parser.parse_args(argv) + + evidence_path = Path(args.evidence) + try: + raw = json.loads(evidence_path.read_text(encoding="utf-8")) + evidence = TaskCompletionEvidence.model_validate(raw) + except (OSError, json.JSONDecodeError, ValidationError) as exc: + print(json.dumps({"ok": False, "error": "invalid_input", "message": str(exc)})) + return 2 + + try: + result = finalize_task(evidence=evidence, worktree=Path(args.worktree), expected_head=args.expected_head) + except InvalidEvidenceError as exc: + print(json.dumps({"ok": False, "error": exc.error_code, "message": str(exc)})) + return 2 + except (TaskEvidenceStaleError, TaskTwinConflictError, ConcurrentChangeError) as exc: + print(json.dumps({"ok": False, "error": exc.error_code, "message": str(exc)})) + return 3 + except OperationError as exc: + print(json.dumps({"ok": False, "error": exc.error_code, "message": str(exc)})) + return 1 + + print(json.dumps({"ok": True, **result})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/heal_orphans.sh b/scripts/sdd/heal_orphans.sh new file mode 100755 index 00000000..f7dedf47 --- /dev/null +++ b/scripts/sdd/heal_orphans.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# heal_orphans.sh — Self-healing sweep for stalled SDD task files. +# +# Detects any file in sdd/tasks/active/ whose task is marked "done" in a +# per-spec index, and removes it (its canonical copy already lives in +# sdd/tasks/completed/). This is the safety net behind /sdd-done: even if a +# task-completion step copied instead of moved (leaving an active orphan), the +# orphan is reaped here before the feature is closed. +# +# A task is only reaped when ALL of these hold (defence in depth): +# 1. some per-spec index marks <TASK-ID> as status="done" +# 2. a completed/ twin file for the same TASK-ID exists +# Otherwise the active file is left untouched (it may be genuinely in flight). +# +# Usage: +# scripts/sdd/heal_orphans.sh [feature-slug] # one feature, or all if omitted +# scripts/sdd/heal_orphans.sh --dry-run [feature-slug] +# +# Exit codes: +# 0 swept (with or without removals) +# 1 usage / environment error +set -euo pipefail + +DRY_RUN=0 +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=1 + shift +fi +FEATURE_SLUG="${1:-}" + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +ACTIVE_DIR="sdd/tasks/active" +COMPLETED_DIR="sdd/tasks/completed" +INDEX_DIR="sdd/tasks/index" + +if [[ -n "$FEATURE_SLUG" ]]; then + indices=("$INDEX_DIR/${FEATURE_SLUG}.json") +else + shopt -s nullglob + indices=("$INDEX_DIR"/*.json) + shopt -u nullglob +fi + +# Collect the set of TASK-IDs that are "done" across the selected indices. +done_ids="$( + for idx in "${indices[@]}"; do + [[ -f "$idx" ]] || continue + jq -r '.tasks[]? | select(.status == "done") | .id' "$idx" 2>/dev/null || true + done | sort -u +)" + +reaped=0 +kept=0 +while IFS= read -r tid; do + [[ -z "$tid" ]] && continue + shopt -s nullglob + active_matches=("$ACTIVE_DIR/${tid}-"*.md) + completed_matches=("$COMPLETED_DIR/${tid}-"*.md) + shopt -u nullglob + [[ ${#active_matches[@]} -eq 0 ]] && continue + if [[ ${#completed_matches[@]} -eq 0 ]]; then + echo "⚠️ ${tid}: done but NO completed/ twin — leaving active file (manual review)." + kept=$((kept + 1)) + continue + fi + for f in "${active_matches[@]}"; do + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "would reap: $f" + else + # git rm stages the deletion when tracked; --ignore-unmatch exits 0 for + # untracked files WITHOUT touching the working tree, so rm -f afterwards + # unconditionally to guarantee the file is gone either way. + git rm -q --ignore-unmatch "$f" >/dev/null 2>&1 || true + rm -f "$f" + echo "reaped orphan: $f" + fi + reaped=$((reaped + 1)) + done +done <<< "$done_ids" + +echo "── heal_orphans: ${reaped} orphan(s) $([[ $DRY_RUN -eq 1 ]] && echo 'would be reaped' || echo 'reaped'), ${kept} kept for review." +exit 0 diff --git a/scripts/sdd/id_ledger.py b/scripts/sdd/id_ledger.py new file mode 100644 index 00000000..ca4c0ca0 --- /dev/null +++ b/scripts/sdd/id_ledger.py @@ -0,0 +1,220 @@ +"""``IdLedger`` model + bootstrap for the git-native TASK/FEAT ID allocator. + +This module defines the tiny, git-tracked "next ID" ledger +(``sdd/tasks/.id_ledger.json``) used by ``scripts/sdd/reserve_ids.py`` as a +compare-and-swap counter for globally-unique ``TASK-<NNN>`` (and +best-effort ``FEAT-<NNN>``) numbers (FEAT-387). + +``IdLedger`` is deliberately kept a pure data container — no +reserve/increment logic lives here. That belongs to ``reserve_ids.py`` +(Module 2), keeping this module and its allocator independently testable +per the spec's Module Breakdown. + +Usage: + python -m scripts.sdd.id_ledger bootstrap [--index-dir DIR] [--specs-dir DIR] [--out PATH] +""" + +from __future__ import annotations + +import argparse +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +from pydantic import BaseModel, Field + +#: Default path to the git-tracked ledger file. +LEDGER_PATH = Path("sdd/tasks/.id_ledger.json") + +#: Matches the spec header line, e.g. "**Feature ID**: FEAT-387". +_FEATURE_ID_HEADER_RE = re.compile(r"\*\*Feature ID\*\*:\s*FEAT-(\d+)") + + +class IdLedger(BaseModel): + """The full contents of ``sdd/tasks/.id_ledger.json``. + + A single, tiny, git-tracked file acting as a compare-and-swap counter + for globally-unique ``TASK-<NNN>`` numbers (and, best-effort, + ``FEAT-<NNN>`` numbers). Every allocator reads this file, computes a + reservation, and races to push an update — the push itself is the + compare-and-swap: a non-fast-forward rejection means someone else + already advanced the counter, so the allocator must re-read and retry. + """ + + next_task_id: int = Field(..., ge=1, description="Next unassigned TASK number.") + next_feature_id: int = Field(..., ge=1, description="Next unassigned FEAT number.") + updated_at: str = Field(..., description="ISO-8601 UTC timestamp of the last reservation.") + updated_by: str = Field( + ..., + description=( + "Free-text origin of the last reservation (feature slug or " + "session id) — diagnostic only, not used for correctness." + ), + ) + + +def load_ledger(path: Path) -> IdLedger: + """Load and validate an ``IdLedger`` from ``path``. + + Args: + path: Path to the ledger JSON file. + + Returns: + The parsed and validated ``IdLedger``. + """ + return IdLedger.model_validate_json(path.read_text(encoding="utf-8")) + + +def save_ledger(path: Path, ledger: IdLedger) -> None: + """Write ``ledger`` to ``path`` as byte-stable JSON. + + Matches ``migrate_index.py``'s own output convention + (``json.dumps(..., indent=2, sort_keys=False)`` plus a trailing + newline) so diffs stay small and reviewable. + + Args: + path: Destination path for the ledger JSON file. + ledger: The ``IdLedger`` to serialize. + """ + path.write_text( + json.dumps(ledger.model_dump(), indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) + + +def _max_task_id(index_dir: Path) -> int: + """Return the highest ``TASK-<NNN>`` number found across ``index_dir``. + + Scans every ``*.json`` file in ``index_dir`` (including + ``_orphans.json`` — it carries real task entries with real IDs, even + though it has no ``feature_id`` header worth scanning for the FEAT + counter) and returns ``0`` if none are found. + """ + max_id = 0 + for index_file in index_dir.glob("*.json"): + try: + doc = json.loads(index_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + for task in doc.get("tasks", []) or []: + task_id = task.get("id") + if not isinstance(task_id, str): + continue + match = re.match(r"TASK-(\d+)", task_id) + if match: + max_id = max(max_id, int(match.group(1))) + return max_id + + +def _max_feature_id(index_dir: Path, specs_dir: Path) -> int: + """Return the highest ``FEAT-<NNN>`` number across index headers + specs. + + Scans both ``index_dir/*.json`` (top-level ``feature_id`` header, + skipping ``_orphans.json`` since it has no meaningful header) AND + ``specs_dir/*.md`` (the ``**Feature ID**: FEAT-<NNN>`` line), taking + the max across both so the ledger never seeds behind an ID already in + use by a spec with no tasks generated yet. + """ + max_id = 0 + + for index_file in index_dir.glob("*.json"): + if index_file.name == "_orphans.json": + continue + try: + doc = json.loads(index_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + feature_id = doc.get("feature_id") + if isinstance(feature_id, str): + match = re.match(r"FEAT-(\d+)", feature_id) + if match: + max_id = max(max_id, int(match.group(1))) + + for spec_file in specs_dir.glob("*.md"): + try: + text = spec_file.read_text(encoding="utf-8") + except OSError: + continue + for match in _FEATURE_ID_HEADER_RE.finditer(text): + max_id = max(max_id, int(match.group(1))) + + return max_id + + +def bootstrap_ledger( + index_dir: Path = Path("sdd/tasks/index"), + specs_dir: Path = Path("sdd/specs"), +) -> IdLedger: + """Seed a fresh ``IdLedger`` strictly ahead of every ID currently in use. + + Scans ``sdd/tasks/index/*.json`` (all ``id``/``feature_id`` fields) AND + ``sdd/specs/*.md`` (the ``**Feature ID**: FEAT-<NNN>`` header line) for + the current maximum ``TASK-<NNN>`` and ``FEAT-<NNN>`` respectively, and + returns an ``IdLedger`` with ``next_task_id``/``next_feature_id`` set to + ``max + 1``. + + Args: + index_dir: Directory containing per-spec index JSON files. + specs_dir: Directory containing spec markdown files. + + Returns: + A freshly bootstrapped ``IdLedger``. + """ + max_task = _max_task_id(index_dir) + max_feature = _max_feature_id(index_dir, specs_dir) + + return IdLedger( + next_task_id=max_task + 1, + next_feature_id=max_feature + 1, + updated_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"), + updated_by="bootstrap", + ) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: ``python -m scripts.sdd.id_ledger bootstrap``.""" + parser = argparse.ArgumentParser( + description="IdLedger bootstrap for the SDD TASK/FEAT ID allocator (FEAT-387).", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + bootstrap_parser = subparsers.add_parser( + "bootstrap", + help="Seed sdd/tasks/.id_ledger.json strictly ahead of every ID currently in use.", + ) + bootstrap_parser.add_argument( + "--index-dir", + type=Path, + default=Path("sdd/tasks/index"), + help="Directory containing per-spec index JSON files (default: sdd/tasks/index)", + ) + bootstrap_parser.add_argument( + "--specs-dir", + type=Path, + default=Path("sdd/specs"), + help="Directory containing spec markdown files (default: sdd/specs)", + ) + bootstrap_parser.add_argument( + "--out", + type=Path, + default=LEDGER_PATH, + help=f"Destination path for the bootstrapped ledger (default: {LEDGER_PATH})", + ) + + args = parser.parse_args(argv) + + if args.command == "bootstrap": + ledger = bootstrap_ledger(index_dir=args.index_dir, specs_dir=args.specs_dir) + save_ledger(args.out, ledger) + print( + f"Bootstrapped {args.out}: next_task_id={ledger.next_task_id}, " + f"next_feature_id={ledger.next_feature_id}" + ) + return 0 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/insight.py b/scripts/sdd/insight.py new file mode 100644 index 00000000..aa0b41bb --- /dev/null +++ b/scripts/sdd/insight.py @@ -0,0 +1,2079 @@ +#!/usr/bin/env python3 +""" +SDD Insight — AI-Parrot internal AI-fluency + SDD-process analyzer. + + python3 scripts/sdd/insight.py + +Adapted from the open-source "Claude Insight v2" engine +(https://github.com/Feloguarin/claude-insight, MIT — see +reference/sdd-insight/UPSTREAM-LICENSE). The upstream personal AI-fluency +analysis is preserved verbatim (Layer 1); AI-Parrot adds a second, SDD-aware +layer (Layer 2) that scores how well this repo follows its own Spec-Driven +Development workflow (brainstorm -> proposal -> spec -> task -> done -> review), +computed deterministically from the `sdd/` artifact tree and surfaced as a +sixth "Process Discipline" panel on top of the personal report. + +Reads your local Claude Code transcripts (~/.claude/projects/**/*.jsonl), +estimates how skillfully you drive an AI coding agent, and writes a single +self-contained HTML report (./ai_fluency_report.html) that opens in your browser. + +Design principles (see README "Methodology"): + * It measures SKILL, not activity. Every score input is a per-prompt or + per-opportunity RATE pushed through a saturating curve, so using the agent + MORE can never raise your score — only using it BETTER can. + * It only looks at YOUR real typed prompts and Claude's real tool actions. + Tool-results, subagent turns, slash-command stubs, injected system text and + pasted walls of text are filtered out before anything is scored. + * Every number is auditable: baselines are recomputed from your corpus at + runtime, formulas are documented, and thin signals are flagged "low data" + and pulled toward a neutral 50 instead of faking confidence. + +Pure Python standard library — no pip, no Ollama, no API key. One command runs the +whole pass: de-contaminate and scrub your transcripts, score them, and (as +`/ai-fluency` in Claude Code) write a Sonnet+Opus skill map grounded in the AI +Fluency framework on top. The only thing it writes is +the HTML report and a local copy of your transcripts in an archive +(~/.claude/insight-archive) so history survives Claude Code's 30-day cleanup — +pass --no-archive to skip that and read your transcripts without copying them. +""" + +import argparse +import glob +import hashlib +import html +import json +import math +import os +import re +import shutil +import statistics +import sys +import webbrowser +from collections import Counter, defaultdict +from datetime import datetime + +# --------------------------------------------------------------------------- # +# Constants & tunables (documented; shown in the report's methodology appendix) +# --------------------------------------------------------------------------- # + +DEFAULT_DIRS = ["~/.claude/projects", "~/.claude/sessions"] + +# Claude Code deletes transcripts older than its `cleanupPeriodDays` setting (default 30), +# so by default only ~30 days of history is ever on disk. We mirror each run's transcripts +# into this persistent archive so history accumulates indefinitely and survives the cleanup. +# Keep this on a PRIVATE, per-person path. A single archive folder shared between different +# people or computers (e.g. a synced team Dropbox) merges everyone's transcripts into one +# analysis — so each person must point --archive at their own location, not a shared one. +DEFAULT_ARCHIVE_DIR = "~/.claude/insight-archive" + +GAP_CAP_SECONDS = 300 # idle gaps longer than this are NOT counted as active time +MAX_HUMAN_PROMPT_CHARS = 6000 # anything longer is treated as a paste/injection, not a typed prompt +PROVISIONAL_MIN_PROMPTS = 30 # below this the headline score is shown as a hedged range + +EDIT_TOOLS = {"edit", "write", "multiedit", "notebookedit"} +READ_TOOLS = {"read", "grep", "glob"} + +# Text that marks a "user"-role record as injected/system rather than typed by the human. +INJECTION_MARKERS = ( + "<task-notification>", "<command-name>", "<command-message>", "<command-args>", + "<local-command-caveat>", "<local-command-stdout>", "<system-reminder>", + "<bash-input>", "<bash-stdout>", "caveat: the messages below", + "[request interrupted", "base directory for this skill", "<user-prompt-submit-hook>", + "<user-memory-input>", "this session is being continued", +) + +# Subagent system prompts get stored as plain user-role text with no other marker. +# They almost always open with "You are <role>…". This catches the back-door inflation. +_INJECTED_HEAD = re.compile( + r"^\s*(you are\b|<[a-z][\w-]*>|base directory for this skill)", re.I +) + +# A Claude Code slash command is recorded as a user message wrapping +# <command-name>/sdd-spec</command-name>. We extract the command name as workflow +# telemetry even though the stub itself is filtered out of the scored prompt corpus. +_CMD_NAME_RE = re.compile(r"<command-name>\s*(/?[\w-]+)\s*</command-name>", re.I) + +# Broad, project-extensible verification matcher (matched against real Bash commands). +VERIFY_RE = re.compile( + r"\b(" + r"pytest|unittest|jest|vitest|mocha|go test|cargo (test|build|check)|" + r"npm (run )?(test|build|lint)|yarn (test|build|lint)|pnpm (test|build|lint)|" + r"ruff|eslint|flake8|mypy|tsc\b|make (test|lint|build|check)|playwright|" + r"python\d? -m \w|\.venv/bin/python|lsof -ti|curl .*(localhost|127\.0\.0\.1)|" + r"docker compose|docker-compose|pre-commit" + r")", + re.I, +) +# Clean-teardown of a live system (small bonus, folded into Verification). +TEARDOWN_RE = re.compile(r"(lsof -ti.*kill|pkill|kill -9|docker compose down|docker-compose down)", re.I) + +# Direction (prompt-quality) cues. +ARTIFACT_RE = re.compile( + r"([\w./\-]+\.(py|js|ts|tsx|jsx|html|css|md|json|sh|ya?ml|toml|rs|go|java|cpp|c|rb|sql))" + r"|((?:/[\w.\-]+){2,})" # multi-segment paths (not bare /word or </tag>) + r"|(`[^`]+`)" # inline code / quoted token + r"|(\b\w+\(\))", # function() reference + re.I, +) +CONSTRAINT_CUE = re.compile( + r"\b(only|must|should|shouldn't|don't|do not|never|always|keep|ensure|instead of|" + r"at most|at least|exactly|without|except|make sure|no more than|leave .* as is)\b", re.I +) +INTENT_CUE = re.compile( + r"\b(so that|because|the goal is|in order to|for the demo|for my|for the|so i can|so we can|" + r"so it|i need|i want .* so)\b", re.I +) +ACTION_VERB = re.compile( + r"\b(add|create|build|make|implement|write|fix|change|update|refactor|remove|delete|run|" + r"generate|set up|setup|install|deploy|edit|rename|move|clean|stitch|speed up|merge|split)\b", re.I +) + +# Iteration cues. +CORRECTION_CUE = re.compile( + r"\b(no|nope|wrong|not quite|that's not|thats not|actually|instead|revert|undo|redo|try again|" + r"too (aggressive|agressive|much|many|slow|fast|big|small)|still (broken|failing|wrong|not)|" + r"doesn't work|does not work|not working|unteligible|unteliggeble)\b", re.I +) +PRAISE_CUE = re.compile(r"\b(great|perfect|love it|nice|awesome|excellent|beautiful|exactly)\b", re.I) +CORRECTION_RATE_CEILING = 0.35 # a "high" correction rate; lower is better + +# Delegation / planning tool signals. +DELEGATION_TOOLS = {"agent", "task", "workflow", "exitplanmode", "enterplanmode"} + +# Dimension weights (sum to 1.0). +WEIGHTS = { + "Direction": 0.24, + "Verification": 0.22, + "Context": 0.22, + "Iteration": 0.18, + "Toolcraft": 0.14, +} +# Opportunity-count targets for per-dimension confidence shrinkage. +TARGET_N = {"Direction": 60, "Verification": 15, "Context": 25, "Iteration": 12, "Toolcraft": 40} + +# User-facing labels. "Direction" is shown as "Briefing" so it never collides with the +# "Director" archetype (the dimension measures how well you brief; the archetype, that +# you delegate — different things). +DISPLAY_NAMES = {"Direction": "Briefing", "Verification": "Verification", + "Context": "Context-setting", "Iteration": "Iteration", "Toolcraft": "Toolcraft"} + +def disp(name): + return DISPLAY_NAMES.get(name, name) + +# Teacher content for each skill (kind, plain-English, with before/after examples and a +# weekly practice). Used to make the report explain what to improve and exactly how. +SKILL_TEACH = { + "Direction": { + "what_it_is": "Telling the agent what you want and giving it something to aim at: a goal plus a file, a constraint, or a way to know it worked.", + "why_it_matters": "When your goal and your limits are clear up front, the agent gets it right the first time instead of guessing and pulling you into rounds of fixes.", + "how_to_improve": "Before you hit enter, add one anchor to your goal: the file to touch, a rule it must not break, or a 'done when…' line. One line is plenty.", + "examples": [ + {"before": "fix the login bug", "after": "Users stay logged out after a correct password on Safari. The check lives in src/auth/session.ts. Fix it so a valid login sets the session cookie, and keep the current tests green."}, + {"before": "add caching to the API", "after": "Cache GET /products responses in api/products.py for 60s to ease DB load on repeat reads. Don't cache authed requests, and add a test that a second call within 60s skips the DB."}, + ], + "practice": "Before sending a prompt, add one anchor to your goal: a file path, a constraint, or a 'done when…' line.", + "good_looks_like": "Every request says what you want plus where to work or how success is judged, so the agent acts instead of guessing.", + }, + "Verification": { + "what_it_is": "Having the agent prove its own work — run the tests, build, lint, or launch the app — before it tells you it's done.", + "why_it_matters": "Code that looks right but was never run is where most AI bugs hide; checking it turns “probably works” into “I watched it work.”", + "how_to_improve": "In the same prompt that asks for the change, name the exact command that proves it (a test, build, lint, or curl) and tell the agent to run it and show you the output before stopping.", + "examples": [ + {"before": "Fix the off-by-one in the pagination helper.", "after": "Fix the off-by-one in the pagination helper, then run `pytest tests/test_pagination.py -x` and paste the output. Don't call it fixed until that test passes."}, + {"before": "Add a /health endpoint to the FastAPI server.", "after": "Add a /health endpoint to the FastAPI server. Start it on port 8000, curl `localhost:8000/health`, and show me the response. Run `ruff check` too and confirm it's clean before you finish."}, + ], + "practice": "Before you accept any change, ask: “How did you verify this? Run it and show me the output.”", + "good_looks_like": "Every change ends with proof — a passing test, a green build, a real response — pasted back to you, not just a claim.", + }, + "Context": { + "what_it_is": "Pointing the agent at the real code — a file, a function, a line area — and having it read that before it changes anything.", + "why_it_matters": "When the agent sees the actual current code first, its edits fit what's really there instead of a guess, so they apply cleanly the first time.", + "how_to_improve": "Before any edit, name the exact file (and the function or area if you can) and tell the agent to read it first. Let it look before it leaps.", + "examples": [ + {"before": "Add retry logic to the API client.", "after": "Read src/api/client.ts first, then add retry-with-backoff to the request() method. Show me the change before you apply it."}, + {"before": "Fix the timezone bug in the date formatter.", "after": "Open src/utils/date.ts and find formatDate(). Read how it handles timezones now, then fix the off-by-one so UTC inputs render in the user's local zone."}, + ], + "practice": "Start your next edit request with “Read <file> first, then…” so the agent grounds itself before touching anything.", + "good_looks_like": "Every edit lands on code the agent just read, so diffs apply cleanly with nothing broken around them.", + }, + "Iteration": { + "what_it_is": "When the agent goes the wrong way, steering it back with a precise correction — naming what broke and the rule to follow — instead of just “no” or “try again.”", + "why_it_matters": "A precise correction lands the fix in one round; a vague “no” makes the agent guess again, and you burn turns while the code drifts further off.", + "how_to_improve": "When a result is wrong, say three things in one message: the symptom you saw, the rule it broke, and what to do instead. Then let it run.", + "examples": [ + {"before": "no that's not right, try again", "after": "The retry loop catches the exception but never re-raises after the last attempt, so failures look like successes. Re-raise the original error once retries run out, and keep the existing backoff."}, + {"before": "this is wrong, fix the test", "after": "The test passes because you mocked the function under test instead of the network call. Don't mock get_user — mock requests.get inside it, and assert it was called with the real URL."}, + ], + "practice": "Before sending a correction, check it names both the symptom and the rule. If it only says “no,” add the missing half.", + "good_looks_like": "One sharp correction — symptom, rule, and the fix — and the agent lands it on the next try.", + }, + "Toolcraft": { + "what_it_is": "Letting the agent use the right tool for each step — searching the code, running commands, starting the app, working in the background — instead of forcing everything through chat.", + "why_it_matters": "The agent works faster and more reliably when it searches and runs things for real, rather than reasoning about the code from memory.", + "how_to_improve": "Tell the agent which action to take first — search the codebase, run the suite, start the server — so it gathers facts and checks its work with the tool built for each step.", + "examples": [ + {"before": "How does login work in this app?", "after": "Search the codebase for the login flow (grep for auth, session, login), read the files you find, then explain how a request goes from form submit to a logged-in session."}, + {"before": "Add a retry to the API client, and make sure the tests still pass.", "after": "Add retry-with-backoff to the API client. Then run the suite in the background; if anything fails, read the failure, fix it, and report back when it's green."}, + ], + "practice": "Add one line to your next task telling the agent which action to take first: “search for…”, “run the tests”, or “start the server and check.”", + "good_looks_like": "You hand off a whole job and the agent searches, edits, runs, and verifies on its own — each step using the tool made for it.", + }, +} + +BANDS = [ + ("Operator", 0, 39, "You use the agent as fast hands. Prompts are short and underspecified, " + "edits often happen without reading the file first, and changes are rarely verified. The " + "fastest gains live right here: state a goal plus one constraint, and let the agent read " + "before it edits."), + ("Developing", 40, 54, "Real back-and-forth is emerging and one or two habits are solid. Some " + "prompts carry a file path or a constraint; verification happens occasionally. The gap to the " + "next level is consistency — doing the right thing by default, not just sometimes."), + ("Proficient", 55, 69, "You drive the agent deliberately. Most prompts are specific, edits " + "usually follow a read of the same file, and you verify more often than not. Solid, reliable " + "AI-assisted engineering. Remaining gains are about altitude (saying why) and orchestration."), + ("Advanced", 70, 84, "You orchestrate rather than operate. Prompts encode goals, constraints " + "and acceptance criteria; reading precedes editing as a habit; verification is near-automatic; " + "you use planning and delegation fluently. You brief the agent like a senior teammate."), + ("Expert", 85, 100, "You treat the agent as a managed engineering system: consistently " + "high-context prompts with explicit success criteria, disciplined read→edit→verify loops, " + "deliberate delegation, and almost no wasted correction cycles."), +] + +# Archetype axes and prototypes. +# The archetype describes YOUR DRIVING STYLE, so it is built only from signals you +# control and DISCOUNTS the habits Claude does on its own. Verification and Context +# (read-before-edit, running tests) are largely the agent's defaults, so they carry +# low "agency" weight; how you brief (Direction), correct (Iteration), reach for tools +# (Toolcraft) and hand off work (Delegation) carry full weight. +ARCHETYPE_AXES = ["Direction", "Verification", "Context", "Iteration", "Toolcraft", "Delegation"] +AGENCY = {"Direction": 1.0, "Verification": 0.35, "Context": 0.15, + "Iteration": 1.0, "Toolcraft": 0.8, "Delegation": 1.0} + +# Prototype vectors over ARCHETYPE_AXES (0-100). Delegation is the axis that separates +# a hands-off delegator from a hands-on builder. These are the five explicit, recognizable +# builder archetypes; the classifier picks the nearest one from your AGENCY-WEIGHTED vector. +PROTOTYPES = { + "Autonomous Agent": {"emoji": "🤖", "vec": [58, 65, 62, 62, 85, 96], + "blurb": "You delegate whole, end-to-end jobs and trust the agent to run them — you set the outcome and let Claude pick the steps."}, + "Architect": {"emoji": "🏗️", "vec": [80, 66, 88, 65, 60, 48], + "blurb": "You plan and explore before you build — you read and design first, so changes land on a clear structure."}, + "Debugger": {"emoji": "🐛", "vec": [62, 88, 82, 85, 60, 28], + "blurb": "You hunt problems methodically — read to diagnose, change, verify, and repeat until it's truly fixed."}, + "Collaborator": {"emoji": "🤝", "vec": [66, 62, 66, 80, 55, 38], + "blurb": "You work with the agent like a teammate — ask for options, give feedback, and steer toward alignment."}, + "Sprinter": {"emoji": "⚡", "vec": [45, 38, 52, 46, 62, 30], + "blurb": "You move fast and direct — terse prompts, quick turns, low ceremony. Great velocity; briefing and verification are the growth edges."}, +} +ARCHETYPE_MARGIN = 0.06 # cosine-similarity margin below which we emit a blended label + + +# --------------------------------------------------------------------------- # +# Parsing +# --------------------------------------------------------------------------- # + +def _parse_ts(s): + if not s: + return None + try: + return datetime.fromisoformat(str(s).replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def _text_of(content): + """Concatenate the text blocks of a message content (str or list).""" + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join( + b.get("text", "") for b in content + if isinstance(b, dict) and b.get("type") == "text" + ) + return "" + + +def _is_tool_result(content): + return isinstance(content, list) and any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content + ) + + +def _looks_injected(text): + head = text[:200].lstrip() + if len(text) > MAX_HUMAN_PROMPT_CHARS: + return True + if _INJECTED_HEAD.match(head): + return True + low = text.lower() + return any(m in low for m in INJECTION_MARKERS) + + +def _denamespace_tool(name): + """mcp__<hash>__slack_read_thread -> slack_read_thread; keep core names as-is.""" + if name.startswith("mcp__"): + parts = name.split("__") + return parts[-1] if parts else name + return name + + +# Redact machine-identifying home paths from free text before it is shown in the report or +# written to the evidence bundle. Applied only at PRESENTATION, never to the scored corpus, +# so scores stay byte-identical. +_HOME_PATH_RE = re.compile(r"(?:/Users/|/home/)[^/\s]+") +_WIN_HOME_RE = re.compile(r"[A-Za-z]:\\Users\\[^\\\s]+") + + +def _scrub_paths(text): + """/Users/<name>/x -> ~/x ; bare /Users/<name> -> ~ ; same for /home/<name> and Windows.""" + if not isinstance(text, str): + return text + text = _HOME_PATH_RE.sub("~", text) + text = _WIN_HOME_RE.sub("~", text) + return text + + +class Corpus: + """Everything we measured from the transcripts, cleanly separated from scoring.""" + + def __init__(self): + self.files = 0 + self.projects = set() + self.total_bytes = 0 + self.user_records = 0 + self.filtered = Counter() # why user records were not counted as prompts + self.real_prompts = [] # list of dicts: text, project, session, idx + self.tool_usage = Counter() # de-namespaced tool name -> count + self.total_tool_calls = 0 + self.delegation_events = 0 + self.all_commands = Counter() # slash-command invocations seen (any /command) + self.sdd_commands = Counter() # subset: /sdd-* invocations (process telemetry) + self.first_ts = None + self.last_ts = None + self.active_seconds = 0.0 + # Per-session ordered timelines of {"kind": "prompt"|"tool", ...} + self.sessions = {} # session_id -> {"project","timeline":[...]} + + +# Agent-to-agent transcripts (Claude Code subagents, Workflow runs) live under a +# ".../subagents/..." path. They are NOT the user's own prompts — counting them would +# contaminate the assessment and inflate counts every time a workflow is run — so they +# are excluded from discovery (an explicitly named single file is still honored). +_SUBAGENT_RE = re.compile(r"[/\\]subagents[/\\]") + + +def _filter_transcripts(paths): + return [p for p in paths if not _SUBAGENT_RE.search(p)] + + +def discover_files(explicit): + if explicit: + p = os.path.expanduser(explicit) + if os.path.isfile(p) and p.endswith(".jsonl"): + return [p] + if os.path.isdir(p): + return _filter_transcripts(sorted(glob.glob(os.path.join(p, "**", "*.jsonl"), recursive=True))) + return [] + env = os.environ.get("CLAUDE_PROJECTS_DIR") + roots = [env] if env else DEFAULT_DIRS + files = [] + for r in roots: + rp = os.path.expanduser(r) + if os.path.isdir(rp): + files.extend(glob.glob(os.path.join(rp, "**", "*.jsonl"), recursive=True)) + return _filter_transcripts(sorted(set(files))) + + +def _dedupe_sessions(files): + """When the same session shows up in more than one root (the live ~/.claude/projects dir + AND the persistent archive — possibly under a since-renamed project folder, a different-case + path, or a synced copy from another machine), keep a single copy of it: the largest one, + since transcripts only ever grow, so the biggest file is the most complete. Claude Code + session filenames are globally-unique IDs, so the filename alone identifies the session — + keying on it (not the parent folder) is what makes the dedupe robust to all of the above.""" + best = {} + for path in files: + key = os.path.basename(path) + try: + size = os.path.getsize(path) + except OSError: + size = -1 + cur = best.get(key) + if cur is None or size > cur[0]: + best[key] = (size, path) + return sorted(p for _, p in best.values()) + + +def archive_transcripts(live_files, archive_dir): + """Copy live transcripts into a persistent archive so they survive Claude Code's + `cleanupPeriodDays` deletion. Each file is mirrored to + <archive>/<project folder>/<session>.jsonl. We copy only when the archived copy is + missing or strictly smaller than the live one (transcripts only grow, so a >= archive copy + is the more complete one and must never be overwritten with a smaller/equal one). We write + via a temp file + atomic replace, re-checking the archive size just before the swap so a + concurrent run can't clobber a larger copy, and always clean up the temp file. + Returns (n_new, n_updated); a stderr note is printed if any file could not be archived.""" + arch_root = os.path.expanduser(archive_dir) + new = updated = failed = 0 + for path in live_files: + project = os.path.basename(os.path.dirname(path)) or "default" + dest_dir = os.path.join(arch_root, project) + dest = os.path.join(dest_dir, os.path.basename(path)) + try: + live_size = os.path.getsize(path) + except OSError: + continue + arch_size = os.path.getsize(dest) if os.path.exists(dest) else -1 + if arch_size >= live_size: + continue # already archived an equal-or-more-complete copy + tmp = dest + ".tmp" + try: + os.makedirs(dest_dir, exist_ok=True) + shutil.copyfile(path, tmp) + # Another run may have grown the archive while we were copying — don't shrink it. + current = os.path.getsize(dest) if os.path.exists(dest) else -1 + if current >= live_size: + continue + os.replace(tmp, dest) # atomic; never leaves a half-written archive copy + except OSError: + failed += 1 + continue + finally: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + if arch_size < 0: + new += 1 + else: + updated += 1 + if failed: + print(f" Note: {failed} transcript(s) could not be archived to {archive_dir} " + f"(check permissions / disk space). They were still analyzed from disk.", + file=sys.stderr) + return new, updated + + +def parse(files): + c = Corpus() + c.files = len(files) + for path in files: + project = os.path.basename(os.path.dirname(path)) or "default" + c.projects.add(project) + try: + c.total_bytes += os.path.getsize(path) + except OSError: + pass + session_id = os.path.splitext(os.path.basename(path))[0] + timeline = [] + ts_in_file = [] + prompt_idx = 0 + try: + fh = open(path, encoding="utf-8") + except OSError: + continue + with fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except json.JSONDecodeError: + continue + ts = _parse_ts(e.get("timestamp")) + if ts: + ts_in_file.append(ts) + c.first_ts = ts if c.first_ts is None or ts < c.first_ts else c.first_ts + c.last_ts = ts if c.last_ts is None or ts > c.last_ts else c.last_ts + msg = e.get("message") if isinstance(e.get("message"), dict) else {} + role = e.get("role") or msg.get("role") or e.get("type") + content = msg.get("content", e.get("content")) + + if role == "assistant": + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "tool_use": + raw = b.get("name", "unknown") + name = _denamespace_tool(raw) + c.tool_usage[name] += 1 + c.total_tool_calls += 1 + inp = b.get("input", {}) if isinstance(b.get("input"), dict) else {} + if name.lower() in DELEGATION_TOOLS: + c.delegation_events += 1 + if name.lower() == "bash" and inp.get("run_in_background"): + c.delegation_events += 1 + fpath = inp.get("file_path") or inp.get("path") or inp.get("notebook_path") + cmd = inp.get("command") if name.lower() == "bash" else None + timeline.append({ + "kind": "tool", "name": name.lower(), + "file": fpath, "cmd": cmd, + }) + continue + + if role != "user": + continue + c.user_records += 1 + # Slash-command telemetry — scan BEFORE the noise filters below, because command + # stubs are recorded as isMeta/injected user records and would otherwise be dropped + # before we ever see the command name. This counts invocations, not scored prompts. + for _m in _CMD_NAME_RE.finditer(_text_of(content)): + _cmd = _m.group(1).strip().lstrip("/").lower() + c.all_commands[_cmd] += 1 + if _cmd.startswith("sdd-") or _cmd.startswith("sdd_"): + c.sdd_commands[_cmd] += 1 + if _is_tool_result(content): + c.filtered["tool results"] += 1 + continue + if e.get("isSidechain") is True: + c.filtered["subagent turns"] += 1 + continue + if e.get("isMeta") is True: + c.filtered["meta-injected"] += 1 + continue + text = _text_of(content).strip() + if not text: + c.filtered["empty"] += 1 + continue + if _looks_injected(text): + c.filtered["injected / pasted"] += 1 + continue + # A genuine, human-typed prompt. + prompt_idx += 1 + rec = {"text": text, "project": project, "session": session_id, "idx": prompt_idx} + c.real_prompts.append(rec) + timeline.append({"kind": "prompt", "text": text, "rec": rec}) + + if len(ts_in_file) >= 2: + ts_in_file.sort() + c.active_seconds += sum( + min((ts_in_file[i + 1] - ts_in_file[i]).total_seconds(), GAP_CAP_SECONDS) + for i in range(len(ts_in_file) - 1) + ) + if timeline: + c.sessions[session_id] = {"project": project, "timeline": timeline} + return c + + +# --------------------------------------------------------------------------- # +# Scoring helpers +# --------------------------------------------------------------------------- # + +def squash(x, target): + """Saturating curve: hitting `target` maxes the signal; exceeding adds nothing.""" + if target <= 0: + return 0.0 + return max(0.0, min(1.0, x / target)) + + +def clamp(x, lo, hi): + return max(lo, min(hi, x)) + + +def _run_fingerprint(corpus): + """A stable hash of THIS run's de-contaminated prompt set. It binds an AI analysis + (the Opus-stage skill map) to the exact data it was written from, so a stale or + foreign ``analysis.json`` — e.g. left over from a previous run or another person on a + machine that reuses the fixed ``~/.claude/insight/`` paths — carries a different + fingerprint and is refused at merge time. This is what stops one person's written + verdict from ever rendering inside someone else's report.""" + h = hashlib.sha256() + for p in sorted(corpus.real_prompts, key=lambda r: (r["session"], r["idx"])): + h.update(f"{p['session']}\x1f{p['idx']}\x1f{p['text']}\x1e".encode("utf-8")) + h.update(f"|n={len(corpus.real_prompts)}".encode("utf-8")) + return h.hexdigest()[:16] + + +def _is_action_prompt(text): + return bool(ACTION_VERB.search(text)) + + +# --------------------------------------------------------------------------- # +# The five dimensions — each returns (score_0_100, detail_dict, evidence_list) +# --------------------------------------------------------------------------- # + +def score_direction(corpus): + prompts = corpus.real_prompts + n = len(prompts) + if n == 0: + return 0.0, {"n": 0}, [] + constraint = artifact = intent = 0 + weak_examples = [] + for p in prompts: + t = p["text"] + has_artifact = bool(ARTIFACT_RE.search(t)) + has_constraint = bool(CONSTRAINT_CUE.search(t) and ACTION_VERB.search(t)) + has_intent = bool(INTENT_CUE.search(t)) + artifact += 1 if has_artifact else 0 + constraint += 1 if has_constraint else 0 + intent += 1 if has_intent else 0 + if _is_action_prompt(t) and not (has_artifact or has_constraint or has_intent) and len(t) < 120: + weak_examples.append(p) + constraint_rate = constraint / n + artifact_rate = artifact / n + intent_rate = intent / n + # front-loading: penalize rules first revealed via a high-info correction + corr = _find_corrections(corpus) + new_rule_corrections = sum(1 for x in corr if x["high_info"]) + action_prompts = max(1, sum(1 for p in prompts if _is_action_prompt(p["text"]))) + front_loading = 1 - clamp(new_rule_corrections / action_prompts, 0, 1) + score = 100 * ( + 0.30 * squash(constraint_rate, 0.45) + + 0.20 * squash(artifact_rate, 0.45) + + 0.25 * squash(intent_rate, 0.30) + + 0.25 * front_loading + ) + detail = { + "n": n, "constraint_rate": constraint_rate, "artifact_rate": artifact_rate, + "intent_rate": intent_rate, "front_loading": front_loading, + } + return score, detail, weak_examples[:6] + + +def _iter_sessions(corpus): + for sid, s in corpus.sessions.items(): + yield sid, s["project"], s["timeline"] + + +def _find_corrections(corpus): + """Correction turns: short rejections that follow an assistant action, praise-guarded.""" + out = [] + for sid, project, timeline in _iter_sessions(corpus): + saw_tool = False + for ev in timeline: + if ev["kind"] == "tool": + saw_tool = True + continue + t = ev["text"] + head = t[:160] + if CORRECTION_CUE.search(head) and not PRAISE_CUE.search(head) and saw_tool: + high_info = bool( + re.search(r"\d", t) or ARTIFACT_RE.search(t) or len(t.split()) >= 8 + or INTENT_CUE.search(t) + ) + out.append({"session": sid, "project": project, "text": t, "high_info": high_info}) + saw_tool = False # reset: correction must directly follow an action turn + return out + + +def score_iteration(corpus): + prompts = corpus.real_prompts + n = len(prompts) + corr = _find_corrections(corpus) + k = len(corr) + if n == 0: + return 50.0, {"n": 0, "corrections": 0}, [] + rate = k / n + specificity = (sum(1 for x in corr if x["high_info"]) / k) if k else 1.0 + score = 100 * (0.6 * (1 - clamp(rate / CORRECTION_RATE_CEILING, 0, 1)) + 0.4 * specificity) + low_info = [x for x in corr if not x["high_info"]] + # Confidence is keyed on prompt count n (the opportunity count), NOT correction count k: + # a user with many clean prompts and zero corrections has STRONG evidence of good iteration, + # so it must not be shrunk toward 50 as if it were "no data". + detail = {"n": n, "corrections": k, "correction_rate": rate, "specificity": specificity} + return score, detail, low_info[:4] + + +def score_context(corpus): + total_edits = 0 + grounded = 0 + blind_examples = [] + for sid, project, timeline in _iter_sessions(corpus): + read_paths = set() + edited_paths = set() + written_paths = set() # files the agent authored this session (grounded to edit) + for ev in timeline: + if ev["kind"] != "tool": + continue + name, fpath = ev["name"], ev.get("file") + if name in READ_TOOLS and fpath: + read_paths.add(fpath) + elif name in EDIT_TOOLS: + total_edits += 1 + if not fpath: + grounded += 1 # can't attribute; don't penalize + continue + is_new_write = (name == "write" and fpath not in read_paths and fpath not in edited_paths) + # grounded if it was read, OR authored earlier this session, OR is being created now + if fpath in read_paths or fpath in written_paths or is_new_write: + grounded += 1 + else: + blind_examples.append({"session": sid, "project": project, "file": fpath}) + if name == "write": + written_paths.add(fpath) + edited_paths.add(fpath) + if total_edits == 0: + return 50.0, {"n": 0, "grounded": 0, "total_edits": 0, "rate": None}, [] + rate = grounded / total_edits + score = 100 * squash(rate, 0.85) + return score, {"n": total_edits, "grounded": grounded, "total_edits": total_edits, "rate": rate}, blind_examples[:4] + + +def score_verification(corpus): + episodes = 0 + verified = 0 + teardown_bonus = 0 + unverified_examples = [] + for sid, project, timeline in _iter_sessions(corpus): + open_ep = False + ep_files = [] + for ev in timeline: + if ev["kind"] == "prompt": + # a "run it / does it work / confirm" prompt verifies an open episode + if open_ep and re.search(r"\b(run it|does it work|confirm|check (it|that)|verify|did it work)\b", + ev["text"], re.I): + verified += 1 + open_ep = False + continue + name = ev["name"] + cmd = ev.get("cmd") or "" + if name in EDIT_TOOLS: + if not open_ep: + open_ep = True + episodes += 1 + ep_files = [] + if ev.get("file"): + ep_files.append(os.path.basename(ev["file"])) + elif name == "bash": + if TEARDOWN_RE.search(cmd): + teardown_bonus = 5 + if open_ep and VERIFY_RE.search(cmd): + verified += 1 + open_ep = False + elif name in READ_TOOLS and open_ep and ev.get("file") and os.path.basename(ev["file"]) in ep_files: + # re-reading the just-edited file is a (weak) check + verified += 1 + open_ep = False + if open_ep: + unverified_examples.append({"session": sid, "project": project, + "files": ", ".join(sorted(set(ep_files))[:3]) or "files"}) + if episodes == 0: + return 50.0, {"n": 0, "episodes": 0, "verified": 0, "rate": None}, [] + rate = verified / episodes + score = min(100, 100 * squash(rate, 0.60) + teardown_bonus) + return score, {"n": episodes, "episodes": episodes, "verified": verified, "rate": rate, + "teardown_bonus": teardown_bonus}, unverified_examples[:4] + + +def score_toolcraft(corpus): + total = corpus.total_tool_calls + if total == 0: + return 0.0, {"n": 0, "distinct": 0, "evenness": 0.0, "delegation_events": 0}, [] + # Collapse case-variant duplicates (e.g. "Bash" vs "bash") for an honest distinct count. + merged = Counter() + for name, cnt in corpus.tool_usage.items(): + merged[name.lower()] += cnt + distinct = len(merged) + breadth = squash(distinct / 20, 1.0) + # Shannon evenness of the usage distribution. + counts = list(merged.values()) + H = -sum((x / total) * math.log(x / total) for x in counts if x > 0) + evenness = (H / math.log(distinct)) if distinct > 1 else 0.0 + active_hours = max(corpus.active_seconds / 3600, 0.5) + delegation = squash(corpus.delegation_events / active_hours, 2.0) + score = 100 * (0.45 * breadth + 0.30 * evenness + 0.25 * delegation) + detail = {"n": total, "distinct": distinct, "evenness": evenness, + "delegation_events": corpus.delegation_events} + return score, detail, [] + + +# --------------------------------------------------------------------------- # +# Aggregate: confidence shrinkage, overall score, band, archetype +# --------------------------------------------------------------------------- # + +def shrink(score, n, target_n): + c = min(1.0, n / target_n) if target_n else 1.0 + return 50 + (score - 50) * c, c + + +def band_for(score): + for name, lo, hi, meaning in BANDS: + if lo <= score <= hi: + return name, meaning + return BANDS[-1][0], BANDS[-1][3] + + +def _cosine(a, b): + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(y * y for y in b)) + return dot / (na * nb) if na and nb else 0.0 + + +def classify_archetype(dim_scores, delegation_score): + """Nearest-prototype over your DRIVING-STYLE vector, with a margin guard. + + The vector adds a Delegation axis and is AGENCY-WEIGHTED: axes you control + (Direction, Iteration, Toolcraft, Delegation) count fully, while axes the agent + mostly drives on its own (Verification, Context) are heavily discounted — so the + archetype reflects how *you* drive, not Claude's built-in habits. + """ + scores = dict(dim_scores) + scores["Delegation"] = delegation_score + V = [scores[ax] for ax in ARCHETYPE_AXES] + names = list(PROTOTYPES.keys()) + mat = [PROTOTYPES[n]["vec"] for n in names] + # z-score each axis across prototypes + the user vector, then apply agency weights + cols = list(zip(*(mat + [V]))) + means = [statistics.mean(col) for col in cols] + stds = [statistics.pstdev(col) or 1.0 for col in cols] + w = [AGENCY[ax] for ax in ARCHETYPE_AXES] + + def zw(vec): + return [w[i] * (v - means[i]) / stds[i] for i, v in enumerate(vec)] + + vz = zw(V) + sims = sorted(((round(_cosine(vz, zw(PROTOTYPES[n]["vec"])), 3), n) for n in names), reverse=True) + top_sim, top = sims[0] + second_sim, second = sims[1] + blended = (top_sim - second_sim) < ARCHETYPE_MARGIN + second_short = second.replace("The ", "") + article = "an" if second_short[:1] in "AEIOU" else "a" + return { + "primary": top, "primary_sim": top_sim, "secondary": second, "secondary_sim": second_sim, + "blended": blended, "all": sims, "delegation_score": round(delegation_score), + "label": f"{PROTOTYPES[top]['emoji']} {top}" + (f", with {article} {second_short} streak" if blended else ""), + "blurb": PROTOTYPES[top]["blurb"], + } + + +# --------------------------------------------------------------------------- # +# Analysis orchestration +# --------------------------------------------------------------------------- # + +def analyze(corpus): + raw, detail, evidence = {}, {}, {} + for name, fn in (("Direction", score_direction), ("Verification", score_verification), + ("Context", score_context), ("Iteration", score_iteration), + ("Toolcraft", score_toolcraft)): + s, d, ev = fn(corpus) + raw[name], detail[name], evidence[name] = s, d, ev + + shrunk, conf = {}, {} + for name in raw: + shrunk[name], conf[name] = shrink(raw[name], detail[name].get("n", 0), TARGET_N[name]) + + overall_raw = round(sum(WEIGHTS[n] * raw[n] for n in WEIGHTS)) + overall = round(sum(WEIGHTS[n] * shrunk[n] for n in WEIGHTS)) + band, band_meaning = band_for(overall) + # Delegation is a user-driven archetype axis (handoffs per active hour). + active_hours = max(corpus.active_seconds / 3600, 0.5) + delegation_score = 100 * squash(corpus.delegation_events / active_hours, 2.0) + archetype = classify_archetype(shrunk, delegation_score) + + # length distribution of real prompts (context only) + lens = [len(p["text"]) for p in corpus.real_prompts] + words = [len(p["text"].split()) for p in corpus.real_prompts] + dist = {} + if lens: + dist = { + "median_chars": int(statistics.median(lens)), + "mean_chars": int(statistics.mean(lens)), + "median_words": int(statistics.median(words)), + "under_80_pct": round(100 * sum(1 for L in lens if L < 80) / len(lens)), + } + + return { + "raw": raw, "shrunk": shrunk, "conf": conf, "detail": detail, "evidence": evidence, + "overall_raw": overall_raw, "overall": overall, "band": band, "band_meaning": band_meaning, + "archetype": archetype, "dist": dist, "fingerprint": _run_fingerprint(corpus), + } + + +def build_action_plan(corpus, result): + """Growth cards ranked by impact = (target - score) * weight. The teaching copy + comes from SKILL_TEACH; user-specific evidence comes from result['evidence'].""" + TARGET = 85 + cards = [] + for name in WEIGHTS: + score = result["shrunk"][name] + impact = (TARGET - score) * WEIGHTS[name] + cards.append({"dim": name, "score": round(score), "impact": impact, + "weak": result["evidence"].get(name, []), + "detail": result["detail"][name]}) + cards.sort(key=lambda c: c["impact"], reverse=True) + # strength callout = highest shrunk score + strength = max(WEIGHTS, key=lambda n: result["shrunk"][n]) + return cards, strength + + +def _shortest_action_prompt(corpus): + cands = [p["text"] for p in corpus.real_prompts if _is_action_prompt(p["text"]) and len(p["text"]) < 40] + return min(cands, key=len) if cands else None + + +def build_evidence(corpus, result, cards, archive_info=None, sdd_result=None): + """Serialize a de-contaminated EVIDENCE bundle for the two-model analysis pipeline + (Sonnet 4.6 explores it; Opus 4.8 analyzes it against the bundled AI-fluency + framework). It contains your real prompts/behavior with home paths scrubbed, and is + git-ignored. Deterministic (no randomness) so runs are reproducible.""" + prompts = corpus.real_prompts + sample, seen = [], set() + + def add(p): + k = (p["session"], p["idx"]) + if k in seen: + return + seen.add(k) + sample.append({"text": _scrub_paths(p["text"][:600]), "project": _project_label(p["project"]), + "chars": len(p["text"])}) + + by_len = sorted(prompts, key=lambda p: len(p["text"])) + for p in by_len[:6]: # the terse nudges + add(p) + for p in by_len[-14:]: # the rich, intent-carrying prompts + add(p) + stride = max(1, len(prompts) // 20) # an even spread through the timeline + for p in prompts[::stride]: + if len(sample) >= 50: + break + add(p) + + def clean_ex(items): + out = [] + for e in items or []: + if not isinstance(e, dict): + continue + c = {} + if e.get("text"): + c["text"] = _scrub_paths(str(e["text"])[:300]) + if e.get("file"): + c["file"] = os.path.basename(str(e["file"])) + if e.get("files"): + c["files"] = str(e["files"]) + if e.get("project"): + c["project"] = _project_label(e["project"]) + if c: + out.append(c) + return out + + span_days = (corpus.last_ts - corpus.first_ts).days if corpus.first_ts and corpus.last_ts else 0 + a = result["archetype"] + return { + "schema": "claude-insight-evidence/1", + "meta": { + "sessions": corpus.files, "projects": len(corpus.projects), + "real_prompts": len(prompts), "user_records": corpus.user_records, + "filtered_noise": dict(corpus.filtered), + "span_days": span_days, + "active_hours": round(corpus.active_seconds / 3600, 1), + "archive": archive_info, + "prompt_distribution": result["dist"], + # Binds any analysis produced from this bundle back to this exact run; the + # analysis stage must echo it so a stale/foreign analysis can be refused. + "run_fingerprint": result.get("fingerprint"), + }, + "scores": { + "overall": result["overall"], "overall_raw": result["overall_raw"], + "band": result["band"], "weights": WEIGHTS, + "dimensions_raw": {k: round(v) for k, v in result["raw"].items()}, + "dimensions_adjusted": {k: round(v) for k, v in result["shrunk"].items()}, + "confidence": {k: round(v, 2) for k, v in result["conf"].items()}, + "dimension_names": DISPLAY_NAMES, + }, + "dimension_detail": result["detail"], + "archetype": {"primary": a["primary"], "secondary": a["secondary"], + "blended": a.get("blended")}, + "behavior": { + "sample_prompts": sample, + "weak_examples": {c["dim"]: clean_ex(c["weak"]) for c in cards}, + "tool_usage": dict(corpus.tool_usage), + "delegation_events": corpus.delegation_events, + "sdd_commands": dict(corpus.sdd_commands), + }, + # Layer 2: repo-level Spec-Driven-Development adherence (None when no sdd/ tree). + # The AI workflow uses this to write the Process-Discipline read; it is project + # data, not this person's, so the analysis must frame it as "the team/repo", not "you". + "sdd": ({ + "overall": sdd_result["overall"], "band": sdd_result["band"], + "weights": sdd_result["weights"], "sub_scores": sdd_result["sub"], + "metrics": sdd_result["raw"], + } if sdd_result else None), + } + + +def _analysis_section_html(analysis): + """Render the AI-authored skill map (produced by the Opus analysis stage, + grounded in reference/ai-fluency-framework.md). Falls back to nothing if absent.""" + if not analysis or not isinstance(analysis, dict): + return "" + parts = ['<section><h3>Skill map — analyzed against the AI Fluency framework</h3>'] + read = analysis.get("overall_read") or analysis.get("summary") + if read: + parts.append(f'<p class="assess">{_esc(read)}</p>') + for s in analysis.get("skill_map") or []: + if not isinstance(s, dict): + continue + comp = _esc(s.get("competency", "?")) + lvl = s.get("level", "?") + label = _esc(s.get("level_label", "")) + summ = _esc(s.get("summary", "")) + nxt = _esc(s.get("next_move", "")) + ev = "".join(f"<li>“{_esc(str(x)[:200])}”</li>" for x in (s.get("evidence") or [])[:3]) + parts.append( + f'<div class="dim"><div class="dim-h"><b>{comp}</b>' + f'<span class="pill">Level {_esc(lvl)}/5 · {label}</span></div>' + f'<p>{summ}</p>' + + (f'<ul class="ev">{ev}</ul>' if ev else "") + + (f'<p class="next"><b>Your next move:</b> {nxt}</p>' if nxt else "") + + '</div>') + strengths = analysis.get("strengths") or [] + if strengths: + items = "".join(f"<li>{_esc(s)}</li>" for s in strengths[:5]) + parts.append(f'<p style="margin-top:14px"><b>What you already do well:</b></p><ul class="facts">{items}</ul>') + parts.append('<p style="color:var(--mut);font-size:13px;margin-top:10px">' + 'This section is written by Claude Opus 4.8 from your de-contaminated evidence ' + '(explored by Claude Sonnet 4.6), grounded in the bundled AI Fluency framework. ' + 'The numbers above are computed deterministically and independently.</p>') + parts.append('</section>') + return "".join(parts) + + +def _growth_cards_html(analysis): + """The 'how to grow' cards, written FOR THIS PERSON by the Opus analysis stage: + each item names the habit, why it matters, how to grow it, and a before/after where + the 'before' is one of THEIR real prompts and the 'after' is Opus's tailored rewrite. + Returns '' when there is no analysis (the caller then falls back to the generic + teaching examples), so the report only ever shows canned examples when no AI ran.""" + if not analysis or not isinstance(analysis, dict): + return "" + items = [g for g in (analysis.get("top_growth") or []) if isinstance(g, dict)] + if not items: + return "" + out = [] + for i, g in enumerate(items[:3]): + title = _esc(g.get("title", "Your next growth move")) + why = _esc(g.get("why", "")) + how = _esc(g.get("how", "")) + before = g.get("example_before") + after = g.get("example_after") + ba = "" + if before and after: + ba = (f'<div class="ba"><div class="before"><span>A prompt you wrote</span>' + f'“{_esc(str(before)[:400])}”</div>' + f'<div class="after"><span>Tailored rewrite for you</span>' + f'“{_esc(str(after)[:600])}”</div></div>') + out.append( + f'<div class="card prio"><div class="ph">Priority {i + 1} · written for you</div>' + f'<h4>{title}</h4>' + + (f'<p class="why"><b>Why it matters.</b> {why}</p>' if why else "") + + (f'<div class="wwh"><span class="lab">How to grow it</span>' + + (f'<p class="how">{how}</p>' if how else "") + f'{ba}</div>' + if (how or ba) else "") + + '</div>') + return "".join(out) + + +# --------------------------------------------------------------------------- # +# Reports +# --------------------------------------------------------------------------- # + +def _project_label(name): + """Claude encodes an absolute path with '-' for '/', so we can't perfectly + recover hyphenated names. Drop the home/boilerplate prefix and show the rest. + '-Users-me-Dropbox-AI-platzi-executive-assistant' -> 'AI platzi executive assistant'.""" + s = re.sub(r"^-?(?:Users|home)-[^-]+(?:-|$)", "", name) # strip -Users-/-home-<user>- (mac & linux) + s = re.sub(r"^Dropbox-", "", s) # strip a common cloud-folder prefix + s = s.replace("-", " ").strip() + # Nothing left -> the session ran in $HOME itself; never echo the raw name (it holds the username). + if not s: + return "home" if re.match(r"^-?(?:Users|home)-", name) else name + return s + + +def terminal_summary(corpus, result): + a = result["archetype"] + lines = [ + "", + f" AI Fluency Score: {result['overall']}/100 ({result['band']})", + f" Archetype: {a['label']}", + f" Based on {len(corpus.real_prompts)} real prompts across {len(corpus.projects)} projects, " + f"{corpus.files} sessions ({corpus.total_bytes/1e6:.1f} MB).", + "", + ] + return "\n".join(lines) + + +def _esc(s): + return html.escape(str(s)) + + +# Each archetype's encouraging "next gain" — frames the top growth lever as a natural +# progression for that style rather than a deficit. +ARCH_PATHS = { + "Autonomous Agent": "You already hand off whole jobs well — add one sharp sentence of intent per hand-off and far more will land right the first time, with less back-and-forth.", + "Architect": "Your planning is a real strength — pair it with a quick check after each change so your designs ship proven, not just drawn.", + "Debugger": "Your diagnostic discipline is excellent — capture each fix as a small reusable rule so the same bug never costs you twice.", + "Collaborator": "Your back-and-forth keeps things aligned — front-loading a constraint or two will get you there in fewer rounds.", + "Sprinter": "Your speed is real — a one-line brief plus a quick test keeps that speed from turning into rework.", +} + +_SIG_DESC = { + "Delegation": "how much you hand off — you give Claude whole jobs and trust it to run them end-to-end", + "Toolcraft": "the range of tools you bring to bear — you reach past the shell for the right instrument", + "Iteration": "how cleanly you change course — your corrections tend to name the fix, not just reject", + "Briefing": "how concretely you frame requests when it matters", +} + +# The specific, evidence-grounded line that explains each dimension as a growth edge. +_GROWTH_LINE = { + "Direction": "{s}s win on how sharply they frame the work they hand off — and right now yours are often one-liners like “{ex}”, so Claude fills gaps you could have decided.", + "Verification": "Right now changes often move on without a test, build or run to confirm them — the cheapest reliability you can buy back.", + "Context": "Right now some edits land before the file has been read that session — an easy blind-edit risk to remove.", + "Iteration": "Right now corrections lean toward brief rejections; naming the symptom and the exact rule resolves loops in fewer turns.", + "Toolcraft": "Right now most work funnels through one tool — reaching for search, planning and delegation widens what you can take on.", +} + + +def build_assessment(corpus, result, cards): + """A coherent, professional written read — synthesizes the numbers into one story + and explicitly resolves the archetype-vs-weakest-dimension tension.""" + a = result["archetype"] + arch = a["primary"] + short = arch.replace("The ", "") + art = "an" if short[:1] in "AEIOU" else "a" + deleg = a["delegation_score"] + n_deleg = corpus.delegation_events + median = result["dist"].get("median_chars", "?") + + # signature strength = your strongest USER-driven signal (not Claude's defaults) + user_signals = { + "Briefing": result["shrunk"]["Direction"], "Iteration": result["shrunk"]["Iteration"], + "Toolcraft": result["shrunk"]["Toolcraft"], "Delegation": float(deleg), + } + sig = max(user_signals, key=user_signals.get) + + growth = cards[0]["dim"] + growth_disp = disp(growth) + example = _shortest_action_prompt(corpus) or "run it" + path_why = ARCH_PATHS.get(arch, "Keep building the habits below and your next run will show the gain.") + + p1 = (f"You drive Claude like <b>{_esc(a['label'])}</b>. {_esc(a['blurb'])} " + f"The clearest signal is your delegation rate — <b>{deleg}/100</b>, from {n_deleg} hand-offs to " + f"subagents, background jobs and planning — paired with fast, terse prompts (median " + f"{median} characters).") + + # Only credit the read→edit→verify loop when the data actually shows it; otherwise this + # clause was claiming a discipline some users' own report contradicts (0% verified / grounded). + loop_ok = result["shrunk"]["Context"] >= 55 and result["shrunk"]["Verification"] >= 55 + p2_mid = ("That, plus the disciplined read→edit→verify loop your sessions show, is" + if loop_ok else "That is") + p2 = (f"Your strongest <i>self-driven</i> habit is {_esc(_SIG_DESC.get(sig, sig.lower()))}. " + f"{p2_mid} why your overall score lands at <b>{result['overall']}/100 ({_esc(result['band'])})</b>.") + + gline = _GROWTH_LINE.get(growth, "").format(s=_esc(short), ex=_esc(example)) + p3 = (f"And the apparent tension, resolved: your lowest dimension is <b>{_esc(growth_disp)}</b> — but for " + f"{art} {_esc(short)} that isn't a contradiction, it's the <i>defining</i> growth edge. {gline} " + f"{_esc(path_why)}") + + return (f'<p class="assess">{p1}</p><p class="assess">{p2}</p><p class="assess">{p3}</p>') + + +def build_html(corpus, result, cards, strength, archive_info=None, analysis=None, + analysis_note=None, sdd_result=None): + a = result["archetype"] + d = result["dist"] + analysis_section = _analysis_section_html(analysis) + sdd_section = _sdd_section_html(sdd_result, corpus, analysis) + # When an AI analysis was expected but couldn't be used (no-op'd, empty, or from a + # different run), say so plainly instead of letting the template-only report pass as + # the full thing. Silent on a plain deterministic run (no --analysis was attempted). + analysis_status_html = "" + if not analysis_section and analysis_note: + analysis_status_html = ( + '<section><div class="prov">ℹ️ <b>Deterministic report only.</b> ' + f'{_esc(analysis_note)} — the Sonnet + Opus skill map was not added on top. ' + 'The scores, archetype and dimensions below are still fully computed from your data; ' + 'to add the AI-written skill map, re-run <code>/ai-fluency</code> inside Claude Code.' + '</div></section>') + days = (corpus.last_ts - corpus.first_ts).days if corpus.first_ts and corpus.last_ts else 0 + active_h = corpus.active_seconds / 3600 + filtered_total = sum(corpus.filtered.values()) + provisional = len(corpus.real_prompts) < PROVISIONAL_MIN_PROMPTS + + DIM_BLURB = { + "Direction": "How clearly you tell the agent what you want before it acts.", + "Verification": "Whether changes get checked (tests / build / app) before moving on.", + "Context": "Reading a file before editing it — grounded, not blind, changes.", + "Iteration": "Correcting precisely instead of thrashing with vague rejections.", + "Toolcraft": "Using a healthy range of tools — not forcing everything through one.", + } + + def dim_rate_line(name): + det = result["detail"][name] + if name == "Verification" and det.get("rate") is not None: + return f"{det['verified']} of {det['episodes']} edit-bursts verified ({det['rate']*100:.0f}%)" + if name == "Context" and det.get("rate") is not None: + return f"{det['grounded']} of {det['total_edits']} edits were grounded in a prior read ({det['rate']*100:.0f}%)" + if name == "Direction": + return (f"{det['constraint_rate']*100:.0f}% carry a constraint · " + f"{det['artifact_rate']*100:.0f}% name a file/error · {det['intent_rate']*100:.0f}% state a why") + if name == "Iteration": + return f"{det['corrections']} correction turns ({det['correction_rate']*100:.0f}% of prompts); {det['specificity']*100:.0f}% were specific" + if name == "Toolcraft": + return f"{det.get('distinct', 0)} distinct tools, evenness {det.get('evenness', 0.0):.2f}, {det.get('delegation_events', 0)} delegations" + return "" + + # dimension bars + dim_html = "" + order = sorted(WEIGHTS, key=lambda n: result["shrunk"][n], reverse=True) + for name in order: + sc = round(result["shrunk"][name]) + raw_sc = round(result["raw"][name]) + c = result["conf"][name] + lowdata = c < 0.75 + tag = "" + if name == strength: + tag = '<span class="tag s">Strength</span>' + elif name == cards[0]["dim"]: + tag = '<span class="tag w">Top growth lever</span>' + ld = '<span class="tag ld">low data</span>' if lowdata else "" + dim_html += f""" + <div class="dim"> + <div class="top"><span class="name">{_esc(disp(name))} {tag}{ld}</span><span class="sval">{sc}<span class="hint">/100</span></span></div> + <div class="bar"><i style="width:{sc}%"></i></div> + <p class="def">{_esc(DIM_BLURB[name])}</p> + <p class="rate">{_esc(dim_rate_line(name))}<span class="wt"> · weight {int(WEIGHTS[name]*100)}%</span></p> + </div>""" + + # archetype affinity + aff = "" + for sim, nm in a["all"]: + pct = max(0, round((sim + 1) / 2 * 100)) + aff += f"""<div class="bar-item"><div class="bl">{PROTOTYPES[nm]['emoji']} {_esc(nm)}</div> + <div class="bt"><i style="width:{pct}%"></i></div><div class="bv">{sim:+.2f}</div></div>""" + + # data-ingested filter breakdown + filt = "".join( + f"<li><b>{v:,}</b> {_esc(k)}</li>" for k, v in corpus.filtered.most_common() + ) + + # Archive stat tile + the "why ~30 days / how to see more" callout. + archive_tile = retention_note = "" + arch_dir_disp = _esc(archive_info["dir"]) if archive_info else _esc(DEFAULT_ARCHIVE_DIR) + if archive_info: + archive_tile = (f'<div class="ing"><div class="n">{archive_info["archived_sessions"]:,}</div>' + f'<div class="l">sessions in your archive</div></div>') + # Show the explainer whenever the visible history is short — that's the 30-day cleanup biting. + if days <= 32: + grew = "" + if archive_info and archive_info.get("enabled"): + grew = (f' This run preserved <b>{archive_info["new"]:,}</b> new session(s) to your ' + f'archive (<code>{arch_dir_disp}</code>), so from here your history keeps growing ' + f'past the 30-day wall. Keep this archive private to you — sharing one folder ' + f'between people would mix everyone\'s transcripts into a single report.') + retention_note = ( + '<div class="honesty" style="margin-top:14px">' + f'<b>Why only ~{days} days?</b> Claude Code deletes transcripts older than your ' + '<code>cleanupPeriodDays</code> setting (default <b>30</b>), so that is all that was ' + 'left on disk to read — not a limit of this tool. To analyze more history: ' + '<b>(1)</b> raise <code>cleanupPeriodDays</code> in <code>~/.claude/settings.json</code> ' + '(e.g. <code>"cleanupPeriodDays": 365</code>) to stop the deletion; ' + f'<b>(2)</b> keep running Claude Insight.{grew}' + '</div>') + + # action cards (what/where/how) + def evidence_html(card): + name = card["dim"] + ev = card["weak"] + if not ev: + return '<p class="ev-none">No clear examples in your transcripts — this is already a habit. ✓</p>' + items = "" + # small-sample guard per project + proj_counts = Counter(p["project"] for p in corpus.real_prompts) + for e in ev[:3]: + if name == "Direction" or name == "Iteration": + proj = e["project"]; txt = _scrub_paths(e["text"]) + small = " <em>(illustrative, small sample)</em>" if proj_counts.get(proj, 0) < 10 else "" + items += f'<li>“{_esc(txt[:140])}” <span class="loc">— {_esc(_project_label(proj))}{small}</span></li>' + elif name == "Context": + small = " <em>(illustrative)</em>" if proj_counts.get(e["project"], 0) < 10 else "" + items += f'<li>Edited <code>{_esc(os.path.basename(e["file"]))}</code> without reading it first <span class="loc">— {_esc(_project_label(e["project"]))}{small}</span></li>' + elif name == "Verification": + small = " <em>(illustrative)</em>" if proj_counts.get(e["project"], 0) < 10 else "" + items += f'<li>A burst of edits to <code>{_esc(e["files"])}</code> with nothing run afterwards <span class="loc">— {_esc(_project_label(e["project"]))}{small}</span></li>' + return f"<ul class='ev'>{items}</ul>" + + cards_html = "" + for i, card in enumerate(cards[:2]): + name = card["dim"] + t = SKILL_TEACH[name] + # These before/after pairs are a fixed teaching library, identical for every user + # with this weak dimension — they are NOT drawn from this person's transcripts. + # Label them as such so they can never be mistaken for a personalized rewrite (the + # personalized signal is the "Where this shows up in your sessions" block above). + ex_html = "".join( + f'<div class="ba"><div class="before"><span>Instead of</span>“{_esc(e["before"])}”</div>' + f'<div class="after"><span>Stronger</span>“{_esc(e["after"])}”</div></div>' + for e in t["examples"] + ) + cards_html += f""" + <div class="card prio"> + <div class="ph">Priority {i+1} · {_esc(disp(name))} <span class="pscore">now {card['score']}/100</span></div> + <h4>{_esc(t['what_it_is'])}</h4> + <p class="why"><b>Why it matters.</b> {_esc(t['why_it_matters'])}</p> + <div class="wwh"><span class="lab">Where this shows up in your sessions</span>{evidence_html(card)}</div> + <div class="wwh"><span class="lab">How to grow it</span><p class="how">{_esc(t['how_to_improve'])}</p> + <p class="exgen">Generic illustrations of the habit — <b>not</b> from your sessions:</p> + {ex_html} + </div> + <p class="tgt">🎯 Try this next session: {_esc(t['practice'])}</p> + </div>""" + + # strength callout — lead with the user's signature (self-driven) strength. + # Floor the praise: if even the best dimension is weak, frame it as "relatively + # strongest" rather than asserting a mastered habit the rate-line would contradict. + s_det = dim_rate_line(strength) + strong_score = round(result["shrunk"][strength]) + if strong_score >= 55: + strength_head = "Keep doing this" + strength_body = (f"{_esc(SKILL_TEACH[strength]['good_looks_like'])} The evidence in your " + f"sessions: {_esc(s_det)}. This is your foundation — build on it.") + else: + strength_head = "Your relatively strongest area" + strength_body = (f"Even your strongest dimension has room to grow ({strong_score}/100), but this " + f"is the most natural place to build from. The evidence in your sessions: " + f"{_esc(s_det)}.") + strength_html = f""" + <div class="card keep"> + <div class="ph">{strength_head} · {_esc(disp(strength))} <span class="pscore">{strong_score}/100</span></div> + <p>{strength_body}</p> + </div>""" + + # skill map (levels) + skill_levels = _skill_levels(result) + skill_html = "" + for sk in skill_levels: + dots = "".join( + f'<span class="dot {"on" if i < sk["level"] else ""}"></span>' for i in range(5) + ) + skill_html += f"""<div class="skill"> + <div class="sk-top"><span class="sk-name">{_esc(sk['name'])} <span class="lvl">Level {sk['level']}/5</span></span><span class="sk-dots">{dots}</span></div> + <p class="sk-what">{_esc(sk['what'])}</p> + <p class="sk-now"><b>You're here:</b> {_esc(sk['now'])}</p> + <p class="sk-next"><b>Next move:</b> {_esc(sk['next'])}</p></div>""" + + prov_banner = "" + if provisional: + prov_banner = (f'<div class="prov">⚠️ Provisional: only {len(corpus.real_prompts)} real prompts found — ' + f'treat the score as a rough range (±10). It sharpens as you use Claude Code more.</div>') + # On thin data the archetype is the least stable signal (a near-neutral vector lands on + # whichever prototype is closest by a hair), so hedge it explicitly rather than asserting it. + arch_hedge = "" + if provisional: + arch_hedge = ('<p style="margin-top:8px;font-size:12.5px;color:var(--warn)">Provisional — based on only ' + f'{len(corpus.real_prompts)} prompt(s); the archetype can shift as more history accumulates.</p>') + + # fun facts + facts = [ + f"{len(corpus.real_prompts)} prompts you actually typed (out of {corpus.user_records:,} user records — the rest were tool output, subagent turns or system text)", + f"median prompt is {d.get('median_chars','?')} characters ({d.get('median_words','?')} words); {d.get('under_80_pct','?')}% are under 80 chars", + f"{active_h:.0f} hours of hands-on time (idle gaps over 5 min excluded)", + f"{result['detail']['Toolcraft']['distinct']} distinct tools used; {corpus.total_tool_calls:,} tool calls in total", + f"most-used tool: {corpus.tool_usage.most_common(1)[0][0] if corpus.tool_usage else 'n/a'}", + f"{corpus.delegation_events} delegations (subagents / background jobs / planning)", + ] + facts_html = "".join(f"<li>{_esc(f)}</li>" for f in facts) + assessment_html = build_assessment(corpus, result, cards) + + # "What to improve": prefer the Opus analysis's tailored growth cards (grounded in this + # person's real prompts). Only fall back to the generic teaching examples when no AI + # analysis ran — so a finished report is personalized, not a library of stock examples. + growth_cards = _growth_cards_html(analysis) + if growth_cards: + improve_cards = growth_cards + improve_intro = ('<p class="exgen" style="margin-bottom:14px">Written for you by Claude ' + 'Opus 4.8 from your real prompts — your highest-leverage moves, each ' + 'with one of your prompts rewritten.</p>') + else: + improve_cards = cards_html + improve_intro = "" + + return f"""<!DOCTYPE html> +<html lang="en"><head><meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Your AI Fluency Report +
+ +
+
Claude Insight · AI Fluency Report
+

How skillfully you build with AI

+

A read of how you actually drive Claude Code — measured from your real prompts and Claude's real actions, analyzed entirely on your machine.

+
+ +{prov_banner} + +
+
+
+ + + + + +
{result['overall']}/ 100
+
+
{_esc(result['band'])}
+
raw {result['overall_raw']} · confidence-adjusted {result['overall']}
+
+
+
{PROTOTYPES[a['primary']]['emoji']}
+

{_esc(a['label'])}

+

{_esc(a['blurb'])}

+

Closest match {a['primary_sim']:+.2f}, next is {_esc(a['secondary'].replace('The ',''))} {a['secondary_sim']:+.2f}{' — close, so this is a blend' if a['blended'] else ''}. Built from how you drive — your briefs, corrections, tool choices and how much you hand off ({a['delegation_score']}/100 delegation) — and it deliberately discounts the read-before-edit and run-the-tests habits Claude does on its own, so it reflects you, not the agent.

+

Your score measures the quality of the collaboration (you + Claude); your archetype measures your driving style alone — so they can differ on purpose.

+ {arch_hedge} +
+
+ +
+

Professional assessment

+ {assessment_html} +
+ +
+

What your score means

+
{_esc(result['band'])} ({result['overall']}/100). {_esc(result['band_meaning'])}
+
+ +
+

How much data this is based on

+
+
{corpus.files}
sessions scanned
+
{len(corpus.projects)}
projects
+
{corpus.total_bytes/1e6:.1f} MB
transcript data parsed
+
{days} days
span of activity
+
{len(corpus.real_prompts)}
real prompts you typed
+
{active_h:.0f} h
hands-on active time
+ {archive_tile} +
+ {retention_note} +
+ The honest part: we found {corpus.user_records:,} “user” records but only {len(corpus.real_prompts)} are prompts you typed. We filtered out {filtered_total:,} that the old tool wrongly counted: +
    {filt}
+

Your real prompts: median {d.get('median_chars','?')} chars · {d.get('under_80_pct','?')}% under 80 chars · {active_h:.0f} h hands-on active time (idle gaps over 5 min are excluded — not raw wall-clock).

+
+
+ +{analysis_section} +{analysis_status_html} +{sdd_section} + +
+

The five dimensions

+ {dim_html} +
+ +
+

What to improve — and exactly how

+ {improve_intro} + {improve_cards} + {strength_html} +
+ +
+

Your skill map

+ {skill_html} +
+ +
+

Archetype affinity

+ {aff} +
+ +
+

Honest numbers at a glance

+
    {facts_html}
+
+ +
+

Methodology & honesty

+
How every number was computed (click to expand) +

Only real prompts are scored. A “user” record counts as a prompt only if it is not a tool-result, not a subagent (sidechain) turn, not meta/injected, not a slash-command stub, and not a paste/system-prompt over {MAX_HUMAN_PROMPT_CHARS:,} chars or opening with “You are …”. This removes the contamination that made the old tool report a {d.get('mean_chars','?')}-vs-real average.

+

Everything is a rate, then squashed. Each dimension is a per-prompt or per-opportunity rate run through min(1, rate/target), so doing more work never raises the score — only doing it better does. Weights: Briefing 24%, Verification 22%, Context-setting 22%, Iteration 18%, Toolcraft 14%.

+

Thin signals are hedged, not faked. Each dimension is pulled toward a neutral 50 in proportion to how many opportunities it had (e.g. Iteration had only {result['detail']['Iteration']['corrections']} corrections, so it is flagged “low data”). Both raw and confidence-adjusted scores are shown.

+

Archetype describes your driving style, not the collaboration's quality, so it is built on a separate agency-weighted vector: Briefing, Iteration, Toolcraft and Delegation (handoffs to subagents/background jobs/planning) count fully, while Verification and Context — habits Claude largely does on its own — are discounted ({int(AGENCY['Verification']*100)}% and {int(AGENCY['Context']*100)}% weight). It is the nearest prototype by cosine on z-scored values; if the top two are within {ARCHETYPE_MARGIN} we show a blend. Active time caps idle gaps at {GAP_CAP_SECONDS//60} min. Fixes vs v1: prompt mis-count, length inflation, idle-time over-count, random archetype, uncapped tool-diversity, and keyword “error” false-positives.

+

Limits: this measures observable behavior, not intent; detectors are heuristic and English-biased; it's a single snapshot, not a trend. Terse prompts that carry intent from the prior turn can under-score Direction.

+
+
+ +
Generated locally by Claude Insight v2 · your transcripts never left this machine.
+
""" + + +def _skill_levels(result): + """Map dimension scores to L1-L5 skill levels with now/next text.""" + def lvl(score): + return max(1, min(5, int(score // 20) + 1)) + s = result["shrunk"] + defs = [ + ("Briefing & specificity", "Direction", + "name a goal + one anchor (path, constraint, or acceptance test) in most action prompts", + {1: "Mostly short nudges with little context.", 2: "Occasional context; one constraint sometimes.", + 3: "Most prompts carry a goal + one anchor.", 4: "Goal + constraint + criterion are common.", + 5: "Consistently high-context with front-loaded rules."}), + ("Verification discipline", "Verification", + "end edit-bursts by running the tests / the app before moving on", + {1: "Edits accepted blind, almost no checks.", 2: "Verifies occasionally.", + 3: "Verifies most bursts of edits.", 4: "Verifies nearly every change.", + 5: "Verification is a reflex — stated up front and layered."}), + ("Context grounding (read→edit)", "Context", + "have the agent read the target file before changing it", + {1: "Often edits files it never read.", 2: "Reads before editing about half the time.", + 3: "Usually points the agent at the right place first.", 4: "Routinely reads target + deps before changing.", + 5: "Deliberate exploration before non-trivial changes."}), + ("Iteration & recovery", "Iteration", + "make corrections name a symptom + the exact rule, in one line", + {1: "Low-info rejections, long loops.", 2: "Corrects but vaguely.", + 3: "Mixes precise and bare corrections.", 4: "Low correction rate, mostly specific.", + 5: "Surgical feedback; turns misses into reusable rules."}), + ("Toolcraft & orchestration", "Toolcraft", + "reach past the shell — search, planning, delegation for the right jobs", + {1: "Effectively one tool.", 2: "The core trio (Bash/Read/Edit).", + 3: "Adds search/web and some planning.", 4: "Comfortable with MCP + balanced spread.", + 5: "20+ tools used appropriately, low concentration."}), + ] + out = [] + for name, dim, nxt, rub in defs: + L = lvl(s[dim]) + out.append({"name": name, "dim": dim, "level": L, "now": rub[L], + "what": SKILL_TEACH[dim]["what_it_is"], + "next": nxt if L < 5 else "maintain this — it's a real strength."}) + return out + + +# --------------------------------------------------------------------------- # +# SDD layer (AI-Parrot internal) — process-discipline metrics from sdd/ artifacts +# --------------------------------------------------------------------------- # +# +# Layer 1 (everything above) measures one PERSON's AI fluency from transcripts. +# Layer 2 (here) measures how well THIS REPO follows its own Spec-Driven +# Development workflow, read deterministically from the sdd/ artifact tree. The +# two are orthogonal — the report shows both — so a strong solo prompter on a +# team that skips reviews still sees the review gap, and vice-versa. + +# A spec is considered to carry acceptance criteria if it has such a heading. +_SDD_AC_RE = re.compile(r"^#{1,4}\s*.*acceptance\s+criteria", re.I | re.M) +_SDD_STATUS_RE = re.compile(r"^\*\*Status\*\*:\s*(.+?)\s*$", re.I | re.M) + +# SDD process sub-dimensions and weights (sum to 1.0). Mirrors the upstream +# dimension model so the report renders them with the same machinery. +SDD_WEIGHTS = { + "Pipeline": 0.22, # research-first: specs backed by tasks; proposals before specs + "Decomposition": 0.22, # task granularity, declared deps / parallelism / effort + "Acceptance": 0.20, # specs state testable acceptance criteria + "Closure": 0.18, # features and their tasks actually reach "done" + "Review": 0.18, # completed features get a recorded review +} +SDD_DISPLAY = { + "Pipeline": "Pipeline adherence", "Decomposition": "Task decomposition", + "Acceptance": "Acceptance criteria", "Closure": "Cycle closure", "Review": "Review coverage", +} +SDD_BLURB = { + "Pipeline": "Specs are backed by a task index and preceded by research (proposal/brainstorm).", + "Decomposition": "Features are split into right-sized tasks with declared dependencies, parallelism and effort.", + "Acceptance": "Specs state explicit, testable acceptance criteria before work starts.", + "Closure": "Features and their tasks are driven all the way to done — not left half-open.", + "Review": "Completed features get a recorded code review, closing the SDD feedback loop.", +} +SDD_BANDS = [ + ("Ad-hoc", 0, 39, "The workflow exists but artifacts skip steps: specs without tasks, tasks without " + "acceptance criteria, or features that never close. The biggest wins are mechanical — back every " + "spec with a task index and an acceptance section."), + ("Forming", 40, 54, "The core loop runs but unevenly. Some features go research -> spec -> task -> done " + "cleanly; others skip review or decomposition. The gap to the next level is consistency."), + ("Disciplined", 55, 69, "Most features follow the full loop: researched, specced, decomposed, closed. " + "Remaining gains are in review coverage and acceptance-criteria rigor."), + ("Rigorous", 70, 84, "The SDD loop is the default path: research-first specs, well-decomposed tasks with " + "dependencies, criteria stated up front, features closed and reviewed."), + ("Exemplary", 85, 100, "Spec-Driven Development is institutional: near-complete pipeline coverage, " + "consistently right-sized tasks, acceptance criteria everywhere, and reviews closing the loop."), +] + + +def _sdd_band(score): + for name, lo, hi, meaning in SDD_BANDS: + if lo <= score <= hi: + return name, meaning + return SDD_BANDS[-1][0], SDD_BANDS[-1][3] + + +def parse_sdd(sdd_dir): + """Read the sdd/ tree (specs, proposals, task indexes, reviews, state) and return a flat + dict of raw counts + derived rates. Pure stdlib, strictly read-only — never mutates a thing.""" + root = os.path.expanduser(sdd_dir) + out = {"present": os.path.isdir(root), "dir": sdd_dir} + if not out["present"]: + return out + + def _norm(p): + return p.replace("\\", "/") + + specs = glob.glob(os.path.join(root, "specs", "*.md")) + specs = sorted(p for p in specs if "/archived/" not in _norm(p)) + proposals = sorted(glob.glob(os.path.join(root, "proposals", "*.md"))) + indexes = sorted(glob.glob(os.path.join(root, "tasks", "index", "*.json"))) + reviews = [p for p in glob.glob(os.path.join(root, "reviews", "**", "*"), recursive=True) + if os.path.isfile(p)] + state_dirs = [d for d in glob.glob(os.path.join(root, "state", "*")) if os.path.isdir(d)] + + # --- Specs: acceptance-criteria coverage + status mix --- + spec_with_ac = 0 + status_mix = Counter() + for p in specs: + try: + txt = open(p, encoding="utf-8", errors="ignore").read() + except OSError: + continue + if _SDD_AC_RE.search(txt): + spec_with_ac += 1 + m = _SDD_STATUS_RE.search(txt) + status_mix[m.group(1).strip().lower() if m else "unknown"] += 1 + + # --- Task indexes: decomposition + cycle closure + lead time --- + feats = feats_closed = 0 + tasks_total = tasks_done = 0 + tasks_with_dep = tasks_parallel = tasks_with_effort = 0 + tasks_per_feat = [] + lead_minutes = [] + indexed_specs = set() + for ip in indexes: + try: + data = json.load(open(ip, encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + continue + feats += 1 + if data.get("completed_at"): + feats_closed += 1 + if data.get("spec"): + indexed_specs.add(os.path.basename(data["spec"])) + tk = data.get("tasks") or [] + tasks_per_feat.append(len(tk)) + for t in tk: + if not isinstance(t, dict): + continue + tasks_total += 1 + if t.get("status") == "done": + tasks_done += 1 + if t.get("depends_on"): + tasks_with_dep += 1 + if t.get("parallel"): + tasks_parallel += 1 + if t.get("effort"): + tasks_with_effort += 1 + st, ct = _parse_ts(t.get("started_at")), _parse_ts(t.get("completed_at")) + if st and ct: + # Index timestamps mix tz-aware and naive forms; normalize to naive to compare. + st = st.replace(tzinfo=None) + ct = ct.replace(tzinfo=None) + if ct >= st: + lead_minutes.append((ct - st).total_seconds() / 60.0) + + spec_basenames = {os.path.basename(p) for p in specs} + specs_with_tasks = len(spec_basenames & indexed_specs) + + def rate(num, den): + return (num / den) if den else None + + out.update({ + "counts": { + "specs": len(specs), "proposals": len(proposals), "indexes": len(indexes), + "reviews": len(reviews), "state_dirs": len(state_dirs), + "features": feats, "features_closed": feats_closed, + "tasks_total": tasks_total, "tasks_done": tasks_done, + "specs_with_tasks": specs_with_tasks, "specs_with_ac": spec_with_ac, + }, + "status_mix": dict(status_mix), + "spec_ac_rate": rate(spec_with_ac, len(specs)), + "spec_coverage": rate(specs_with_tasks, len(specs)), + "research_depth": rate(len(proposals) + len(state_dirs), feats), + "review_coverage": rate(len(reviews), feats), + "feat_closure_rate": rate(feats_closed, feats), + "task_done_rate": rate(tasks_done, tasks_total), + "decomp": { + "median_tasks_per_feature": statistics.median(tasks_per_feat) if tasks_per_feat else 0, + "dep_rate": rate(tasks_with_dep, tasks_total), + "parallel_rate": rate(tasks_parallel, tasks_total), + "effort_rate": rate(tasks_with_effort, tasks_total), + }, + "median_lead_minutes": statistics.median(lead_minutes) if lead_minutes else None, + }) + return out + + +def score_sdd(sdd): + """Turn raw sdd/ metrics into 0-100 sub-scores + an overall SDD-adherence score. + Returns None when there is no sdd/ tree or no features to judge.""" + if not sdd.get("present") or not sdd.get("counts", {}).get("features"): + return None + + def pct(x): + return 0.0 if x is None else clamp(x, 0.0, 1.0) + + dc = sdd["decomp"] + mt = dc["median_tasks_per_feature"] or 0 + # Granularity peaks at a 3-8 task/feature sweet spot; thin or giant decompositions taper off. + if 3 <= mt <= 8: + gran = 1.0 + elif mt < 3: + gran = clamp(mt / 3.0, 0.0, 1.0) + else: + gran = clamp(8.0 / mt, 0.0, 1.0) + decomposition = (0.35 * gran + 0.25 * pct(dc["dep_rate"]) + + 0.20 * pct(dc["parallel_rate"]) + 0.20 * pct(dc["effort_rate"])) + + sub = { + "Pipeline": 100 * (0.6 * pct(sdd["spec_coverage"]) + 0.4 * squash(sdd["research_depth"] or 0, 0.7)), + "Decomposition": 100 * decomposition, + "Acceptance": 100 * pct(sdd["spec_ac_rate"]), + "Closure": 100 * (0.5 * pct(sdd["feat_closure_rate"]) + 0.5 * pct(sdd["task_done_rate"])), + "Review": 100 * squash(sdd["review_coverage"] or 0, 0.5), + } + overall = round(sum(SDD_WEIGHTS[k] * sub[k] for k in SDD_WEIGHTS)) + band, meaning = _sdd_band(overall) + return { + "sub": {k: round(v) for k, v in sub.items()}, + "overall": overall, "band": band, "band_meaning": meaning, + "weights": SDD_WEIGHTS, "raw": sdd, + } + + +def _sdd_section_html(sdd_result, corpus, analysis=None): + """Render the SDD process-discipline panel. Returns '' when no SDD data is available.""" + if not sdd_result: + return "" + # Layer-2 AI narrative (Opus sdd_read), rendered above the deterministic bars when present. + sdd_read = "" + if isinstance(analysis, dict) and isinstance(analysis.get("sdd_read"), str) and analysis["sdd_read"].strip(): + sdd_read = (f'

{_esc(analysis["sdd_read"].strip())}

') + c = sdd_result["raw"]["counts"] + sub = sdd_result["sub"] + bars = "" + for name in sorted(SDD_WEIGHTS, key=lambda n: sub[n], reverse=True): + sc = sub[name] + bars += f""" +
+
{_esc(SDD_DISPLAY[name])}{sc}/100
+
+

{_esc(SDD_BLURB[name])}

+

weight {int(SDD_WEIGHTS[name]*100)}%

+
""" + + dc = sdd_result["raw"]["decomp"] + lead = sdd_result["raw"].get("median_lead_minutes") + + def _p(x): + return "n/a" if x is None else f"{x*100:.0f}%" + + facts = [ + f"{c['features']} features tracked · {c['features_closed']} closed " + f"({_p(sdd_result['raw']['feat_closure_rate'])})", + f"{c['specs']} specs · {c['specs_with_tasks']} backed by a task index " + f"({_p(sdd_result['raw']['spec_coverage'])}) · {c['specs_with_ac']} state acceptance criteria " + f"({_p(sdd_result['raw']['spec_ac_rate'])})", + f"{c['tasks_total']:,} tasks · {c['tasks_done']:,} done ({_p(sdd_result['raw']['task_done_rate'])}) · " + f"median {dc['median_tasks_per_feature']:.0f} tasks/feature", + f"{c['proposals']} proposals + {c['state_dirs']} research bundles before specs · " + f"{c['reviews']} recorded reviews ({_p(sdd_result['raw']['review_coverage'])})", + ] + if lead is not None: + facts.append(f"median task lead time {lead/60:.1f} h (started -> completed, where timestamped)") + cmd_line = "" + if corpus and corpus.sdd_commands: + top = ", ".join(f"/{k} ({v})" for k, v in corpus.sdd_commands.most_common(6)) + cmd_line = (f'

SDD slash-commands seen in your transcripts: ' + f'{_esc(top)}

') + facts_html = "".join(f"
  • {_esc(f)}
  • " for f in facts) + + return f""" +
    +

    SDD process discipline — how this repo runs Spec-Driven Development

    +
    {_esc(sdd_result['band'])} ({sdd_result['overall']}/100). {_esc(sdd_result['band_meaning'])}
    + {sdd_read} +
    {bars}
    +
      {facts_html}
    + {cmd_line} +

    Computed deterministically from the repo's + {_esc(sdd_result['raw']['dir'])}/ tree (specs, proposals, task indexes, reviews, research + state). This panel measures the project's workflow adherence, independent of the personal + AI-fluency scores above.

    +
    """ + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + +def main(argv=None): + ap = argparse.ArgumentParser(description="Claude Insight v2 — AI fluency analyzer (one command, zero install).") + ap.add_argument("path", nargs="?", help="transcript dir or .jsonl file (default: ~/.claude/projects)") + ap.add_argument("-o", "--out", default="ai_fluency_report.html", help="HTML output path") + ap.add_argument("--json", action="store_true", help="print raw metrics as JSON and exit") + ap.add_argument("--no-open", action="store_true", help="don't auto-open the report in a browser") + ap.add_argument("--archive", default=os.environ.get("CLAUDE_INSIGHT_ARCHIVE", DEFAULT_ARCHIVE_DIR), + metavar="DIR", + help="persistent archive that preserves transcripts beyond Claude Code's " + "30-day cleanup so history accumulates (default ~/.claude/insight-archive; " + "keep it private to you — a folder shared between people mixes their data)") + ap.add_argument("--no-archive", action="store_true", + help="don't copy this run's transcripts into the archive (still reads an existing one)") + ap.add_argument("--evidence", metavar="PATH", + help="write the de-contaminated evidence bundle (JSON) for the two-model " + "analysis pipeline to PATH ('-' for stdout), then continue") + ap.add_argument("--analysis", metavar="PATH", + help="merge an AI analysis (JSON from the Opus stage) into the report's skill map") + ap.add_argument("--analysis-evidence", metavar="PATH", dest="analysis_evidence", + help="the evidence bundle the --analysis was produced from; its run_fingerprint " + "is checked against this run so a stale/foreign analysis can't be merged") + ap.add_argument("--quiet", action="store_true", + help="suppress the terminal summary (the skill's internal measure pass uses this " + "so the score isn't surfaced before the full AI report is ready)") + ap.add_argument("--sdd-dir", default=os.environ.get("SDD_DIR", "sdd"), metavar="DIR", dest="sdd_dir", + help="path to the repo's SDD artifact tree for the Layer-2 process-discipline panel " + "(default ./sdd; skipped automatically if it doesn't exist)") + ap.add_argument("--no-sdd", action="store_true", dest="no_sdd", + help="skip the Layer-2 SDD process-discipline analysis (personal fluency only)") + args = ap.parse_args(argv) + + files = discover_files(args.path) + + # Default mode: maintain + read the persistent archive so we can analyze more than the + # ~30 days Claude Code keeps on disk. Skipped when an explicit path is given. + archive_info = None + if not args.path: + archive_dir = os.path.expanduser(args.archive) + new = updated = 0 + if not args.no_archive: + new, updated = archive_transcripts(files, archive_dir) + arch_files = _filter_transcripts(glob.glob(os.path.join(archive_dir, "**", "*.jsonl"), recursive=True)) + merged = _dedupe_sessions(files + arch_files) + archive_info = { + "dir": args.archive, "enabled": not args.no_archive, + "live_sessions": len(files), "archived_sessions": len(arch_files), + "merged_sessions": len(merged), "new": new, "updated": updated, + } + files = merged + # If most of what we're analyzing comes only from the archive (not this machine's + # live transcripts), a shared/synced archive could be feeding in someone else's data. + archive_only = archive_info["merged_sessions"] - archive_info["live_sessions"] + if archive_only > max(25, 2 * archive_info["live_sessions"]): + print(f" Note: {archive_only} of {archive_info['merged_sessions']} analyzed sessions exist " + f"only in the archive ({args.archive}), not in your live transcripts. If that archive " + f"is shared or synced across people/machines, this report may mix in data that isn't " + f"yours — point --archive at a private, per-person path.", file=sys.stderr) + + if not files: + where = args.path or "~/.claude/projects" + print(f"No Claude Code transcripts found in {where}.\n" + f"Point at your transcripts with: python3 insight.py /path/to/dir", file=sys.stderr) + return 1 + + corpus = parse(files) + if not corpus.real_prompts: + print("Found transcripts but no real human-typed prompts to analyze.", file=sys.stderr) + return 1 + + result = analyze(corpus) + cards, strength = build_action_plan(corpus, result) + + # Layer 2: repo-level SDD process discipline (None when disabled or no sdd/ tree). + sdd_result = None if args.no_sdd else score_sdd(parse_sdd(args.sdd_dir)) + + if args.evidence: + bundle = build_evidence(corpus, result, cards, archive_info, sdd_result) + text = json.dumps(bundle, indent=2) + if args.evidence == "-": + print(text) + else: + ep = os.path.abspath(args.evidence) + os.makedirs(os.path.dirname(ep) or ".", exist_ok=True) + with open(ep, "w", encoding="utf-8") as f: + f.write(text) + if not args.quiet: + print(f" Evidence: {ep}", file=sys.stderr) + + analysis = None + analysis_note = None + if args.analysis: + try: + with open(os.path.expanduser(args.analysis), encoding="utf-8") as f: + analysis = json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"Could not read --analysis {args.analysis}: {e}", file=sys.stderr) + return 1 + # Don't blindly trust the analysis file: it lives at a fixed, reused path, so it + # may be empty (the AI stage no-op'd) or left over from a different run/person. + # Validate shape + provenance; on any failure render the deterministic report + # only, and say so, rather than pasting someone else's verdict into this report. + current_fp = result.get("fingerprint") + if not isinstance(analysis, dict) or not analysis.get("skill_map"): + print(" Note: --analysis had no usable skill map (the AI stage may not have run); " + "rendering the deterministic report only.", file=sys.stderr) + analysis_note = "the AI skill-map stage returned no usable output" + analysis = None + elif args.analysis_evidence: + # Deterministic provenance gate: the analysis is valid for this run only if the + # evidence it was built from fingerprints to THIS run's data. insight.py wrote + # that fingerprint, so this check never depends on the model copying anything. + evidence_fp = None + try: + with open(os.path.expanduser(args.analysis_evidence), encoding="utf-8") as f: + evidence_fp = (json.load(f).get("meta") or {}).get("run_fingerprint") + except (OSError, json.JSONDecodeError): + evidence_fp = None + if evidence_fp != current_fp: + print(f" Note: the --analysis does not match this run (its evidence fingerprint " + f"{evidence_fp} != {current_fp}). Ignoring it so it can't leak into this " + f"report; rendering the deterministic report only.", file=sys.stderr) + analysis_note = ("the saved AI analysis was produced from a different run / " + "dataset, so it was not used") + analysis = None + else: + # Manual --analysis with no evidence binding: if the file itself happens to carry a + # run_fingerprint, honor it; otherwise merge (back-compat with hand-written analyses). + supplied_fp = analysis.get("run_fingerprint") + if supplied_fp and current_fp and supplied_fp != current_fp: + print(f" Note: the supplied --analysis was produced from a DIFFERENT run " + f"(fingerprint {supplied_fp} != {current_fp}). Ignoring it so it can't " + f"leak into this report; rendering the deterministic report only.", + file=sys.stderr) + analysis_note = ("the saved AI analysis was produced from a different run / " + "dataset, so it was not used") + analysis = None + + if args.json: + payload = { + "overall": result["overall"], "overall_raw": result["overall_raw"], + "band": result["band"], "archetype": result["archetype"]["label"], + "dimensions_raw": result["raw"], "dimensions_adjusted": result["shrunk"], + "confidence": result["conf"], "detail": result["detail"], + "data_ingested": { + "files": corpus.files, "projects": len(corpus.projects), + "bytes": corpus.total_bytes, "user_records": corpus.user_records, + "real_prompts": len(corpus.real_prompts), "filtered": dict(corpus.filtered), + "active_hours": round(corpus.active_seconds / 3600, 1), + "prompt_distribution": result["dist"], + "archive": archive_info, + }, + "sdd_discipline": ({"overall": sdd_result["overall"], "band": sdd_result["band"], + "sub_scores": sdd_result["sub"], "metrics": sdd_result["raw"]} + if sdd_result else None), + } + print(json.dumps(payload, indent=2)) + return 0 + + # Render fully before touching the file, so a render error can't leave a 0-byte report. + html_doc = build_html(corpus, result, cards, strength, archive_info, analysis, + analysis_note, sdd_result) + out_path = os.path.abspath(args.out) + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + f.write(html_doc) + + if not args.quiet: + print(terminal_summary(corpus, result)) + if sdd_result: + print(f" SDD Process Discipline: {sdd_result['overall']}/100 ({sdd_result['band']}) " + f"— {sdd_result['raw']['counts']['features']} features, " + f"{sdd_result['raw']['counts']['tasks_total']:,} tasks.") + if archive_info and archive_info["enabled"]: + print(f" Archive: {archive_info['merged_sessions']} sessions preserved at " + f"{archive_info['dir']} ({archive_info['new']} new, {archive_info['updated']} updated this run).") + print(f" Report: {out_path}\n") + if not args.no_open: + try: + webbrowser.open(f"file://{out_path}") + except Exception: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/install_hooks.py b/scripts/sdd/install_hooks.py new file mode 100644 index 00000000..f1af5f3b --- /dev/null +++ b/scripts/sdd/install_hooks.py @@ -0,0 +1,149 @@ +"""``install_hooks.py`` — install the daily /sdd-spec intake prune git hook (FEAT-577). + +Adds a marker-delimited block to post-checkout / post-merge / post-commit in the +effective hooks directory (``git rev-parse --git-path hooks``). The block runs +``python -m scripts.sdd.prune_intake --daily --apply`` from the primary checkout; +the ``--daily`` stamp limits it to once per 24 h. Idempotent; ``--uninstall`` +removes only this block. Refuses (exit 2) when the hooks directory is missing. +""" + +from __future__ import annotations + +import argparse +import stat +import subprocess +import sys +from pathlib import Path + +MARKER_BEGIN: str = "# >>> sdd-intake-prune >>>" +MARKER_END: str = "# <<< sdd-intake-prune <<<" +HOOK_EVENTS: tuple[str, ...] = ("post-checkout", "post-merge", "post-commit") +SHEBANG: str = "#!/bin/sh\n" + + +def hooks_dir(repo_root: Path) -> Path: + """Resolve ``git rev-parse --git-path hooks`` against ``repo_root`` (honours core.hooksPath).""" + out = subprocess.run( + ["git", "rev-parse", "--git-path", "hooks"], cwd=repo_root, capture_output=True, text=True, check=True + ).stdout.strip() + path = Path(out) + return path if path.is_absolute() else (repo_root / path).resolve() + + +def render_block(python: str, repo_root: Path) -> str: + """The marker-delimited shell block: primary checkout only; prune --daily --apply; never fails.""" + return ( + f"{MARKER_BEGIN}\n" + "# Prune /sdd-spec intake staging older than 10 days, at most once a day (FEAT-577).\n" + "# Installed by `python -m scripts.sdd.install_hooks`; remove with `--uninstall`.\n" + "if [ -d .git ]; then\n" + f' ( cd "{repo_root}" && "{python}" -m scripts.sdd.prune_intake --daily --apply ) >/dev/null 2>&1 || true\n' + "fi\n" + f"{MARKER_END}\n" + ) + + +def _strip_block(text: str) -> str: + """Remove an existing sdd-intake-prune block (markers inclusive); return the rest unchanged.""" + lines = text.splitlines(keepends=True) + start_idx = -1 + end_idx = -1 + for i, line in enumerate(lines): + if line.strip() == MARKER_BEGIN: + start_idx = i + elif line.strip() == MARKER_END: + end_idx = i + break + + if start_idx != -1 and end_idx != -1 and end_idx >= start_idx: + new_lines = lines[:start_idx] + lines[end_idx + 1 :] + return "".join(new_lines) + return text + + +def install(hooks: Path, block: str, events: tuple[str, ...] = HOOK_EVENTS) -> list[Path]: + """Add or replace the block in each hook; create + chmod +x missing hooks. Raises FileNotFoundError when ``hooks`` is missing.""" + if not hooks.is_dir(): + raise FileNotFoundError(hooks) + + modified_paths: list[Path] = [] + for event in events: + hook_path = hooks / event + if hook_path.exists(): + content = hook_path.read_text(encoding="utf-8") + else: + content = SHEBANG + + stripped = _strip_block(content) + if not stripped.endswith("\n") and stripped != "": + stripped += "\n" + + new_content = stripped + block + hook_path.write_text(new_content, encoding="utf-8") + + # Add execute permissions + current_mode = hook_path.stat().st_mode + hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + modified_paths.append(hook_path) + + return modified_paths + + +def uninstall(hooks: Path, events: tuple[str, ...] = HOOK_EVENTS) -> list[Path]: + """Remove only the sdd-intake-prune block from each hook; leave everything else byte-identical.""" + modified_paths: list[Path] = [] + if not hooks.is_dir(): + return modified_paths + + for event in events: + hook_path = hooks / event + if not hook_path.exists(): + continue + + content = hook_path.read_text(encoding="utf-8") + if MARKER_BEGIN not in content: + continue + + stripped = _strip_block(content) + hook_path.write_text(stripped, encoding="utf-8") + modified_paths.append(hook_path) + + return modified_paths + + +def main(argv: list[str] | None = None) -> int: + """CLI: [--uninstall] [--repo-root]. Exit 0; 2 when the hooks dir is missing (message names core.hooksPath).""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--uninstall", action="store_true") + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + args = parser.parse_args(argv) + + repo_root = args.repo_root.resolve() + try: + hooks = hooks_dir(repo_root) + except Exception: + # Fallback if git command fails or we are not in a git repo + hooks = repo_root / ".git" / "hooks" + + if not hooks.is_dir(): + print( + f"hooks directory {hooks} does not exist — check `git config core.hooksPath`; nothing installed", + file=sys.stderr, + ) + return 2 + + if args.uninstall: + uninstalled = uninstall(hooks) + for p in uninstalled: + print(f"Uninstalled prune hook from {p}") + else: + block = render_block(sys.executable, repo_root) + installed = install(hooks, block) + for p in installed: + print(f"Installed prune hook to {p}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/lint_new.py b/scripts/sdd/lint_new.py new file mode 100755 index 00000000..4fc808dd --- /dev/null +++ b/scripts/sdd/lint_new.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +"""Ruff runner that reports only the findings a branch introduced. + +The dev-loop QA gate scopes lint to the files a feature changed — correct, +but too blunt: a five-line addition to a module carrying 28 pre-existing +``UP``-series findings turns that whole backlog into a blocker for the +feature, and the QA feedback then asks the worker to modernise a file it +barely touched. This filters ruff's output down to findings whose reported +source range intersects a line the branch actually added or modified. + +Range intersection, not "the finding starts on a changed line", is the point: +``I001`` (un-sorted imports) is reported at the top of the import block with +an ``end_location`` spanning the whole block, so an import added five lines +below the reported row is still correctly attributed to this branch. + +Usage: + python -m scripts.sdd.lint_new [--base ] [ ...] + +Exit status: + 0 — the branch introduced no new ruff finding (pre-existing ones are + reported as an informational count only). + 1 — at least one new finding; each is printed in ``ruff --output-format + concise`` shape. + 2 — ruff itself could not run. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +#: Refs tried, in order, to locate the branch point when --base is omitted. +#: Mirrors ``QANode._get_changed_files``' own candidate order. +_BASE_CANDIDATES = ("origin/dev", "origin/main") + +_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + + +def _git(*args: str) -> str: + """Run a git command, returning stdout — or ``""`` on any failure.""" + try: + proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False) + except OSError: + return "" + return proc.stdout if proc.returncode == 0 else "" + + +def _merge_base(base: str | None) -> str: + """The commit this branch forked from, or ``""`` when undeterminable.""" + for ref in ([base] if base else list(_BASE_CANDIDATES)): + sha = _git("merge-base", ref, "HEAD").strip() + if sha: + return sha + return "" + + +def _parse_hunks(diff_text: str, added: dict[str, set[int]]) -> None: + """Fold ``git diff -U0`` output into a path -> added-line-numbers map.""" + current: str | None = None + for line in diff_text.splitlines(): + if line.startswith("+++ b/"): + current = line[len("+++ b/") :].strip() + added.setdefault(current, set()) + elif line.startswith("@@") and current is not None: + match = _HUNK_RE.match(line) + if match: + start = int(match.group(1)) + count = int(match.group(2) or 1) + added[current].update(range(start, start + count)) + + +def _added_lines(merge_base: str, paths: list[str]) -> dict[str, set[int]]: + """Every line this branch added or modified, per path. + + One ``git diff`` against the merge base covers committed AND uncommitted + work in a single call; untracked files are new in their entirety and are + added separately. + """ + added: dict[str, set[int]] = {} + if merge_base: + _parse_hunks(_git("diff", "-U0", merge_base, "--", *paths), added) + + untracked = _git("ls-files", "--others", "--exclude-standard", "--", *paths).split() + for rel in untracked: + try: + total = len(Path(rel).read_text(encoding="utf-8", errors="replace").splitlines()) + except OSError: + continue + added.setdefault(rel, set()).update(range(1, total + 1)) + + return added + + +def _ruff_findings(paths: list[str]) -> list[dict] | None: + """Ruff's JSON findings for ``paths``, or ``None`` when ruff failed.""" + try: + proc = subprocess.run( + ["ruff", "check", "--output-format", "json", "--force-exclude", *paths], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return None + # ruff exits 1 when it finds violations — that is not a failure here. + if proc.returncode not in (0, 1): + sys.stderr.write(proc.stderr) + return None + try: + return json.loads(proc.stdout or "[]") + except json.JSONDecodeError: + sys.stderr.write(proc.stdout) + return None + + +def _is_new(finding: dict, added: dict[str, set[int]], repo_root: str) -> bool: + """Whether a finding's source range intersects a line this branch changed.""" + filename = finding.get("filename") or "" + try: + rel = os.path.relpath(filename, repo_root) + except ValueError: + return True # cannot attribute it — fail closed, report it + lines = added.get(rel) + if not lines: + return False + start = int((finding.get("location") or {}).get("row") or 0) + end = int((finding.get("end_location") or {}).get("row") or start) + if start <= 0: + return True + return any(row in lines for row in range(start, max(end, start) + 1)) + + +def _format(finding: dict, repo_root: str) -> str: + """One finding in ``ruff --output-format concise`` shape.""" + filename = finding.get("filename") or "" + try: + rel = os.path.relpath(filename, repo_root) + except ValueError: + rel = filename + location = finding.get("location") or {} + return ( + f"{rel}:{location.get('row', 0)}:{location.get('column', 0)}: " + f"{finding.get('code') or '?'} {finding.get('message') or ''}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Report only the ruff findings this branch introduced.") + parser.add_argument("--base", default=None, help="Base ref (default: origin/dev, then origin/main)") + parser.add_argument("paths", nargs="*", help="Files to check") + args = parser.parse_args(argv) + + if not args.paths: + print("lint_new: no files to check — nothing to do.") + return 0 + + repo_root = _git("rev-parse", "--show-toplevel").strip() or os.getcwd() + + findings = _ruff_findings(args.paths) + if findings is None: + print("lint_new: ruff could not be run.", file=sys.stderr) + return 2 + + added = _added_lines(_merge_base(args.base), args.paths) + new = [f for f in findings if _is_new(f, added, repo_root)] + pre_existing = len(findings) - len(new) + + for finding in new: + print(_format(finding, repo_root)) + + if new: + print( + f"lint_new: {len(new)} new finding(s) introduced by this branch " + f"({pre_existing} pre-existing finding(s) ignored)." + ) + return 1 + + print(f"lint_new: no new findings ({pre_existing} pre-existing finding(s) " f"in the changed files were ignored).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/migrate_index.py b/scripts/sdd/migrate_index.py new file mode 100644 index 00000000..20687757 --- /dev/null +++ b/scripts/sdd/migrate_index.py @@ -0,0 +1,177 @@ +"""One-shot migration: monolithic ``sdd/tasks/.index.json`` → per-spec index files. + +The script reads the legacy monolith, groups tasks by their ``feature`` slug +(NOT ``feature_id`` — multiple specs share a numeric ID in the wild, so the +slug is the disambiguator), and writes one JSON file per feature into +``sdd/tasks/index/.json``. Tasks without a resolvable feature go to +``_orphans.json`` with a stderr warning per orphan. + +The script is **idempotent** — re-running on the same input produces +byte-equivalent output. It NEVER modifies or deletes the source monolith; +removing the source is a separate, explicit step performed by a human after +verifying the migration. + +Usage: + python -m scripts.sdd.migrate_index [--source PATH] [--dest DIR] [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + + +def _completed_at_of_group(tasks: list[dict[str, Any]]) -> str | None: + """Return the maximum ``completed_at`` across a group, or ``None``. + + A group is considered complete only when *every* task in it has + ``status == "done"``. Reading the timestamp from the task entries + themselves (instead of ``datetime.now()``) keeps the script + byte-stable across reruns. + """ + if not tasks or not all(t.get("status") == "done" for t in tasks): + return None + stamps = [t.get("completed_at") for t in tasks if t.get("completed_at")] + return max(stamps) if stamps else None + + +def _build_meta_registry(raw: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Build a slug → header-metadata map from the monolith. + + Combines the current top-level feature with the ``previous_features`` + registry so every historic feature has a metadata source for its + per-spec index header. + """ + meta: dict[str, dict[str, Any]] = {} + + current_slug = raw.get("feature") + if current_slug: + meta[current_slug] = { + "feature": current_slug, + "feature_id": raw.get("feature_id"), + "spec": raw.get("spec"), + "created_at": raw.get("created_at"), + } + + for prev in raw.get("previous_features", []) or []: + slug = prev.get("feature") + if slug and slug not in meta: + meta[slug] = { + "feature": slug, + "feature_id": prev.get("feature_id"), + "spec": prev.get("spec"), + "created_at": prev.get("created_at"), + } + + return meta + + +def migrate(source: Path, dest: Path, dry_run: bool = False) -> int: + """Split the monolithic ``source`` index into per-spec files under ``dest``. + + Args: + source: Path to the legacy ``sdd/tasks/.index.json``. + dest: Directory where per-spec ``.json`` files are written + (created if missing). + dry_run: When ``True``, compute the plan and emit warnings but do + not write any files. + + Returns: + Process exit code: ``0`` on success. + """ + raw = json.loads(source.read_text(encoding="utf-8")) + feat_meta = _build_meta_registry(raw) + + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + orphans: list[dict[str, Any]] = [] + for task in raw.get("tasks", []) or []: + slug = task.get("feature") + if not slug: + orphans.append(task) + continue + grouped[slug].append(task) + + if not dry_run: + dest.mkdir(parents=True, exist_ok=True) + + for slug in sorted(grouped): + tasks = grouped[slug] + meta = feat_meta.get(slug) or { + "feature": slug, + "feature_id": tasks[0].get("feature_id"), + "spec": tasks[0].get("spec"), + "created_at": None, + } + index_doc = { + "feature": meta["feature"], + "feature_id": meta.get("feature_id"), + "spec": meta.get("spec"), + "type": "feature", + "base_branch": "dev", + "created_at": meta.get("created_at"), + "completed_at": _completed_at_of_group(tasks), + "tasks": tasks, + } + if not dry_run: + (dest / f"{slug}.json").write_text( + json.dumps(index_doc, indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) + + if orphans: + for o in orphans: + print( + f"WARN: TASK-{o.get('id', '?')} has no feature; routed to _orphans.json", + file=sys.stderr, + ) + orph_doc = { + "feature": "_orphans", + "feature_id": None, + "spec": None, + "type": "feature", + "base_branch": "dev", + "created_at": None, + "completed_at": None, + "tasks": orphans, + } + if not dry_run: + (dest / "_orphans.json").write_text( + json.dumps(orph_doc, indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) + + return 0 + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point for the migration script.""" + parser = argparse.ArgumentParser( + description="Migrate monolithic SDD index to per-spec index files.", + ) + parser.add_argument( + "--source", + type=Path, + default=Path("sdd/tasks/.index.json"), + help="Path to the legacy monolithic index (default: sdd/tasks/.index.json)", + ) + parser.add_argument( + "--dest", + type=Path, + default=Path("sdd/tasks/index"), + help="Destination directory for per-spec index files (default: sdd/tasks/index)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Plan only — do not write files (orphan warnings still emitted).", + ) + args = parser.parse_args(argv) + return migrate(args.source, args.dest, args.dry_run) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/profile_execution.py b/scripts/sdd/profile_execution.py new file mode 100644 index 00000000..82ca16c8 --- /dev/null +++ b/scripts/sdd/profile_execution.py @@ -0,0 +1,715 @@ +"""``profile_execution.py`` — offline versioned execution profiling (FEAT-584 M7/R7). + +Reconstructs *requests* (not raw transcript rows) and *spans* (not naive +next-timestamp pairing) by explicit identity, from explicit ``--events``/ +``--transcript`` file paths. Never discovers files on its own -- no scanning +of private home sessions, no execution of local profiling scripts (spec R7: +"no ejecutar ni importar sin revisión los scripts de profiling locales con +rutas personales"). + +Two independent JSON Lines sources, each line one JSON object: + +``--events`` (required) + One object per line, structurally shaped like + ``parrot.flows.dev_loop.sdd_coder.optimization_models.WorkflowEvent`` + (this module deliberately does not import that class so it stays + framework-decoupled). The expected fields are pinned by the + ``ProfileEventRecord`` TypedDict below; parity with the authoritative + Pydantic model is machine-verified by + ``test_event_schema_parity_with_workflow_event`` (FEAT-596). + +``--transcript`` (optional) + One row per line from a host transcript. The expected fields are + pinned by the ``ProfileTranscriptRow`` TypedDict below. + +Historical bug this module structurally cannot reproduce: the previous +profiler summed per-category interval unions and then subtracted an +overlap figure computed separately, double-subtracting shared time between +categories (spec evidence: 36.2% vs the corrected 39.0%). Here the GLOBAL +union is computed once, directly, by merging every resolved span's real +(start, end) pair (see ``_merge_intervals``); per-category buckets are a +SEPARATE, independent union of only that category's spans, and are +expected to overlap the global union and each other -- their sum is not, +and must never be read as, the total (AC14). + +Background job spans (and any other ``*.finished``/``*.observed`` kind +lacking an already-settled ``payload`` window) are resolved ONLY from +paired events in ``--events`` (a real ``*.launched``/dispatch event and its +real ``*.finished``/receipt counterpart, joined by explicit identity +fields) -- never from an immediate transcript ``tool_result`` row. A +transcript row is used only to build request/token statistics, never to +close a span. When no real receipt/paired-start event exists, the span is +recorded as unresolved (span real = null) instead of being fabricated from +whichever event happens to come next (spec R3, R7, AC22). +""" + +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional, TypedDict + + +# --------------------------------------------------------------------------- +# Schema contracts: the fields this module reads from each JSON Lines source. +# These TypedDicts pin the duck-typed schema so a parity test can detect drift +# against the authoritative WorkflowEvent Pydantic model (FEAT-596). +# --------------------------------------------------------------------------- + + +class ProfileEventRecord(TypedDict, total=False): + """Fields this module reads from ``--events`` JSON Lines records. + + Structurally compatible with ``optimization_models.WorkflowEvent``; the + parity is machine-verified by ``test_event_schema_parity_with_workflow_event``. + """ + + kind: str + execution_id: str + task_id: Optional[str] + attempt_uid: Optional[str] + job_id: Optional[str] + timestamp: str + source: str + payload: dict[str, Any] + + +class ProfileTranscriptRow(TypedDict, total=False): + """Fields this module reads from ``--transcript`` JSON Lines records. + + Transcript rows are host-specific (not an sdd_coder contract), so there + is no upstream Pydantic model to validate against. This TypedDict exists + to document the expected shape and prevent silent field-name typos. + """ + + role: str + request_id: Optional[str] + message_id: Optional[str] + timestamp: str + tool_name: Optional[str] + tool_input: Optional[dict[str, Any]] + usage: Optional[dict[str, Any]] + process_id: Optional[str] + is_tool_result: Optional[bool] + + +# --------------------------------------------------------------------------- +# Pairing rules: end-kind -> (start-kind, identity fields that must all be +# present on BOTH events to join them). Joining is always by explicit +# identity, never by "the next event of the right kind" (spec R7). +# --------------------------------------------------------------------------- +_PAIR_RULES: dict[str, tuple[str, tuple[str, ...]]] = { + "attempt.finished": ("attempt.dispatched", ("execution_id", "task_id", "attempt_uid")), + "review.finished": ("review.started", ("execution_id",)), + "compaction.finished": ("compaction.requested", ("execution_id",)), + "background.finished": ("background.launched", ("execution_id", "job_id")), + "fallback.finished": ("fallback.started", ("execution_id",)), +} + +_INSPECTION_COMMANDS = frozenset( + {"cat", "less", "more", "head", "tail", "grep", "rg", "ls", "find", "wc", "pwd", "sed", "awk"} +) +_INSPECTION_GIT_SUBCOMMANDS = frozenset({"status", "diff", "log", "show", "blame"}) + +_TOKEN_FIELDS = ("input_tokens", "cache_read_tokens", "cache_creation_tokens", "output_tokens") + +_LONG_SPAN_THRESHOLD_S = 120.0 + + +# --------------------------------------------------------------------------- +# Small, isolated primitives. +# --------------------------------------------------------------------------- + + +def _read_jsonl(path: Path) -> tuple[list[dict[str, Any]], list[str]]: + """Parse one JSON object per line. + + An invalid line is skipped with an explicit warning -- never silently + dropped, never aborts the whole file (R7: degrade explicitly). + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + return [], [f"cannot read {path}: {exc}"] + + records: list[dict[str, Any]] = [] + warnings: list[str] = [] + for lineno, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError as exc: + warnings.append(f"{path}:{lineno}: invalid JSON ({exc.msg}), line skipped") + continue + if not isinstance(obj, dict): + warnings.append(f"{path}:{lineno}: expected a JSON object, line skipped") + continue + records.append(obj) + return records, warnings + + +def _parse_ts(raw: object) -> Optional[datetime]: + """Parse an ISO-8601 timestamp, requiring an explicit UTC/aware offset. + + A naive timestamp cannot be safely correlated across processes (spec + R3: "timestamps UTC para correlación entre procesos") so it is rejected + (returns None) rather than silently assumed to be UTC. + """ + if not isinstance(raw, str) or not raw: + return None + text = raw[:-1] + "+00:00" if raw.endswith("Z") else raw + try: + ts = datetime.fromisoformat(text) + except ValueError: + return None + if ts.tzinfo is None: + return None + return ts.astimezone(timezone.utc) + + +def _merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]: + """Merge overlapping/adjacent (start, end) pairs into a disjoint sorted set. + + This is the ONE place "active" time is ever totalled. Because it merges + real (start, end) pairs directly instead of summing per-category unions + and subtracting a separately-computed overlap figure, it structurally + cannot double-subtract shared time between categories -- the bug this + module exists to fix (spec R7, AC14). + """ + cleaned = sorted((s, e) for s, e in intervals if e > s) + merged: list[tuple[datetime, datetime]] = [] + for start, end in cleaned: + if merged and start <= merged[-1][1]: + if end > merged[-1][1]: + merged[-1] = (merged[-1][0], end) + else: + merged.append((start, end)) + return merged + + +def _union_seconds(intervals: list[tuple[datetime, datetime]]) -> float: + return sum((end - start).total_seconds() for start, end in _merge_intervals(intervals)) + + +def _classify_bash_command(command: object) -> str: + """Classify a Bash command as 'inspection' (recognized read-only op) or 'unknown'. + + Never falls back to a residual "bash-other" bucket treated as pure + inspection (spec R7): anything the parser does not positively recognize + stays 'unknown'. + """ + if not isinstance(command, str) or not command.strip(): + return "unknown" + try: + tokens = shlex.split(command.strip()) + except ValueError: + return "unknown" + if not tokens: + return "unknown" + head = tokens[0].rsplit("/", 1)[-1] + if head == "git": + sub = tokens[1] if len(tokens) > 1 else "" + return "inspection" if sub in _INSPECTION_GIT_SUBCOMMANDS else "unknown" + return "inspection" if head in _INSPECTION_COMMANDS else "unknown" + + +def _identity_key(event: dict[str, Any], fields: tuple[str, ...]) -> Optional[tuple[Any, ...]]: + values = tuple(event.get(field) for field in fields) + if any(value is None for value in values): + return None + return values + + +def _identity_snapshot(event: dict[str, Any]) -> dict[str, Any]: + return { + "execution_id": event.get("execution_id"), + "task_id": event.get("task_id"), + "attempt_uid": event.get("attempt_uid"), + "job_id": event.get("job_id"), + } + + +def _category_for(event: dict[str, Any]) -> str: + payload = event.get("payload") or {} + category = payload.get("category") if isinstance(payload, dict) else None + if isinstance(category, str) and category: + return category + kind = str(event.get("kind", "unknown")) + return kind.split(".", 1)[0] + + +def _resolve_payload_window(event: dict[str, Any]) -> Optional[tuple[datetime, datetime]]: + """An already-settled span the event's own payload carries (most authoritative).""" + payload = event.get("payload") or {} + if not isinstance(payload, dict): + return None + started_raw = payload.get("started_at") + ended_raw = payload.get("ended_at") + if started_raw is None or ended_raw is None: + return None + start = _parse_ts(started_raw) + end = _parse_ts(ended_raw) + if start is None or end is None: + return None + return start, end + + +def _clock_basis(start_event: dict[str, Any], end_event: dict[str, Any]) -> str: + """Distinguish a same-process monotonic-precise pairing from a wall-clock proxy. + + Duration between two identity-paired events is always ALSO reported on + UTC wall-clock terms (needed for the global union), but when both + boundary events agree on ``process_id`` and both carry a numeric + ``monotonic_s``, that is the more precise number -- and crucially, it + must never be computed across two DIFFERENT processes (spec R3: + "duraciones monotónicas solo dentro del mismo proceso"). + """ + start_payload = start_event.get("payload") or {} + end_payload = end_event.get("payload") or {} + if not isinstance(start_payload, dict) or not isinstance(end_payload, dict): + return "identity_paired" + same_process = ( + start_payload.get("process_id") is not None + and start_payload.get("process_id") == end_payload.get("process_id") + and isinstance(start_payload.get("monotonic_s"), (int, float)) + and isinstance(end_payload.get("monotonic_s"), (int, float)) + ) + return "identity_paired+monotonic_same_process" if same_process else "identity_paired" + + +def _span_public(span: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = { + "kind": span["kind"], + "category": span["category"], + "identity": span["identity"], + "start": span["start"].isoformat(), + "end": span["end"].isoformat(), + "duration_s": span["duration_s"], + "clock_basis": span["clock_basis"], + } + if span.get("duration_precise_s") is not None: + out["duration_precise_s"] = span["duration_precise_s"] + return out + + +# --------------------------------------------------------------------------- +# Span resolution. +# --------------------------------------------------------------------------- + + +def _resolve_spans(events: list[dict[str, Any]]) -> dict[str, list[Any]]: + by_kind: dict[str, list[dict[str, Any]]] = defaultdict(list) + for event in events: + kind = event.get("kind") + if isinstance(kind, str): + by_kind[kind].append(event) + + start_pools: dict[tuple[str, tuple[Any, ...]], list[dict[str, Any]]] = defaultdict(list) + for start_kind, id_fields in _PAIR_RULES.values(): + for event in by_kind.get(start_kind, []): + key = _identity_key(event, id_fields) + if key is not None: + start_pools[(start_kind, key)].append(event) + for pool in start_pools.values(): + pool.sort(key=lambda e: e.get("timestamp") or "") + + finished_like = [ + event for kind, group in by_kind.items() if kind.endswith((".finished", ".observed")) for event in group + ] + finished_like.sort(key=lambda e: e.get("timestamp") or "") + + active_spans: list[dict[str, Any]] = [] + fallback_spans: list[dict[str, Any]] = [] + excluded_spans: list[dict[str, Any]] = [] + unresolved: list[dict[str, Any]] = [] + warnings: list[str] = [] + + for end_event in finished_like: + kind = str(end_event.get("kind", "")) + category = _category_for(end_event) + + start: Optional[datetime] = None + end: Optional[datetime] = None + clock_basis = "payload_window" + duration_precise_s: Optional[float] = None + + window = _resolve_payload_window(end_event) + if window is not None: + start, end = window + elif kind in _PAIR_RULES: + start_kind, id_fields = _PAIR_RULES[kind] + key = _identity_key(end_event, id_fields) + end_ts = _parse_ts(end_event.get("timestamp")) + if key is not None and end_ts is not None: + candidate: Optional[dict[str, Any]] = None + for start_event in start_pools.get((start_kind, key), []): + if start_event.get("_consumed"): + continue + start_ts = _parse_ts(start_event.get("timestamp")) + if start_ts is None or start_ts > end_ts: + continue + candidate = start_event # closest not-yet-consumed preceding start, by identity + if candidate is not None: + candidate["_consumed"] = True + start = _parse_ts(candidate.get("timestamp")) + end = end_ts + clock_basis = _clock_basis(candidate, end_event) + if clock_basis == "identity_paired+monotonic_same_process": + start_mono = (candidate.get("payload") or {}).get("monotonic_s") + end_mono = (end_event.get("payload") or {}).get("monotonic_s") + duration_precise_s = float(end_mono) - float(start_mono) + + if start is None or end is None: + unresolved.append( + { + "kind": kind, + "category": category, + "identity": _identity_snapshot(end_event), + "reason": "no_payload_window_and_no_unconsumed_start_event", + } + ) + continue + if end < start: + warnings.append(f"reversed window for kind={kind} event_id={end_event.get('event_id')}, span skipped") + continue + + record: dict[str, Any] = { + "kind": kind, + "category": category, + "identity": _identity_snapshot(end_event), + "start": start, + "end": end, + "duration_s": (end - start).total_seconds(), + "clock_basis": clock_basis, + "duration_precise_s": duration_precise_s, + } + + payload = end_event.get("payload") or {} + if isinstance(payload, dict) and payload.get("baseline_excluded"): + excluded_spans.append({**_span_public(record), "defect_id": payload.get("defect_id")}) + elif category in ("fallback", "fallback_self_impl"): + fallback_spans.append(record) + else: + active_spans.append(record) + + return { + "active_spans": active_spans, + "fallback_spans": fallback_spans, + "excluded_spans": excluded_spans, + "unresolved_spans": unresolved, + "warnings": warnings, + } + + +def _build_attempts(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Consolidate per-(execution_id, task_id, attempt_uid) identity. + + Every join below is keyed by explicit identity fields carried on each + event -- never by "whichever event comes next" -- so an unrelated later + merge for a different attempt_uid can never attach to, shorten or + misattribute this attempt (spec R7). + """ + by_identity: dict[tuple[Any, Any, Any], dict[str, Any]] = {} + + def _get(execution_id: Any, task_id: Any, attempt_uid: Any) -> dict[str, Any]: + key = (execution_id, task_id, attempt_uid) + if key not in by_identity: + by_identity[key] = { + "execution_id": execution_id, + "task_id": task_id, + "attempt_uid": attempt_uid, + "dispatched_at": None, + "finished_at": None, + "observed_at": None, + "merged_at": None, + "accepted_at": None, + } + return by_identity[key] + + _ATTEMPT_FIELD = { + "attempt.dispatched": "dispatched_at", + "attempt.finished": "finished_at", + "delivery.observed": "observed_at", + } + for event in events: + kind = event.get("kind") + execution_id, task_id, attempt_uid = event.get("execution_id"), event.get("task_id"), event.get("attempt_uid") + if kind in _ATTEMPT_FIELD and execution_id and task_id and attempt_uid: + record = _get(execution_id, task_id, attempt_uid) + record[_ATTEMPT_FIELD[kind]] = event.get("timestamp") + + # merge.finished/task.accepted only require task_id (spec R3 minimal + # identity table): attach to every attempt sharing that task_id, further + # narrowed to an EXACT attempt_uid match when the event carries one -- + # never to whichever attempt happened to finish most recently. + for event in events: + kind = event.get("kind") + if kind not in ("merge.finished", "task.accepted"): + continue + task_id = event.get("task_id") + if not task_id: + continue + attempt_uid = event.get("attempt_uid") + field = "merged_at" if kind == "merge.finished" else "accepted_at" + for key, record in by_identity.items(): + if key[1] != task_id: + continue + if attempt_uid is not None and key[2] != attempt_uid: + continue + record[field] = event.get("timestamp") + + return [by_identity[key] for key in sorted(by_identity, key=lambda k: tuple(str(part) for part in k))] + + +# --------------------------------------------------------------------------- +# Requests (transcript-derived), kept explicitly separate from raw rows. +# --------------------------------------------------------------------------- + + +def _first_category(members: list[dict[str, Any]]) -> str: + for row in members: + tool_name = row.get("tool_name") + if tool_name == "Bash": + tool_input = row.get("tool_input") + command = tool_input.get("command") if isinstance(tool_input, dict) else None + return _classify_bash_command(command) + if isinstance(tool_name, str) and tool_name: + return tool_name.lower() + return "text" + + +def _combine_usage(members: list[dict[str, Any]]) -> dict[str, Optional[int]]: + """Resolve one settled usage reading per request from streamed rows. + + Never sums token counts ACROSS the streamed delta rows of the SAME + request (that would double count): usage is typically reported once, + already-summed, on the row that settles it. Scans from the last row + backwards for the first row that reports each field. + """ + result: dict[str, Optional[int]] = dict.fromkeys(_TOKEN_FIELDS, None) + for row in reversed(members): + usage = row.get("usage") + if not isinstance(usage, dict): + continue + for field in _TOKEN_FIELDS: + if result[field] is None and isinstance(usage.get(field), int): + result[field] = usage[field] + if all(result[field] is not None for field in _TOKEN_FIELDS): + break + return result + + +def _build_requests(rows: list[dict[str, Any]]) -> dict[str, Any]: + """Reconstruct requests (unique identity) from raw transcript rows. + + Streamed partial rows that share one ``request_id`` (falling back to + ``message_id``) collapse into ONE request -- ``count`` is deliberately + not the same number as ``assistant_rows_total`` (spec AC14: "separan + requests de mensajes"). + """ + groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + assistant_rows_total = 0 + for row in rows: + if row.get("role") == "assistant": + assistant_rows_total += 1 + key = row.get("request_id") or row.get("message_id") + if key is None: + continue + groups[str(key)].append(row) + + by_category: Counter[str] = Counter() + long_requests: list[dict[str, Any]] = [] + cross_process_count = 0 + token_sums: dict[str, int] = dict.fromkeys(_TOKEN_FIELDS, 0) + token_known_counts: dict[str, int] = dict.fromkeys(_TOKEN_FIELDS, 0) + context_known = 0 + context_unknown = 0 + + for request_id, members in groups.items(): + members_sorted = sorted(members, key=lambda r: r.get("timestamp") or "") + category = _first_category(members_sorted) + by_category[category] += 1 + + timestamps = [ts for ts in (_parse_ts(row.get("timestamp")) for row in members_sorted) if ts is not None] + process_ids = {row.get("process_id") for row in members_sorted if row.get("process_id") is not None} + duration_s: Optional[float] = None + if len(timestamps) >= 2: + duration_s = (max(timestamps) - min(timestamps)).total_seconds() + if len(process_ids) > 1: + cross_process_count += 1 + if duration_s is not None and duration_s >= _LONG_SPAN_THRESHOLD_S: + long_requests.append({"request_id": request_id, "category": category, "duration_s": duration_s}) + + usage = _combine_usage(members_sorted) + for field in _TOKEN_FIELDS: + value = usage[field] + if value is not None: + token_sums[field] += value + token_known_counts[field] += 1 + if all(usage[field] is not None for field in ("input_tokens", "cache_read_tokens", "cache_creation_tokens")): + context_known += 1 + else: + context_unknown += 1 + + tokens: dict[str, Any] = { + f"{field}_sum": (token_sums[field] if token_known_counts[field] else None) for field in _TOKEN_FIELDS + } + tokens["context_tokens_known_requests"] = context_known + tokens["context_tokens_unknown_requests"] = context_unknown + + return { + "count": len(groups), + "assistant_rows_total": assistant_rows_total, + "by_category": dict(by_category), + "long_requests": long_requests, + "clock_cross_process_count": cross_process_count, + "tokens": tokens, + } + + +def _wall_bounds( + events: list[dict[str, Any]], transcript_rows: list[dict[str, Any]] +) -> tuple[Optional[datetime], Optional[datetime]]: + stamps: list[datetime] = [] + for record in (*events, *transcript_rows): + ts = _parse_ts(record.get("timestamp")) + if ts is not None: + stamps.append(ts) + if not stamps: + return None, None + return min(stamps), max(stamps) + + +# --------------------------------------------------------------------------- +# Report assembly and CLI. +# --------------------------------------------------------------------------- + + +def build_report( + events: list[dict[str, Any]], + transcript_rows: list[dict[str, Any]], + *, + events_path: str, + transcript_path: Optional[str], +) -> dict[str, Any]: + """Build the versioned aggregate report from already-parsed records.""" + resolved = _resolve_spans(events) + requests = _build_requests(transcript_rows) + attempts = _build_attempts(events) + wall_start, wall_end = _wall_bounds(events, transcript_rows) + wall_span_s = (wall_end - wall_start).total_seconds() if wall_start and wall_end else None + + active_spans = resolved["active_spans"] + union_s = _union_seconds([(span["start"], span["end"]) for span in active_spans]) + + by_category: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list) + for span in active_spans: + by_category[span["category"]].append((span["start"], span["end"])) + category_buckets_s = {category: _union_seconds(intervals) for category, intervals in by_category.items()} + + fallback_spans = resolved["fallback_spans"] + fallback_union_s = _union_seconds([(span["start"], span["end"]) for span in fallback_spans]) + + residual_s = max(0.0, wall_span_s - union_s) if wall_span_s is not None else None + + long_spans = [ + {**_span_public(span), "bucket": "active"} + for span in active_spans + if span["duration_s"] >= _LONG_SPAN_THRESHOLD_S + ] + long_spans += [ + {**_span_public(span), "bucket": "fallback"} + for span in fallback_spans + if span["duration_s"] >= _LONG_SPAN_THRESHOLD_S + ] + + return { + "schema_version": 1, + "sources": {"events_path": events_path, "transcript_path": transcript_path}, + "wall_clock": { + "start": wall_start.isoformat() if wall_start else None, + "end": wall_end.isoformat() if wall_end else None, + "span_s": wall_span_s, + }, + "active": { + "union_s": union_s, + "category_buckets_s": category_buckets_s, + "residual_s": residual_s, + "spans": [_span_public(span) for span in active_spans], + "long_spans": long_spans, + "unresolved_spans": resolved["unresolved_spans"], + }, + "fallback_self_impl": { + "union_s": fallback_union_s, + "spans": [_span_public(span) for span in fallback_spans], + }, + "baseline": {"excluded_spans": resolved["excluded_spans"]}, + "attempts": attempts, + "requests": requests, + "warnings": resolved["warnings"], + } + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Reconstruct requests/spans by identity from explicit sdd-coder event/transcript files." + ) + parser.add_argument("--events", required=True, type=Path, help="Explicit path to an events JSONL file.") + parser.add_argument("--transcript", type=Path, default=None, help="Explicit path to a transcript JSONL file.") + parser.add_argument("--output", required=True, type=Path, help="Explicit path to write the aggregate JSON report.") + parser.add_argument("--pretty", action="store_true", help="Pretty-print the output JSON.") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Read explicit event/transcript paths and emit versioned aggregate JSON. + + Returns 0 on success, 2 on a usage error (missing/unparsable arguments + or an unreadable ``--events`` path). + """ + try: + args = _build_parser().parse_args(argv) + except SystemExit: + return 2 + + if not args.events.exists(): + print(f"profile_execution: --events path does not exist: {args.events}", file=sys.stderr) # noqa: T201 + return 2 + + events, events_warnings = _read_jsonl(args.events) + transcript_rows: list[dict[str, Any]] = [] + warnings: list[str] = list(events_warnings) + transcript_path: Optional[str] = None + if args.transcript is not None: + transcript_path = str(args.transcript) + if args.transcript.exists(): + transcript_rows, transcript_warnings = _read_jsonl(args.transcript) + warnings.extend(transcript_warnings) + else: + warnings.append(f"--transcript path does not exist, skipped: {args.transcript}") + + report = build_report( + events, + transcript_rows, + events_path=str(args.events), + transcript_path=transcript_path, + ) + report["warnings"] = warnings + report["warnings"] + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(report, indent=2 if args.pretty else None, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/prune_intake.py b/scripts/sdd/prune_intake.py new file mode 100644 index 00000000..d83f1237 --- /dev/null +++ b/scripts/sdd/prune_intake.py @@ -0,0 +1,169 @@ +"""``prune_intake.py`` — prune stale /sdd-spec intake staging (FEAT-577). + +Staged intake runs live under ``sdd/state/.intake/-/`` (git-ignored). +A git hook (installed by ``scripts/sdd/install_hooks.py``) calls this with +``--daily --apply`` on checkout/merge/commit; ``--daily`` lets it run at most once +per 24 h. Dry-run by default; only direct, non-symlink child directories of a +root that resolves to ``.../sdd/state/.intake`` are ever deleted. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import shutil +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +DEFAULT_ROOT: Path = Path("sdd/state/.intake") +DEFAULT_MAX_AGE_DAYS: int = 10 +DAILY_INTERVAL: timedelta = timedelta(hours=24) +STAMP_NAME: str = "sdd-intake-prune.stamp" + + +class StaleIntake(BaseModel): + """One staged run selected for pruning.""" + + path: Path + age_days: float + age_source: str # "updated_at" | "mtime" + + +def _repo_root() -> Path | None: + """Resolved top-level directory of the current git work tree, or None outside a repo.""" + result = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False) + if result.returncode != 0: + return None + return Path(result.stdout.strip()).resolve() + + +def _check_root(root: Path, repo_root: Path | None = None) -> Path: + """Return the resolved root, or raise ValueError when it is not an intake staging root inside a repo. + + ``repo_root`` defaults to the current git work tree's top level (via ``_repo_root()``); + callers may pass it explicitly (e.g. tests pinning a synthetic repo boundary). + """ + resolved = root.resolve() + if resolved.parts[-3:] != ("sdd", "state", ".intake"): + raise ValueError(f"refusing to prune outside sdd/state/.intake: {root}") + repo_root = repo_root.resolve() if repo_root is not None else _repo_root() + if repo_root is None or not (resolved == repo_root or resolved.is_relative_to(repo_root)): + raise ValueError(f"refusing to prune a root outside the repository: {root}") + return resolved + + +def run_age(run_dir: Path, now: datetime) -> tuple[timedelta, str]: + """Age from intake.json ``updated_at``; falls back to the dir mtime when missing/unparsable.""" + intake_json = run_dir / "intake.json" + try: + data = json.loads(intake_json.read_text(encoding="utf-8")) + updated_at = data["updated_at"] + ts_str = updated_at[:-1] + "+00:00" if updated_at.endswith("Z") else updated_at + updated = datetime.fromisoformat(ts_str) + if updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + return now - updated, "updated_at" + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + mtime = datetime.fromtimestamp(run_dir.stat().st_mtime, tz=timezone.utc) + return now - mtime, "mtime" + + +def find_stale( + root: Path, + max_age_days: int = DEFAULT_MAX_AGE_DAYS, + now: datetime | None = None, + repo_root: Path | None = None, +) -> list[StaleIntake]: + """Direct child dirs of ``root`` (no symlinks) older than ``max_age_days``. Missing root → [].""" + if not root.exists(): + return [] + resolved = _check_root(root, repo_root) + now = now or datetime.now(timezone.utc) + max_age = timedelta(days=max_age_days) + stale: list[StaleIntake] = [] + for entry in sorted(resolved.iterdir()): + if entry.is_symlink() or not entry.is_dir(): + continue + age, source = run_age(entry, now) + if age > max_age: + stale.append(StaleIntake(path=entry, age_days=age.total_seconds() / 86400.0, age_source=source)) + return stale + + +def prune( + root: Path, + max_age_days: int = DEFAULT_MAX_AGE_DAYS, + *, + apply: bool = False, + now: datetime | None = None, + repo_root: Path | None = None, +) -> list[StaleIntake]: + """Return the stale runs; delete them only when ``apply``. Raises ValueError for an unsafe root.""" + stale = find_stale(root, max_age_days, now, repo_root) + if apply and root.exists(): + resolved_root = _check_root(root, repo_root) + for run in stale: + if run.path.parent != resolved_root or run.path.is_symlink(): + continue + shutil.rmtree(run.path) + logger.info("pruned %s", run.path) + return stale + + +def default_stamp() -> Path | None: + """``$(git rev-parse --git-common-dir)/sdd-intake-prune.stamp`` (shared by all worktrees); None outside a repo.""" + result = subprocess.run(["git", "rev-parse", "--git-common-dir"], capture_output=True, text=True, check=False) + if result.returncode != 0: + return None + return Path(result.stdout.strip()).resolve() / STAMP_NAME + + +def claim_daily_slot(stamp: Path, now: datetime | None = None) -> bool: + """True (and touch the stamp) when the last run was ≥ 24 h ago or never; False otherwise.""" + now = now or datetime.now(timezone.utc) + if stamp.exists(): + mtime = datetime.fromtimestamp(stamp.stat().st_mtime, tz=timezone.utc) + if now - mtime < DAILY_INTERVAL: + return False + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.touch() + timestamp = now.timestamp() + os.utime(stamp, (timestamp, timestamp)) + return True + + +def main(argv: list[str] | None = None) -> int: + """CLI: --root, --older-than-days (default 10), --apply, --daily, --stamp. Exit 0; 2 on an unsafe root.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) + parser.add_argument("--older-than-days", type=int, default=DEFAULT_MAX_AGE_DAYS) + parser.add_argument("--apply", action="store_true", help="delete (default: dry-run)") + parser.add_argument("--daily", action="store_true", help="run at most once per 24 h (git hook mode)") + parser.add_argument( + "--stamp", type=Path, default=None, help="daily stamp file (default: /" + STAMP_NAME + ")" + ) + args = parser.parse_args(argv) + if args.daily: + stamp = args.stamp or default_stamp() + if stamp is None or not claim_daily_slot(stamp): + return 0 + try: + stale = prune(args.root, args.older_than_days, apply=args.apply) + except ValueError as exc: + print(str(exc)) # noqa: T201 - CLI output + return 2 + verb = "pruned" if args.apply else "would prune" + for run in stale: + print(f"{verb} {run.path.name} ({run.age_days:.1f}d, {run.age_source})") # noqa: T201 - CLI output + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/reserve_ids.py b/scripts/sdd/reserve_ids.py new file mode 100644 index 00000000..3170bc98 --- /dev/null +++ b/scripts/sdd/reserve_ids.py @@ -0,0 +1,657 @@ +"""``reserve_ids.py`` — the git-native compare-and-swap TASK/FEAT ID allocator. + +Replaces the "scan existing files, take the max, +1" numbering used by +``/sdd-task``/``/sdd-spec`` with an optimistic-concurrency-with-retry +allocator (FEAT-387): read ``sdd/tasks/.id_ledger.json`` (TASK-1963), +compute the requested reservation, build a *ledger-only* commit on top of +``origin/``, and push it. A non-fast-forward push rejection +means another allocation landed first — fetch, re-read the now-current +ledger, recompute, and retry (bounded, with jittered backoff), instead of +silently succeeding with a stale, already-claimed number. + +The reservation is a compare-and-swap against ``origin/`` +**alone**. The candidate commit is assembled with git plumbing +(``hash-object`` / ``read-tree`` / ``write-tree`` / ``commit-tree``) in a +throwaway index, so the local branch, the local index and the working tree +are never mutated by an attempt — a lost race costs one dangling commit +object and nothing else. In particular this allocator never runs ``git +reset --hard`` and never pushes ``HEAD``, so it can neither destroy nor +publish local-only commits on the base branch. + +Usage: + python -m scripts.sdd.reserve_ids --kind task --count 8 --base-branch dev --label sandbox-hardening +""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import re +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Literal + +from pydantic import BaseModel, Field, ValidationError + +from scripts.sdd.id_ledger import LEDGER_PATH, IdLedger, save_ledger + +#: Timeout (seconds) for every individual git subprocess call. This script +#: is meant to serve concurrent, automated dev-loop dispatches — an +#: indefinite hang (stalled network, interactive credential prompt) in one +#: allocator instance must not block forever. +_GIT_TIMEOUT_SECONDS = 30.0 + +#: The ledger's path as git spells it (forward slashes on every platform). +_LEDGER_GIT_PATH = LEDGER_PATH.as_posix() + + +class IdReservationError(RuntimeError): + """Raised when reserve_ids() exhausts its retry budget, or when a + non-retryable precondition (dirty working tree, wrong branch, invalid + push failure) is violated. + """ + + +class IdReservation(BaseModel): + """Result handed back to the calling SDD command.""" + + kind: str + first_id: int + count: int = Field(..., ge=1, description="Number of IDs reserved; must be at least 1.") + ids: list[str] + + +def _run_git( + args: list[str], + repo_root: Path, + *, + check: bool = True, + env_extra: dict[str, str] | None = None, + stdin: str | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a git subcommand rooted at ``repo_root``. + + Args: + args: Arguments following ``git`` (e.g. ``["status", "--porcelain"]``). + repo_root: Working directory to run the command in. + check: When ``True`` (default), raise ``CalledProcessError`` on a + non-zero exit code. Set to ``False`` for calls (like ``push``) + whose failure is expected and handled explicitly by the caller. + env_extra: Extra environment variables for this call — used to + point ``GIT_INDEX_FILE`` at a throwaway index so the plumbing + that builds the ledger commit never touches the real one. + stdin: Text piped to the command's standard input (used by + ``git hash-object --stdin``). + + Returns: + The completed subprocess result. + + Raises: + IdReservationError: If the git command does not complete within + ``_GIT_TIMEOUT_SECONDS`` (e.g. a stalled network or an + interactive credential prompt). Bounded so a single allocator + instance can never hang forever — and surfaced as this + module's own error type so ``reserve_ids()`` keeps its + contract that every failure mode is an ``IdReservationError``. + subprocess.CalledProcessError: If ``check`` is ``True`` and git + exits non-zero; callers translate this into an + ``IdReservationError`` with context. + """ + env = dict(os.environ) + # Never let git block on an interactive credential prompt — fail fast + # instead of hanging indefinitely. + env["GIT_TERMINAL_PROMPT"] = "0" + # Two of this module's decisions read git's output (the porcelain + # status codes and the push ref-status line). Neither is translated by + # the git version in use — verified empirically under a fully + # installed es_ES.UTF-8 with LANGUAGE=es, where the surrounding advice + # text IS translated ("ayuda:") but `! [rejected] ... (fetch first)` + # stays English. Pinning the locale anyway costs nothing and removes + # the dependency on that observation holding in future git releases. + env["LC_ALL"] = "C" + if env_extra: + env.update(env_extra) + try: + return subprocess.run( + ["git", *args], + cwd=repo_root, + check=check, + capture_output=True, + text=True, + env=env, + input=stdin, + timeout=_GIT_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise IdReservationError( + f"git {' '.join(args[:2])} timed out after " + f"{_GIT_TIMEOUT_SECONDS:.0f}s" + ) from exc + + +def _porcelain_path(line: str) -> str: + """Extract the file path from one `git status --porcelain` line. + + Porcelain v1 (non-verbose) format is a fixed 2-character status code + followed by a single space, then the path — parsing the field + positionally is more robust than a substring/``in`` check, which would + false-negative on any unrelated path that merely CONTAINS the ledger + path as a substring. + """ + return line[3:] if len(line) > 3 else line + + +def _assert_safe_to_reserve(root: Path, base_branch: str) -> None: + """Verify ``root`` is in the state this allocator is contracted to run in. + + Neither check is load-bearing for *safety* any more — the reservation + is built with plumbing against ``origin/`` and never + rewrites local history (see the module docstring). They are kept as a + contract check: ``/sdd-task`` and ``/sdd-spec`` are documented to run + on the base branch with a clean tree, and the post-reservation + fast-forward of the local branch only lands cleanly under those + conditions. Failing loudly here beats silently leaving the caller's + branch behind ``origin/``. + + Untracked files are ignored: nothing in this module can clobber them. + + Args: + root: Repository working directory to check. + base_branch: The branch this reservation is expected to push to. + + Raises: + IdReservationError: If tracked files have uncommitted changes + besides the ledger file, or the current branch is not + ``base_branch``. + """ + status = _run_git(["status", "--porcelain"], root) + dirty = [ + line + for line in status.stdout.splitlines() + if line.strip() + and not line.startswith("?? ") + and _porcelain_path(line) != _LEDGER_GIT_PATH + ] + if dirty: + raise IdReservationError( + "refusing to run — working tree has uncommitted changes besides " + f"{LEDGER_PATH}:\n" + "\n".join(dirty) + ) + + branch_result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], root) + current_branch = branch_result.stdout.strip() + if current_branch != base_branch: + raise IdReservationError( + f"refusing to run — current branch {current_branch!r} does not " + f"match --base-branch {base_branch!r}. reserve_ids() must run " + "with the target branch checked out, never from inside a " + "feature worktree." + ) + + +def _is_non_fast_forward_rejection(stderr: str) -> bool: + """Return whether ``stderr`` looks like a non-fast-forward push rejection. + + Git's ref-status line (``! [rejected] ... (fetch first)`` / + ``(non-fast-forward)``) is emitted in English regardless of the + process locale — verified empirically against the installed git + version — so this check is stable across environments, unlike the + surrounding, locale-translated advice text. + """ + return "[rejected]" in stderr and ( + "(fetch first)" in stderr or "(non-fast-forward)" in stderr + ) + + +def _resolve_base_sha(root: Path, base_branch: str, *, fetch: bool) -> str: + """Return the commit sha the next reservation attempt must build on. + + Args: + root: Repository working directory. + base_branch: Branch being reserved against. + fetch: When ``True``, refresh from the remote first and use the + fetched tip. Retries always fetch — a retry only happens + because the remote moved. The first attempt reads the existing + ``origin/`` remote-tracking ref (falling back to a + fetch if it does not exist yet): if that ref is stale, the + attempt simply loses the race and the retry corrects it. + + Returns: + The 40-character sha of the base commit. + + Raises: + IdReservationError: If the base tip cannot be resolved. + """ + if not fetch: + probe = _run_git( + ["rev-parse", "--verify", "--quiet", f"origin/{base_branch}^{{commit}}"], + root, + check=False, + ) + if probe.returncode == 0 and probe.stdout.strip(): + return probe.stdout.strip() + + # Fetch into the remote-tracking ref explicitly rather than reading + # FETCH_HEAD: FETCH_HEAD is a single per-worktree file, so a concurrent + # `git fetch` for a DIFFERENT branch in the same working directory (two + # SDD commands on one checkout is a documented hazard in this repo) + # could hand us an unrelated tip — which we would then push onto + # . The tracking ref is per-branch and cannot be confused. + tracking_ref = f"refs/remotes/origin/{base_branch}" + try: + _run_git( + ["fetch", "origin", f"+refs/heads/{base_branch}:{tracking_ref}"], root + ) + result = _run_git(["rev-parse", "--verify", f"{tracking_ref}^{{commit}}"], root) + except subprocess.CalledProcessError as exc: + raise IdReservationError( + f"could not resolve origin/{base_branch}: {exc.stderr or exc}" + ) from exc + return result.stdout.strip() + + +def _read_ledger_at(root: Path, base_sha: str) -> IdLedger: + """Read the ledger exactly as it exists in commit ``base_sha``. + + The remote's copy — not the working tree's — is the compare-and-swap's + input: a local edit or a stale checkout must never be able to hand out + a number the remote already issued. + + Args: + root: Repository working directory. + base_sha: Commit to read ``sdd/tasks/.id_ledger.json`` out of. + + Returns: + The parsed ``IdLedger``. + + Raises: + IdReservationError: If the ledger is missing from that commit. + """ + try: + result = _run_git(["show", f"{base_sha}:{_LEDGER_GIT_PATH}"], root) + except subprocess.CalledProcessError as exc: + raise IdReservationError( + f"{LEDGER_PATH} not found in {base_sha}: {exc.stderr or exc}" + ) from exc + + try: + return IdLedger.model_validate_json(result.stdout) + except ValidationError as exc: + raise IdReservationError( + f"{LEDGER_PATH} in {base_sha} is not a valid ledger: {exc}" + ) from exc + + +def _build_ledger_commit( + root: Path, base_sha: str, ledger: IdLedger, message: str +) -> str: + """Create a commit on top of ``base_sha`` changing only the ledger. + + Built entirely with plumbing against a throwaway index + (``GIT_INDEX_FILE``), so the caller's branch, index and working tree + are untouched. The commit is a loose object until the push succeeds; a + lost race leaves it dangling, to be reclaimed by ``git gc``. + + Args: + root: Repository working directory. + base_sha: Parent commit for the new commit. + ledger: The updated ledger to serialize into the commit. + message: Commit message. + + Returns: + The sha of the newly created commit object. + + Raises: + IdReservationError: If any plumbing step fails. + """ + with tempfile.TemporaryDirectory(prefix="reserve-ids-") as tmpdir: + index_env = {"GIT_INDEX_FILE": str(Path(tmpdir) / "index")} + ledger_json = Path(tmpdir) / "ledger.json" + save_ledger(ledger_json, ledger) + + try: + blob = _run_git( + ["hash-object", "-w", "--stdin"], + root, + stdin=ledger_json.read_text(encoding="utf-8"), + ).stdout.strip() + _run_git(["read-tree", base_sha], root, env_extra=index_env) + _run_git( + [ + "update-index", + "--add", + "--cacheinfo", + f"100644,{blob},{_LEDGER_GIT_PATH}", + ], + root, + env_extra=index_env, + ) + tree = _run_git(["write-tree"], root, env_extra=index_env).stdout.strip() + commit = _run_git( + ["commit-tree", tree, "-p", base_sha, "-m", message], root + ).stdout.strip() + except subprocess.CalledProcessError as exc: + raise IdReservationError( + f"could not build the ledger commit: {exc.stderr or exc}" + ) from exc + + return commit + + +def _last_git_error_line(stderr: str) -> str: + """Reduce git's multi-line stderr to the one line worth showing. + + git pads failures with advisory output (``hint:`` lines, blank lines). + Embedding all of it inside a warning pushes the sentence that actually + matters several lines away from its conclusion, which is how a + successful reservation ends up *looking* like a failed one — see + :func:`_sync_local_branch`. + + Args: + stderr: Raw stderr text captured from a git invocation. + + Returns: + The last non-empty, non-``hint:`` line, or ``"not a fast-forward"`` + when nothing usable remains. + """ + lines = [ + line.strip() + for line in stderr.splitlines() + if line.strip() and not line.strip().startswith("hint:") + ] + return lines[-1] if lines else "not a fast-forward" + + +def _sync_local_branch(root: Path, base_branch: str, commit_sha: str) -> None: + """Fast-forward the checked-out base branch onto the reserved commit. + + Best-effort and strictly non-destructive: ``--ff-only`` refuses rather + than rewrites, so a base branch carrying unpushed local commits (or a + locally modified ledger) is left exactly as it was, with a warning + telling the caller how to reconcile. The reservation has already + landed on the remote at this point — failing the call over a local + bookkeeping step would strand IDs that are now permanently issued. + + Args: + root: Repository working directory. + base_branch: Branch expected to be checked out. + commit_sha: The ledger commit that was just pushed. + """ + merge = _run_git(["merge", "--ff-only", commit_sha], root, check=False) + if merge.returncode == 0: + return + + # Message ordering is deliberate and load-bearing. The reservation has + # ALREADY succeeded here; only a local bookkeeping fast-forward was + # skipped. Earlier wording opened with the success, then embedded git's + # multi-line `hint:`/`fatal:` output mid-sentence and closed on "were NOT + # pushed" (which referred to the caller's own commits, not the + # reservation). Any truncation — `tail`, a CI log summary — kept the + # alarming half and dropped the reassuring one, so callers read it as a + # failure and re-ran, permanently burning the IDs they had just been + # issued. So: state the outcome first, keep git's noise to one line, and + # put the anti-retry instruction LAST where truncation cannot remove it. + reason = _last_git_error_line(merge.stderr) + print( + f"reserve_ids: reservation SUCCEEDED — the ledger commit is pushed to " + f"origin/{base_branch}.\n" + f"reserve_ids: only the local courtesy fast-forward was skipped " + f"({reason}); your own commits are untouched.\n" + f"reserve_ids: reconcile when convenient: " + f"`git pull --no-rebase origin {base_branch}`.\n" + f"reserve_ids: the ID(s) this command printed are ALREADY ALLOCATED — " + f"do NOT re-run it, or you will burn a second set.", + file=sys.stderr, + ) + + +#: A real, already-issued feature id. Deliberately does NOT match the +#: template placeholder ``FEAT-`` in ``sdd/templates/spec.md``, so a +#: scaffolded-but-unnumbered spec never looks like an existing identity. +_FEATURE_ID_RE = re.compile(r"\bFEAT-(\d{3,})\b") + + +def existing_feature_id(root: Path, label: str) -> tuple[str, str] | None: + """Return the feature id a slug already owns, if any. + + A slug has ONE feature id for its lifetime. Re-running ``/sdd-spec`` + over an existing spec must reuse that id, never reserve a second one: + a second reservation forks the feature's identity — the spec and the + task index move to the new number while the task files, the branch and + the worktree keep the old one, and ``DevelopmentNode._find_feature_slug`` + (which matches strictly on the index header's ``feature_id`` inside the + worktree) then finds no index at all and silently degrades a whole + multi-agent pool to a single agent. + + Args: + root: Repository root. + label: The feature slug (``--label``), i.e. the stem of + ``sdd/specs/.spec.md``. + + Returns: + ``(feature_id, source_path)`` when the slug already carries an id, + or ``None`` when it does not (no spec, no index, or a spec still + holding the template's ``FEAT-`` placeholder). + """ + spec = root / "sdd" / "specs" / f"{label}.spec.md" + if spec.is_file(): + try: + for line in spec.read_text(encoding="utf-8").splitlines(): + if line.lstrip().startswith("**Feature ID**"): + match = _FEATURE_ID_RE.search(line) + if match: + return f"FEAT-{match.group(1)}", str(spec) + # Placeholder line — scaffolded, not yet numbered. + break + except OSError: + pass + + index = root / "sdd" / "tasks" / "index" / f"{label}.json" + if index.is_file(): + try: + data = json.loads(index.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + if isinstance(data, dict): + candidate = str(data.get("feature_id") or "") + if _FEATURE_ID_RE.fullmatch(candidate): + return candidate, str(index) + return None + + +def reserve_ids( + kind: Literal["task", "feature"], + count: int, + base_branch: str, + label: str, + *, + max_retries: int = 5, + repo_root: Path | None = None, + sleep_fn: Callable[[float], None] = time.sleep, +) -> IdReservation: + """Atomically reserve ``count`` sequential TASK or FEAT numbers. + + Reads ``sdd/tasks/.id_ledger.json`` **as of ``origin/``**, + computes the next ``count`` numbers of the given ``kind``, builds a + ledger-only commit on that tip with git plumbing, and pushes it to + ``refs/heads/``. The push IS the compare-and-swap: on a + non-fast-forward rejection the remote moved first, so the allocator + fetches, re-reads the now-current ledger, recomputes, and retries up + to ``max_retries`` times (with jittered backoff). It raises + ``IdReservationError`` if retries are exhausted, or if the push fails + for a reason OTHER than a non-fast-forward rejection (e.g. + auth/network failure), since those are not safe to retry the same way. + + The pushed commit touches ONLY ``sdd/tasks/.id_ledger.json`` and has + ``origin/`` as its parent, so the reservation race is + decided by a single-line JSON diff — never by whatever else the caller + happens to have committed locally. + + **Local commits on the base branch are never published and never + destroyed.** Attempts run entirely in the object database plus a + throwaway index; no attempt mutates the local branch, the index or the + working tree, and no code path here runs ``git reset``. After a + successful push the local branch is fast-forwarded onto the + reservation as a convenience; if it cannot be (because it carries + unpushed commits), it is left untouched and a warning explains how to + reconcile. + + Args: + kind: ``"task"`` or ``"feature"``. + count: Number of sequential IDs to reserve. Must be ``>= 1`` — a + non-positive count would silently rewind the ledger's counter + below IDs already issued, reopening the exact collision this + allocator exists to close. + base_branch: The branch to push the ledger-only commit to (e.g. + ``"dev"``). ``reserve_ids()`` refuses to run unless this is + also the currently checked-out branch in ``repo_root``. + label: Free-text origin of this reservation (feature slug or + session id) — diagnostic only, stored in the ledger's + ``updated_by`` field. + max_retries: Maximum number of read-build-push attempts before + raising ``IdReservationError``. + repo_root: Directory to run all git operations in. Defaults to the + current working directory. Tests pass a throwaway clone here + so the retry loop never touches the real repository. + sleep_fn: Callable used for the jittered backoff between retries. + Defaults to ``time.sleep``; tests inject a no-op to avoid + waiting in real time. + + Returns: + The ``IdReservation`` describing the IDs that were successfully + reserved. + + Raises: + ValueError: If ``count < 1``. + IdReservationError: When every attempt is rejected up to + ``max_retries``, when a push fails for a non-retryable reason, + when the contract check (clean tree, correct branch) fails, or + — for ``kind="feature"`` — when ``label`` already owns a + feature id (see :func:`existing_feature_id`). + """ + if count < 1: + raise ValueError(f"count must be >= 1, got {count!r}") + + root = repo_root if repo_root is not None else Path.cwd() + + # A slug owns ONE feature id. Checked BEFORE the working-tree/branch + # contract so a duplicate /sdd-spec run gets the actionable message + # rather than an unrelated "wrong branch" one. Task reservations are + # untouched: reserving more TASK ids for an existing feature is the + # normal case. + if kind == "feature": + owned = existing_feature_id(root, label) + if owned is not None: + feature_id, source = owned + raise IdReservationError( + f"refusing to reserve a NEW feature id: slug {label!r} already " + f"owns {feature_id} (declared in {source}). Reuse it. A second " + "reservation forks the feature's identity — spec and task index " + "move to the new number while the task files, branch and worktree " + "keep the old one, and the dev-loop then finds no task index for " + "the run's feat_id and degrades the whole pool to a single agent. " + "If this is genuinely a different feature, give it a different " + "slug; if it is a deliberate multi-spec split of one initiative, " + "set reuse_feature_id in the spec frontmatter and do not call this " + "allocator." + ) + + _assert_safe_to_reserve(root, base_branch) + prefix = "TASK" if kind == "task" else "FEAT" + + for attempt in range(max_retries): + base_sha = _resolve_base_sha(root, base_branch, fetch=attempt > 0) + ledger = _read_ledger_at(root, base_sha) + first = ledger.next_task_id if kind == "task" else ledger.next_feature_id + ids = [f"{prefix}-{first + i}" for i in range(count)] + + if kind == "task": + ledger.next_task_id = first + count + else: + ledger.next_feature_id = first + count + ledger.updated_by = label + ledger.updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") + + commit_sha = _build_ledger_commit( + root, + base_sha, + ledger, + f"sdd: reserve {count} {kind} id(s) for {label}", + ) + + push_result = _run_git( + ["push", "origin", f"{commit_sha}:refs/heads/{base_branch}"], + root, + check=False, + ) + if push_result.returncode == 0: + _sync_local_branch(root, base_branch, commit_sha) + return IdReservation(kind=kind, first_id=first, count=count, ids=ids) + + if not _is_non_fast_forward_rejection(push_result.stderr): + # Not a rejection we know how to retry (auth/network failure, + # etc.) — fail loudly instead of retrying as a lost race. + # Nothing to undo: the attempt never left the object database. + raise IdReservationError( + "git push failed for a reason other than a non-fast-forward " + f"rejection: {push_result.stderr}" + ) + + # Rejected — someone else advanced the ledger first. Retry against + # the new remote tip; the candidate commit just built is abandoned + # as an unreferenced object. + sleep_fn(random.uniform(0.1, 0.5) * (attempt + 1)) + + raise IdReservationError( + f"Failed to reserve {count} {kind} id(s) after {max_retries} attempts" + ) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point. + + Prints the reserved IDs one per line on success (exit 0); prints an + error to stderr and exits 1 on failure. ``reserve_ids()`` itself + refuses to run (see its docstring) if tracked files have uncommitted + changes besides the ledger file, if the current branch does not match + ``--base-branch``, or if ``--count`` is not positive — this CLI + surfaces all three as a clean error message rather than a traceback. + """ + parser = argparse.ArgumentParser( + description="Reserve sequential TASK/FEAT IDs via a git-native compare-and-swap ledger (FEAT-387).", + ) + parser.add_argument("--kind", choices=["task", "feature"], required=True) + parser.add_argument("--count", type=int, required=True) + parser.add_argument("--base-branch", required=True) + parser.add_argument("--label", required=True) + parser.add_argument("--max-retries", type=int, default=5) + args = parser.parse_args(argv) + + try: + reservation = reserve_ids( + args.kind, + args.count, + args.base_branch, + args.label, + max_retries=args.max_retries, + ) + except (IdReservationError, ValueError) as exc: + print(f"reserve_ids: {exc}", file=sys.stderr) + return 1 + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + print(f"reserve_ids: git command failed: {exc}", file=sys.stderr) + return 1 + + for reserved_id in reservation.ids: + print(reserved_id) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/review_checkpoint.py b/scripts/sdd/review_checkpoint.py new file mode 100644 index 00000000..8958515b --- /dev/null +++ b/scripts/sdd/review_checkpoint.py @@ -0,0 +1,145 @@ +"""Prepare or validate the durable review handoff from authoritative local state. + +Spec `sdd/specs/sdd-execution-optimization.spec.md` (FEAT-584), module M4 +(R5 "Checkpoint antes del code reviewer"). Every fact this CLI reports comes +from durable, on-disk state (the settlement `end_execution` published, the +per-spec index/spec/convention files, and `git`) -- never from an in-memory +engine table, and never from a SHA the caller supplies: `prepare` always +resolves HEAD/branch/base SHA locally via `git`. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path + +from parrot.flows.dev_loop.sdd_coder.checkpoint import ( + CheckpointBusyError, + CheckpointError, + CheckpointIncompleteError, + CheckpointStaleError, + load_review_checkpoint, + prepare_review_checkpoint, + validate_review_checkpoint, +) +from parrot.flows.dev_loop.sdd_coder.evidence import ExecutionEvidenceStore +from parrot.flows.dev_loop.sdd_coder.optimization_models import ReviewCheckpoint +from parrot.flows.dev_loop.sdd_coder.telemetry import resolve_durable_root + +#: Mirrors `scripts.sdd.finalize_task._TELEMETRY_ROOT_ENV` -- the same +#: env-var contract, so `prepare` and `validate_review_checkpoint`'s own +#: internal resolution (`checkpoint.py`) always agree on one durable root. +_TELEMETRY_ROOT_ENV = "SDD_CODER_TELEMETRY_DIR" + + +def _checkpoint_summary(checkpoint: ReviewCheckpoint) -> dict[str, object]: + """Bounded, JSON-serializable projection -- never the full brief/refs payload twice.""" + return { + "checkpoint_id": checkpoint.checkpoint_id, + "execution_id": checkpoint.execution_id, + "feature": checkpoint.feature, + "worktree": checkpoint.worktree, + "branch": checkpoint.branch, + "base_sha": checkpoint.base_sha, + "implementation_head": checkpoint.implementation_head, + "commits": len(checkpoint.commits), + "task_refs": len(checkpoint.task_refs), + "criteria_refs": len(checkpoint.criteria_refs), + "validation_refs": len(checkpoint.validation_refs), + "evidence_refs": len(checkpoint.evidence_refs), + "pending_actions": checkpoint.pending_actions, + "neutral_brief": checkpoint.neutral_brief, + "relative_path": f"executions/{checkpoint.execution_id}/review/{checkpoint.checkpoint_id}.json", + } + + +def _build_store(worktree: Path) -> ExecutionEvidenceStore: + configured_root = os.environ.get(_TELEMETRY_ROOT_ENV) or None + root = resolve_durable_root(configured_root, worktree_base_path=str(worktree)) + return ExecutionEvidenceStore(root) + + +async def _run_prepare( + *, feature: str, worktree: Path, execution_id: str, store: ExecutionEvidenceStore +) -> dict[str, object]: + checkpoint = await prepare_review_checkpoint( + feature=feature, worktree=worktree, execution_id=execution_id, store=store + ) + return _checkpoint_summary(checkpoint) + + +async def _run_validate( + *, feature: str, worktree: Path, execution_id: str, checkpoint_id: str, store: ExecutionEvidenceStore +) -> dict[str, object]: + checkpoint = await load_review_checkpoint(execution_id, checkpoint_id, store=store) + if checkpoint.feature != feature: + raise ValueError(f"--feature {feature!r} does not match checkpoint.feature {checkpoint.feature!r}") + await validate_review_checkpoint(checkpoint, worktree=worktree) + return {**_checkpoint_summary(checkpoint), "valid": True} + + +def main(argv: list[str] | None = None) -> int: + """Prepare/validate: same codes and JSON output with a reference to the checkpoint. + + Exit codes: 0 success, 1 operation error, 2 invalid input, 3 stale/busy. + """ + parser = argparse.ArgumentParser(prog="python -m scripts.sdd.review_checkpoint", description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + prepare_parser = sub.add_parser("prepare", help="Publish a durable review checkpoint from local settlement.") + prepare_parser.add_argument("--feature", required=True) + prepare_parser.add_argument("--worktree", required=True) + prepare_parser.add_argument("--execution-id", required=True) + + validate_parser = sub.add_parser("validate", help="Reject a stale checkpoint before starting review.") + validate_parser.add_argument("--feature", required=True) + validate_parser.add_argument("--worktree", required=True) + validate_parser.add_argument("--execution-id", required=True) + validate_parser.add_argument("--checkpoint-id", required=True) + + args = parser.parse_args(argv) + worktree = Path(args.worktree) + + try: + store = _build_store(worktree) + except ValueError as exc: + print(json.dumps({"ok": False, "error": "invalid_input", "message": str(exc)})) + return 2 + + try: + if args.command == "prepare": + result = asyncio.run( + _run_prepare(feature=args.feature, worktree=worktree, execution_id=args.execution_id, store=store) + ) + else: + result = asyncio.run( + _run_validate( + feature=args.feature, + worktree=worktree, + execution_id=args.execution_id, + checkpoint_id=args.checkpoint_id, + store=store, + ) + ) + except ValueError as exc: + print(json.dumps({"ok": False, "error": "invalid_input", "message": str(exc)})) + return 2 + except (CheckpointBusyError, CheckpointStaleError, CheckpointIncompleteError) as exc: + print(json.dumps({"ok": False, "error": exc.error_code, "message": str(exc)})) + return 3 + except CheckpointError as exc: + print(json.dumps({"ok": False, "error": exc.error_code, "message": str(exc)})) + return 1 + except OSError as exc: + print(json.dumps({"ok": False, "error": "operation_error", "message": str(exc)})) + return 1 + + print(json.dumps({"ok": True, **result})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/sdd_meta.py b/scripts/sdd/sdd_meta.py new file mode 100644 index 00000000..be2a613c --- /dev/null +++ b/scripts/sdd/sdd_meta.py @@ -0,0 +1,26 @@ +"""Compatibility shim — the SDD frontmatter parser now ships in ``ai-parrot``. + +The implementation lives in :mod:`parrot.knowledge.wiki.ledger.sdd_meta` so the +installed ``wikitoolkit`` CLI can import it outside this repository (a wheel +never contains the repo-local ``scripts`` package). This module re-exports the +same objects so ``from scripts.sdd.sdd_meta import ...`` keeps working for the +SDD commands, agents and tests. +""" + +from parrot.knowledge.wiki.ledger.sdd_meta import ( # noqa: F401 + KNOWN_BRANCHES, + KNOWN_PROJECTS, + PROJECT_ALIASES, + WORK_KIND_FLOW, + WORKTREE_ROOT, + DocTaxonomy, + FlowMeta, + WorktreePlan, + emit, + normalize_project, + normalize_tag, + parse, + parse_taxonomy, + plan_worktree, + resolve_flow, +) diff --git a/scripts/sdd/select_tests.py b/scripts/sdd/select_tests.py new file mode 100644 index 00000000..345c6624 --- /dev/null +++ b/scripts/sdd/select_tests.py @@ -0,0 +1,112 @@ +"""``select_tests.py`` — tier-scoped pytest plans for SDD agents (FEAT-563). + +Usage: + python -m scripts.sdd.select_tests --tier {task,merge,feature} [--base origin/dev] + [--task-file sdd/tasks/active/TASK-NNN-x.md ...] [--worktree .] [--run] [--json] +""" + +from __future__ import annotations + +import argparse +import shlex +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +_KERNEL_PARENT = Path(__file__).resolve().parents[2] / "packages/ai-parrot/src/parrot/flows/dev_loop" + + +def _load_kernel() -> ModuleType: + """Import the stdlib kernel by path as top-level `test_scope` (never `import parrot`).""" + if str(_KERNEL_PARENT) not in sys.path: + sys.path.insert(0, str(_KERNEL_PARENT)) + import test_scope # noqa: PLC0415 — path-dependent import + + return test_scope + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Plan (and optionally run) tier-scoped pytest invocations.") + parser.add_argument("--tier", required=True, choices=("task", "merge", "feature")) + parser.add_argument("--base", default="origin/dev") + parser.add_argument("--task-file", action="append", default=[], type=Path) + parser.add_argument("--worktree", type=Path, default=Path.cwd()) + parser.add_argument("--run", action="store_true") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: 0 ok, 1 an invocation failed, 2 usage error / empty task-tier plan.""" + try: + args = _build_parser().parse_args(argv) + except SystemExit: + return 2 + kernel = _load_kernel() + worktree = args.worktree.resolve() + + declared: list[list[str]] = [] + for task_file in args.task_file: + path = task_file if task_file.is_absolute() else worktree / task_file + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + declared.extend(kernel.contract.parse_validation_commands(text)) + + plan = kernel.plan_tests( + worktree=worktree, + changed_files=kernel.changed_files(worktree, args.base), + tier=args.tier, + declared=declared, + ) + + if not plan.invocations and args.tier == "task": + import test_scope.guard as guard_mod # noqa: PLC0415 — path-dependent import + + print(guard_mod.BLOCK_MESSAGE) # noqa: T201 - CLI output + return 2 + + if args.json: + import test_scope.models as models_mod # noqa: PLC0415 — the only kernel module importing pydantic + + print(models_mod.ScopePlanModel.from_plan(plan).model_dump_json(indent=2)) # noqa: T201 - CLI output + else: + for invocation in plan.invocations: + print(shlex.join(invocation.argv)) # noqa: T201 - CLI output + for note in plan.notes: + print(f"# note: {note}") # noqa: T201 - CLI output + for dist in plan.escalated: + print(f"# escalated: {dist}") # noqa: T201 - CLI output + for dist in plan.skipped_escalations: + print(f"# skipped: {dist}") # noqa: T201 - CLI output + + if not args.run: + return 0 + + exit_code = 0 + for invocation in plan.invocations: + result = subprocess.run(list(invocation.argv), cwd=worktree) + is_core_escalation = any(target.reason == "core" for target in invocation.targets) + is_cap_escalation = any(target.reason == "escalated" for target in invocation.targets) + if result.returncode != 0: + exit_code = 1 + if is_core_escalation or is_cap_escalation: + # R14/AC9c: a red escalation run re-arms it, even when its driving file content is unchanged. + kernel.context.record_red_run(worktree, [invocation.distribution]) + continue + if is_core_escalation or is_cap_escalation: + dist = invocation.distribution + core_files = [hit.path for hit in plan.core_hits if dist in hit.distributions] + impact_files = list(plan.cap_hits.get(dist, ())) + impacted_hashes = {dist: plan.cap_impacted[dist]} if dist in plan.cap_impacted else {} + if core_files or impact_files: + kernel.context.record_green_escalation( + worktree, [dist], core_files, impact_files=impact_files, impacted_hashes=impacted_hashes + ) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdd/tag_yaml_fixtures.py b/scripts/sdd/tag_yaml_fixtures.py new file mode 100644 index 00000000..c53a04c4 --- /dev/null +++ b/scripts/sdd/tag_yaml_fixtures.py @@ -0,0 +1,192 @@ +"""scripts/sdd/tag_yaml_fixtures.py + +Idempotently inserts ``tenant: navigator`` into YAML form fixtures that +declare ``form_id:`` but lack a top-level ``tenant:`` field. + +Usage:: + + python -m scripts.sdd.tag_yaml_fixtures [--dry-run] [--roots PATH ...] + +The default roots are the in-repo YAML fixture locations relevant to +``parrot-formdesigner``. Pass ``--roots`` to override them. + +Idempotency guarantee: + Running the script twice against the same directory produces no diff on + the second run. The script checks for an existing ``tenant:`` key in + the parsed YAML before writing. + +Diff-minimal strategy: + The script inserts a single line ``tenant: navigator`` immediately after + the line that starts with ``form_id:``, preserving all other formatting. + It does NOT round-trip the YAML through ``yaml.safe_dump()``. + +CI integration (follow-up): + Add ``python -m scripts.sdd.tag_yaml_fixtures --dry-run`` to the CI + lint step after merging FEAT-183 to ensure new fixtures always carry + ``tenant:``. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +import yaml + +DEFAULT_ROOTS: list[str] = [ + "packages/parrot-formdesigner/tests", + "examples/forms", + "tests/forms", +] +DEFAULT_TENANT = "navigator" +LOG = logging.getLogger("tag_yaml_fixtures") + + +def is_form_fixture(parsed: object) -> bool: + """Return True when *parsed* looks like a form fixture dict. + + A form fixture is identified by having ``form_id`` as a top-level key + in a YAML mapping. + + Args: + parsed: Python object parsed from YAML. + + Returns: + True if *parsed* is a dict with a ``form_id`` key. + """ + return isinstance(parsed, dict) and "form_id" in parsed + + +def already_tagged(parsed: dict) -> bool: # type: ignore[type-arg] + """Return True when the YAML dict already carries a ``tenant:`` key. + + Args: + parsed: Python dict parsed from YAML. + + Returns: + True if ``tenant`` is present at the top level. + """ + return "tenant" in parsed + + +def tag_file(path: Path, *, dry_run: bool = False) -> str: + """Inspect and (conditionally) tag a single YAML file. + + The insertion is diff-minimal: one line ``tenant: navigator`` is inserted + immediately after the first line that begins with ``form_id:`` (matching + the indent of that line). If no ``form_id:`` line is found (very unlikely + for a detected form fixture), the line is appended at the end. + + Args: + path: Path to the YAML file to inspect/modify. + dry_run: When ``True``, report what would be done without writing. + + Returns: + One of: + - ``"tagged"``: file was (or would be, in dry-run) tagged. + - ``"already"``: file already has ``tenant:``; left untouched. + - ``"not-a-fixture"``: file lacks ``form_id:`` at root; skipped. + - ``"parse-error"``: YAML parse failed; skipped. + """ + try: + content = path.read_text(encoding="utf-8") + parsed = yaml.safe_load(content) + except Exception as exc: + LOG.debug("parse error %s: %s", path, exc) + return "parse-error" + + if not is_form_fixture(parsed): + return "not-a-fixture" + + if already_tagged(parsed): + return "already" + + if dry_run: + return "tagged" + + # Diff-minimal insertion: insert tenant: after the form_id: line. + lines = content.splitlines(keepends=True) + out: list[str] = [] + inserted = False + for line in lines: + out.append(line) + if not inserted and line.lstrip().startswith("form_id:"): + indent = line[: len(line) - len(line.lstrip())] + out.append(f"{indent}tenant: {DEFAULT_TENANT}\n") + inserted = True + + if not inserted: + # Fallback: append at end (very unlikely — form_id: must be present + # for is_form_fixture() to return True, so we always reach the above + # branch under normal circumstances). + out.append(f"tenant: {DEFAULT_TENANT}\n") + + path.write_text("".join(out), encoding="utf-8") + return "tagged" + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the fixture tagger. + + Args: + argv: Command-line arguments (defaults to ``sys.argv[1:]``). + + Returns: + Exit code (always 0 on success regardless of how many files were + tagged; non-zero only on unexpected errors). + """ + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Report what would be changed without writing files.", + ) + parser.add_argument( + "--roots", + nargs="*", + default=DEFAULT_ROOTS, + metavar="PATH", + help=( + "Root directories to search for YAML fixtures. " + "Defaults to the in-repo parrot-formdesigner fixture roots." + ), + ) + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(message)s") + + counts: dict[str, int] = { + "tagged": 0, + "already": 0, + "not-a-fixture": 0, + "parse-error": 0, + } + + for root_str in args.roots: + root_path = Path(root_str) + if not root_path.exists(): + LOG.info("skip missing root %s", root_path) + continue + + yaml_files = list(root_path.rglob("*.yaml")) + list(root_path.rglob("*.yml")) + for yaml_file in sorted(yaml_files): + result = tag_file(yaml_file, dry_run=args.dry_run) + counts[result] += 1 + if result == "tagged": + action = "would-tag" if args.dry_run else "tagged" + LOG.info("%s %s", action, yaml_file) + elif result == "parse-error": + LOG.debug("parse-error %s", yaml_file) + + LOG.info("summary: %s", counts) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sdd/token_audit.py b/scripts/sdd/token_audit.py new file mode 100644 index 00000000..9964850f --- /dev/null +++ b/scripts/sdd/token_audit.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""Attribute Claude Code token spend to SDD features, from the local transcripts. + +Claude Code writes one JSONL transcript per session under +``~/.claude/projects//`` (subagent transcripts live in +``/subagents/``). Every assistant record carries a ``usage`` block, +and each session carries a ``cost-state`` record with Claude Code's own cost +accounting plus ``pr-link`` records. This script rolls those up per SDD feature. + +Two facts make or break the numbers: + +1. **One API response is written as several transcript lines** (one per content + block), each repeating the *same* ``usage`` object. Counting lines instead of + ``message.id`` inflates every total ~5x. +2. **Cache reads dominate.** ~99% of tokens and ~70% of cost are + ``cache_read_input_tokens`` — i.e. the context re-read on every turn. Raw + "tokens per feature" is therefore a context-size metric, not an effort one; + compare ``$/turn`` and ``ctx/turn``, not token totals. + +Feature attribution uses work signals, strongest wins, most recent stays in force: + + tier 3 worktree dir / branch ``feat-FEAT-`` — the agent is working there + tier 2 ``TASK-`` resolved via ``sdd/tasks/index/*.json`` + tier 1 ``FEAT-`` typed by the human (prompt or slash-command args) + tier 0 ``FEAT-`` anywhere (tool output, file contents) — fallback only + +Tiers 3+2 cover ~99.8% of tokens, which is why a plain "last FEAT-id mentioned" +heuristic is not used: a session that reads the ``sdd/`` tree mentions 30-70 ids. + +Not covered: tokens burned by the external sdd-coder seats (nova / codex / +google). Those live on other providers and are recorded, when the durable usage +sink is enabled, in ``artifacts/logs/sdd-coder-usage/.jsonl`` — see +``scripts/analyze_sdd_coder_usage.py``. + +Usage: + python scripts/sdd/token_audit.py scan # build the row cache + python scripts/sdd/token_audit.py features # spend per feature + python scripts/sdd/token_audit.py trend # per-turn economics by week + python scripts/sdd/token_audit.py feature FEAT-584 +""" +from __future__ import annotations + +import argparse +import collections +import datetime as dt +import json +import re +import statistics +import sys +from pathlib import Path +from typing import Any, Iterator + +PROJECTS = Path.home() / ".claude" / "projects" +REPO = Path(__file__).resolve().parents[2] +CACHE = REPO / "artifacts" / "logs" / "token-audit" + +#: $/MTok: (fresh input, output, cache write 5m, cache write 1h, cache read). +#: Verified against the ``cost-state`` records Claude Code writes itself. +PRICES: dict[str, tuple[float, float, float, float, float]] = { + "claude-opus-5": (5.00, 25.00, 6.25, 10.00, 0.50), + "claude-opus-4-8": (5.00, 25.00, 6.25, 10.00, 0.50), + "claude-sonnet-5": (2.00, 10.00, 2.50, 4.00, 0.20), + "claude-sonnet-4-6": (3.00, 15.00, 3.75, 6.00, 0.30), + "claude-haiku-4-5": (1.00, 5.00, 1.25, 2.00, 0.10), + "claude-fable-5-1": (10.00, 50.00, 12.50, 20.00, 0.25), + "claude-fable-5": (10.00, 50.00, 12.50, 20.00, 1.00), +} + +WORKTREE_RE = re.compile(r"(?:feat-|feat/)FEAT-(\d{2,4})") +FEAT_RE = re.compile(r"\bFEAT-(\d{2,4})\b") +TASK_RE = re.compile(r"\bTASK-(\d{2,5})\b") +COMMAND_RE = re.compile(r"/([a-z0-9\-]+)") +COMMAND_ARGS_RE = re.compile(r"(.*?)", re.S) + + +def price(model: str, row: dict[str, int]) -> float: + """Return the USD cost of one response, or 0.0 for an unpriced model.""" + for prefix, (inp, out, write5m, write1h, read) in PRICES.items(): + if model.startswith(prefix): + return ( + row["input"] * inp + + row["output"] * out + + row["cw5"] * write5m + + row["cw1"] * write1h + + row["cr"] * read + ) / 1e6 + return 0.0 + + +def task_to_feature() -> dict[str, str]: + """Map every ``TASK-`` to its feature id via the per-spec task indexes.""" + mapping: dict[str, str] = {} + for path in (REPO / "sdd" / "tasks" / "index").glob("*.json"): + try: + index = json.loads(path.read_text()) + except (OSError, ValueError): + continue + feature_id = index.get("feature_id") + for task in index.get("tasks") or []: + task_id = task.get("id") + if task_id: + mapping[task_id] = task.get("feature_id") or feature_id + return mapping + + +def feature_index() -> dict[str, dict[str, Any]]: + """Return per-feature metadata (slug, spec, task count) from the indexes.""" + meta: dict[str, dict[str, Any]] = {} + for path in (REPO / "sdd" / "tasks" / "index").glob("*.json"): + try: + index = json.loads(path.read_text()) + except (OSError, ValueError): + continue + feature_id = index.get("feature_id") + if feature_id: + meta[feature_id] = { + "feature": index.get("feature"), + "spec": index.get("spec"), + "type": index.get("type"), + "ntasks": len(index.get("tasks") or []), + } + return meta + + +def _human_text(record: dict[str, Any]) -> str: + """Return the text the human actually typed, or '' for anything else.""" + if record.get("type") != "user": + return "" + content = (record.get("message") or {}).get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + return "" + + +def parse_transcript( + path: Path, + session_id: str, + tasks: dict[str, str], + agent_file: str | None = None, + inherit: dict[int, str | None] | None = None, +) -> tuple[list[dict[str, Any]], dict[str, Any], dict[int, str | None]]: + """Parse one transcript into per-response rows plus session metadata.""" + current: dict[int, str | None] = {3: None, 2: None, 1: None, 0: None} + if inherit: + current.update({tier: feat for tier, feat in inherit.items() if feat}) + command: str | None = None + seen: set[str] = set() + rows: list[dict[str, Any]] = [] + meta: dict[str, Any] = { + "cost_state": None, + "prs": set(), + "commands": [], + "agent": None, + "tasks": set(), + "start": None, + "end": None, + "branches": set(), + "coder_chunks": 0, + "coder_calls": 0, + } + + with path.open(errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except ValueError: + continue + kind = record.get("type") + if kind == "cost-state": + meta["cost_state"] = record + continue + if kind == "pr-link": + meta["prs"].add(record.get("prNumber")) + continue + if kind == "agent-setting": + meta["agent"] = record.get("agentSetting") + continue + + raw = json.dumps(record, ensure_ascii=False) + timestamp = record.get("timestamp") + if timestamp: + if meta["start"] is None or timestamp < meta["start"]: + meta["start"] = timestamp + if meta["end"] is None or timestamp > meta["end"]: + meta["end"] = timestamp + if record.get("gitBranch"): + meta["branches"].add(record["gitBranch"]) + + commands = COMMAND_RE.findall(raw) + meta["commands"].extend(commands) + if commands: + command = commands[-1] + if '"mcp__parrot-sdd-coder__coder_run_chunk"' in raw: + meta["coder_chunks"] += 1 + if '"mcp__parrot-sdd-coder__' in raw: + meta["coder_calls"] += 1 + + worktrees = WORKTREE_RE.findall(raw) + if worktrees: + current[3] = "FEAT-" + worktrees[-1] + task_ids = [f"TASK-{n}" for n in TASK_RE.findall(raw)] + meta["tasks"].update(task_ids) + mapped = [tasks[t] for t in task_ids if t in tasks] + if mapped: + current[2] = mapped[-1] + typed = FEAT_RE.findall(_human_text(record) + " ".join(COMMAND_ARGS_RE.findall(raw))) + if typed: + current[1] = "FEAT-" + typed[-1] + anywhere = FEAT_RE.findall(raw) + if anywhere: + current[0] = "FEAT-" + anywhere[-1] + + if kind != "assistant": + continue + message = record.get("message") or {} + usage = message.get("usage") or {} + if not usage: + continue + # One response spans several lines, each repeating this usage block. + message_id = message.get("id") or record.get("requestId") or record.get("uuid") + if message_id in seen: + continue + seen.add(message_id) + + creation = usage.get("cache_creation") or {} + row = { + "ts": timestamp, + "session": session_id, + "agent_file": agent_file, + "model": message.get("model") or "unknown", + "sidechain": bool(record.get("isSidechain")), + "cmd": command, + "feat": current[3] or current[2] or current[1] or current[0], + "tier": next((t for t in (3, 2, 1, 0) if current[t]), -1), + "input": usage.get("input_tokens", 0) or 0, + "output": usage.get("output_tokens", 0) or 0, + "thinking": (usage.get("output_tokens_details") or {}).get("thinking_tokens", 0) or 0, + "cw5": creation.get("ephemeral_5m_input_tokens", 0) or 0, + "cw1": creation.get("ephemeral_1h_input_tokens", 0) or 0, + "cr": usage.get("cache_read_input_tokens", 0) or 0, + } + if not (row["cw5"] or row["cw1"]): + row["cw5"] = usage.get("cache_creation_input_tokens", 0) or 0 + row["cost"] = price(row["model"], row) + rows.append(row) + + return rows, meta, current + + +def project_dirs(match: str) -> list[Path]: + """Return the Claude Code project directories belonging to this repo.""" + return [d for d in PROJECTS.iterdir() if d.is_dir() and match in d.name] + + +def scan(match: str) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Parse every transcript for the repo, including subagent transcripts.""" + tasks = task_to_feature() + rows: list[dict[str, Any]] = [] + sessions: dict[str, Any] = {} + for directory in project_dirs(match): + for transcript in sorted(directory.glob("*.jsonl")): + session_id = transcript.stem + session_rows, meta, current = parse_transcript(transcript, session_id, tasks) + rows.extend(session_rows) + subagents = directory / session_id / "subagents" + meta["n_subagents"] = 0 + if subagents.is_dir(): + for sub in sorted(subagents.glob("*.jsonl")): + sub_rows, sub_meta, _ = parse_transcript( + sub, session_id, tasks, agent_file=sub.name, inherit=current + ) + rows.extend(sub_rows) + meta["n_subagents"] += 1 + meta["coder_chunks"] += sub_meta["coder_chunks"] + meta["coder_calls"] += sub_meta["coder_calls"] + meta["tasks"] |= sub_meta["tasks"] + meta["file"] = str(transcript) + sessions[session_id] = meta + return rows, sessions + + +def save(rows: list[dict[str, Any]], sessions: dict[str, Any]) -> None: + """Write the row cache so the report commands do not re-parse every run.""" + CACHE.mkdir(parents=True, exist_ok=True) + with (CACHE / "rows.jsonl").open("w") as handle: + for row in rows: + handle.write(json.dumps(row) + "\n") + serializable = { + sid: { + **meta, + "prs": sorted(p for p in meta["prs"] if p), + "tasks": sorted(meta["tasks"]), + "branches": sorted(meta["branches"]), + } + for sid, meta in sessions.items() + } + (CACHE / "sessions.json").write_text(json.dumps(serializable, indent=1)) + + +def load() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Load the cached rows, or exit telling the caller to scan first.""" + rows_path = CACHE / "rows.jsonl" + if not rows_path.exists(): + sys.exit("no cache yet - run: python scripts/sdd/token_audit.py scan") + rows = [json.loads(line) for line in rows_path.open()] + sessions = json.loads((CACHE / "sessions.json").read_text()) + return rows, sessions + + +def context_tokens(row: dict[str, Any]) -> int: + """Return the context carried into one response (everything but output).""" + return row["cr"] + row["cw5"] + row["cw1"] + row["input"] + + +def week_of(timestamp: str) -> str: + """Return the ISO date of the Monday of that timestamp's week.""" + day = dt.date.fromisoformat(timestamp[:10]) + return (day - dt.timedelta(days=day.weekday())).isoformat() + + +def by_feature(rows: list[dict[str, Any]]) -> dict[str, collections.Counter]: + """Roll responses up per feature.""" + per: dict[str, collections.Counter] = collections.defaultdict(collections.Counter) + for row in rows: + if not row["feat"]: + continue + counter = per[row["feat"]] + counter["turns"] += 1 + counter["ctx"] += context_tokens(row) + counter["out"] += row["output"] + counter["cr"] += row["cr"] + counter["usd_micro"] += round(row["cost"] * 1e6) + return per + + +def cmd_features(rows: list[dict[str, Any]], sessions: dict[str, Any], args: argparse.Namespace) -> None: + per = by_feature(rows) + meta = feature_index() + first: dict[str, str] = {} + for row in rows: + if row["feat"] and row["ts"]: + first[row["feat"]] = min(first.get(row["feat"], row["ts"]), row["ts"]) + ranked = sorted(per.items(), key=lambda kv: -kv[1]["usd_micro"])[: args.limit] + print(f"{'feature':<10}{'start':<12}{'turns':>7}{'ctx/turn':>10}{'$/turn':>8}{'total $':>9}{'tasks':>6} name") + for feature, counter in ranked: + turns = counter["turns"] + usd = counter["usd_micro"] / 1e6 + info = meta.get(feature, {}) + print( + f"{feature:<10}{(first.get(feature) or '')[:10]:<12}{turns:>7,}" + f"{counter['ctx'] // turns:>10,}{usd / turns:>8,.3f}{usd:>9,.2f}" + f"{str(info.get('ntasks') or '-'):>6} {info.get('feature') or ''}" + ) + + +def cmd_trend(rows: list[dict[str, Any]], sessions: dict[str, Any], args: argparse.Namespace) -> None: + weeks: dict[str, collections.Counter] = collections.defaultdict(collections.Counter) + for row in rows: + if not row["ts"]: + continue + counter = weeks[week_of(row["ts"])] + counter["turns"] += 1 + counter["ctx"] += context_tokens(row) + counter["out"] += row["output"] + counter["usd_micro"] += round(row["cost"] * 1e6) + print(f"{'week':<12}{'turns':>9}{'ctx/turn':>11}{'out/turn':>10}{'$/turn':>9}{'week $':>10}") + for week in sorted(weeks): + counter = weeks[week] + turns = max(counter["turns"], 1) + usd = counter["usd_micro"] / 1e6 + print( + f"{week:<12}{counter['turns']:>9,}{counter['ctx'] // turns:>11,}" + f"{counter['out'] // turns:>10,}{usd / turns:>9,.3f}{usd:>10,.0f}" + ) + + +def cmd_feature(rows: list[dict[str, Any]], sessions: dict[str, Any], args: argparse.Namespace) -> None: + feature = args.feature.upper() + selected = [r for r in rows if r["feat"] == feature] + if not selected: + sys.exit(f"no responses attributed to {feature}") + turns = len(selected) + usd = sum(r["cost"] for r in selected) + ctx = sum(context_tokens(r) for r in selected) + out = sum(r["output"] for r in selected) + session_ids = {r["session"] for r in selected} + print(f"{feature} {turns:,} responses across {len(session_ids)} sessions") + print(f" context read {ctx:,} tok ({ctx // turns:,}/turn)") + print(f" output {out:,} tok ({out // turns:,}/turn)") + print(f" modelled cost ${usd:,.2f} (${usd / turns:.3f}/turn)") + models: dict[str, float] = collections.defaultdict(float) + for row in selected: + models[row["model"]] += row["cost"] + for model, cost in sorted(models.items(), key=lambda kv: -kv[1]): + print(f" {model:<28} ${cost:>8,.2f}") + chunks = sum(sessions.get(s, {}).get("coder_chunks", 0) for s in session_ids) + prs = sorted({p for s in session_ids for p in sessions.get(s, {}).get("prs", [])}) + print(f" external coder chunks dispatched: {chunks} PRs touched: {prs}") + reported = sum( + (sessions.get(s, {}).get("cost_state") or {}).get("totalCostUSD", 0.0) for s in session_ids + ) + print(f" Claude Code cost-state for those whole sessions: ${reported:,.2f}") + + +def cmd_scan(rows: list[dict[str, Any]], sessions: dict[str, Any], args: argparse.Namespace) -> None: + raise NotImplementedError # handled in main() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--match", default="ai-parrot", help="substring of the Claude Code project dir names") + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("scan", help="parse every transcript and cache the rows") + features = sub.add_parser("features", help="spend per feature") + features.add_argument("--limit", type=int, default=30) + sub.add_parser("trend", help="per-turn economics by week") + one = sub.add_parser("feature", help="detail for one feature") + one.add_argument("feature") + args = parser.parse_args() + + if args.command == "scan": + rows, sessions = scan(args.match) + save(rows, sessions) + print(f"{len(rows):,} responses from {len(sessions)} sessions -> {CACHE}") + return + + rows, sessions = load() + {"features": cmd_features, "trend": cmd_trend, "feature": cmd_feature}[args.command](rows, sessions, args) + + +if __name__ == "__main__": + main() diff --git a/scripts/sdd/worktree_status.py b/scripts/sdd/worktree_status.py new file mode 100644 index 00000000..d3f6b121 --- /dev/null +++ b/scripts/sdd/worktree_status.py @@ -0,0 +1,619 @@ +"""Discover SDD worktrees and report their task state and health. + +Read-only: never writes files or runs mutating git commands. +CLI: ``python -m scripts.sdd.worktree_status [--json]`` +Library: ``from scripts.sdd.worktree_status import discover_worktree_reports`` +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + +from scripts.sdd.sdd_meta import WORKTREE_ROOT # verified: scripts/sdd/sdd_meta.py:15 + +# --------------------------------------------------------------------------- +# Branch-name patterns +# --------------------------------------------------------------------------- + +_FEAT_BRANCH_RE = re.compile(r"^feat-(?:FEAT-)?(\d+)-(.+)$") +_HOTFIX_BRANCH_RE = re.compile(r"^hotfix-([A-Z]+-\d+)-(.+)$") +# sdd-coder pool sub-worktree suffix, e.g. "...--TASK-3505-a1-6818142afc1542a0bdd479267b8ccb6e" +# (FEAT-549). These are task-level attempt branches nested inside a feature +# worktree, not feature worktrees themselves, and must never be reported as +# one (they would otherwise pass _FEAT_BRANCH_RE with a garbage slug). +_POOL_SUB_WORKTREE_RE = re.compile(r"--TASK-\d+-a\d+-[0-9a-f]+$") + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + + +class WorktreeTaskStatus(BaseModel): + """Per-task status as seen from a worktree's index.""" + + id: str + status: Literal["pending", "in-progress", "done", "done-with-issues"] + completed_at: str | None = None + + +class WorktreeHealth(BaseModel): + """Health signals for a single worktree.""" + + dirty_count: int = 0 + unpushed_count: int = 0 + live_process_count: int = 0 + + +class WorktreeReport(BaseModel): + """Complete worktree state for one feature.""" + + feature_slug: str + feature_id: str | None = None + flow_type: Literal["feature", "hotfix"] + worktree_path: str + branch: str + base_branch: str = "dev" + health: WorktreeHealth = Field(default_factory=WorktreeHealth) + tasks: list[WorktreeTaskStatus] = Field(default_factory=list) + index_found: bool = True + ready_for_done: bool = False + + +# --------------------------------------------------------------------------- +# Git helper +# --------------------------------------------------------------------------- + + +def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a git command, return CompletedProcess (never raises on failure). + + A missing/deleted ``cwd`` (e.g. a worktree directory removed without + ``git worktree remove``/``prune``) would otherwise raise ``OSError`` + before git even runs; that is caught here and reported as a synthetic + non-zero-exit failure instead, matching the "never raises" contract. + """ + try: + return subprocess.run( + ["git", *args], + cwd=str(cwd), + text=True, + capture_output=True, + ) + except OSError as exc: + return subprocess.CompletedProcess(args=["git", *args], returncode=1, stdout="", stderr=str(exc)) + + +# --------------------------------------------------------------------------- +# Branch parsing +# --------------------------------------------------------------------------- + + +def _parse_branch( + branch: str, +) -> tuple[str, str | None, Literal["feature", "hotfix"]] | None: + """Parse an SDD branch name into (slug, feature_id_or_jira_key, flow_type). + + Returns None for non-SDD branches (chore-*, fix-*, detached, etc.) and for + sdd-coder pool sub-worktree branches (FEAT-549), which are task-level + attempts nested inside a feature worktree, not feature worktrees. + Handles both ``feat-FEAT-550-slug`` and legacy ``feat-550-slug``. + """ + if _POOL_SUB_WORKTREE_RE.search(branch): + return None + m = _FEAT_BRANCH_RE.match(branch) + if m: + return m.group(2), f"FEAT-{m.group(1)}", "feature" + m = _HOTFIX_BRANCH_RE.match(branch) + if m: + return m.group(2), m.group(1), "hotfix" + return None + + +# --------------------------------------------------------------------------- +# Index reading +# --------------------------------------------------------------------------- + + +def _read_worktree_index( + wt_path: Path, + slug: str, +) -> tuple[list[WorktreeTaskStatus], str]: + """Read the per-spec index inside a worktree. + + Returns (task_list, base_branch). task_list is empty if not found or + malformed. base_branch defaults to ``"dev"`` on any failure. + """ + index_path = wt_path / "sdd" / "tasks" / "index" / f"{slug}.json" + try: + with open(index_path, encoding="utf-8") as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return [], "dev" + + base_branch = data.get("base_branch", "dev") + tasks = [] + for task in data.get("tasks", []): + try: + tasks.append( + WorktreeTaskStatus( + id=task["id"], + status=task["status"], + completed_at=task.get("completed_at"), + ) + ) + except (KeyError, TypeError, ValueError): + # Skip malformed task entries (ValueError also covers pydantic's + # ValidationError, e.g. a status outside the Literal enum). + continue + return tasks, base_branch + + +# --------------------------------------------------------------------------- +# Health checks +# --------------------------------------------------------------------------- + + +def _live_process_count(path: Path) -> int: + """Count processes with cwd inside path (Linux /proc, best-effort).""" + try: + resolved = path.resolve() + except OSError: + return 0 + + # Non-Linux systems don't have /proc + proc_dir = Path("/proc") + if not proc_dir.exists(): + return 0 + + count = 0 + for entry in proc_dir.iterdir(): + if not entry.name.isdigit(): + continue + try: + pid = int(entry.name) + cwd = Path(os.readlink(f"/proc/{pid}/cwd")) + if cwd == resolved or resolved in cwd.parents: + count += 1 + except (OSError, PermissionError): + continue + return count + + +def _check_health(wt_path: Path, base_branch: str) -> WorktreeHealth: + """Dirty files, unpushed commits, live processes.""" + # Count dirty files + status_proc = _git("status", "--porcelain", cwd=wt_path) + dirty_count = len([line for line in status_proc.stdout.splitlines() if line.strip()]) + + # Count unpushed commits + log_proc = _git("log", f"origin/{base_branch}..HEAD", "--oneline", cwd=wt_path) + unpushed_count = len([line for line in log_proc.stdout.splitlines() if line.strip()]) + + # Count live processes + live_process_count = _live_process_count(wt_path) + + return WorktreeHealth( + dirty_count=dirty_count, + unpushed_count=unpushed_count, + live_process_count=live_process_count, + ) + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +def _parse_porcelain(output: str) -> list[tuple[Path, str | None]]: + """Parse ``git worktree list --porcelain`` into [(path, branch_or_None)]. + + Every ``worktree`` block is registered exactly once, even when it has no + ``branch refs/heads/...`` line (detached HEAD, or a bare repository) — in + that case the branch is ``None`` rather than the block being dropped. + """ + worktrees: list[tuple[Path, str | None]] = [] + current: Path | None = None + current_branch: str | None = None + + def _flush() -> None: + nonlocal current, current_branch + if current is not None: + worktrees.append((current, current_branch)) + current = None + current_branch = None + + for raw in output.splitlines(): + line = raw.strip() + if line.startswith("worktree "): + _flush() + current = Path(line[len("worktree ") :]).resolve() + elif line.startswith("branch refs/heads/") and current is not None: + current_branch = line[len("branch refs/heads/") :] + elif not line: + # Blank line ends the current block + _flush() + + _flush() + return worktrees + + +def discover_worktree_reports(repo_root: Path) -> list[WorktreeReport]: + """Main entry point: discover all SDD worktrees and their task state. + + 1. Parse ``git worktree list --porcelain``. + 2. Also scan WORKTREE_ROOT for orphan directories (not in porcelain). + 3. For each SDD branch, parse slug/id, read worktree index, check health. + 4. Compute ready_for_done. + """ + # Get worktrees from git + porcelain_proc = _git("worktree", "list", "--porcelain", cwd=repo_root) + git_worktrees = _parse_porcelain(porcelain_proc.stdout) + + # Get all worktree paths from git + git_worktree_paths = {path for path, _ in git_worktrees} + + # Scan WORKTREE_ROOT for orphan directories. WORKTREE_ROOT is relative + # (".claude/worktrees") and must be resolved against repo_root, not the + # process's current working directory (which may itself be a worktree). + worktree_root = repo_root / WORKTREE_ROOT + orphan_paths: list[Path] = [] + if worktree_root.exists(): + for entry in worktree_root.iterdir(): + if entry.is_dir() and entry.resolve() not in git_worktree_paths: + orphan_paths.append(entry) + + # Build set of all worktree paths (git + orphans) + all_worktree_paths = set(git_worktree_paths) | {p.resolve() for p in orphan_paths} + + reports: list[WorktreeReport] = [] + + for wt_path in all_worktree_paths: + # Try to get branch from git porcelain + branch = None + for path, b in git_worktrees: + if path == wt_path: + branch = b + break + + # If not found in porcelain, try to get it from git + if branch is None: + branch_proc = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=wt_path) + if branch_proc.returncode == 0: + branch = branch_proc.stdout.strip() + else: + # Detached HEAD or error + continue + + # Parse branch name + parsed = _parse_branch(branch) + if parsed is None: + # Not an SDD branch + continue + + slug, feature_id, flow_type = parsed + + # Read worktree index + tasks, base_branch = _read_worktree_index(wt_path, slug) + index_found = len(tasks) > 0 or base_branch != "dev" + + # Check health + health = _check_health(wt_path, base_branch) + + # Compute ready_for_done + # All tasks must be done or done-with-issues, no dirty files, no unpushed commits + all_done = all(t.status in ("done", "done-with-issues") for t in tasks) and len(tasks) > 0 + ready_for_done = all_done and health.dirty_count == 0 and health.unpushed_count == 0 and index_found + + reports.append( + WorktreeReport( + feature_slug=slug, + feature_id=feature_id, + flow_type=flow_type, + worktree_path=str(wt_path), + branch=branch, + base_branch=base_branch, + health=health, + tasks=tasks, + index_found=index_found, + ready_for_done=ready_for_done, + ) + ) + + return reports + + +# --------------------------------------------------------------------------- +# Dev-index reconciliation +# --------------------------------------------------------------------------- + +# A worktree may only ever ADVANCE a task's status, never roll it back. +# The dev-branch index is the merged, authoritative record; the worktree index +# is a live-but-unmerged one that can also be a stale pre-branch snapshot (a +# worktree branched before /sdd-task ran carries every task at "pending", and +# a worktree left behind after /sdd-done still carries its own copy long after +# the feature closed). Taking the worktree as truth re-opens finished +# features; taking the maximum of the two shows work that started in a +# worktree without ever losing what dev already knows. +# +# "done" and "done-with-issues" share a rank so neither side can silently flip +# a finished task between the two terminal states; on a tie the dev value wins. +_STATUS_RANK: dict[str, int] = { + "pending": 0, + "in-progress": 1, + "done": 2, + "done-with-issues": 2, +} + +_TERMINAL_STATUSES = ("done", "done-with-issues") + + +class ReconciledTask(BaseModel): + """One task after merging the dev-branch index with a worktree index.""" + + id: str + status: Literal["pending", "in-progress", "done", "done-with-issues"] + source: Literal["dev", "worktree"] = "dev" + + +class ReconciledFeature(BaseModel): + """A feature's task state after dev/worktree reconciliation.""" + + feature_slug: str + feature_id: str | None = None + spec: str | None = None + index_path: str | None = None + worktree_branch: str | None = None + worktree_path: str | None = None + #: at least one task's status was advanced by the worktree index + worktree_ahead: bool = False + #: the worktree index is behind dev everywhere it differs (leftover worktree) + worktree_stale: bool = False + #: dev already considers the feature finished (completed_at, or all tasks terminal) + dev_closed: bool = False + #: the feature has no index on dev at all — spec/tasks live only in the worktree + worktree_only: bool = False + tasks: list[ReconciledTask] = Field(default_factory=list) + + +def reconcile_feature( + dev_index: dict | None, + report: WorktreeReport | None, +) -> ReconciledFeature: + """Merge one feature's dev-branch index with its worktree index. + + The worktree may only advance a task (``pending`` → ``in-progress`` → + terminal); a lower-ranked worktree status is ignored and marks the + worktree stale instead. Tasks that exist only in the worktree index are + appended (they were generated inside the worktree and never merged). + + Args: + dev_index: The parsed ``sdd/tasks/index/.json`` from the current + branch, or ``None`` when the feature exists only in a worktree. + report: The worktree report for the same feature, or ``None``. + + Returns: + The reconciled feature, carrying per-task provenance and the + ``worktree_ahead`` / ``worktree_stale`` / ``dev_closed`` flags the + task board uses for labelling. + """ + dev_index = dev_index or {} + dev_tasks = [t for t in dev_index.get("tasks", []) if isinstance(t, dict) and "id" in t] + dev_by_id = {t["id"]: t for t in dev_tasks} + + dev_closed = bool(dev_index.get("completed_at")) or ( + bool(dev_tasks) and all(t.get("status") in _TERMINAL_STATUSES for t in dev_tasks) + ) + + wt_by_id: dict[str, WorktreeTaskStatus] = {} + if report is not None and report.index_found: + wt_by_id = {t.id: t for t in report.tasks} + + merged: list[ReconciledTask] = [] + ahead = False + behind = False + + for task in dev_tasks: + dev_status = task.get("status", "pending") + wt_task = wt_by_id.get(task["id"]) + if wt_task is None: + merged.append(ReconciledTask(id=task["id"], status=dev_status, source="dev")) + continue + dev_rank = _STATUS_RANK.get(dev_status, 0) + wt_rank = _STATUS_RANK.get(wt_task.status, 0) + if wt_rank > dev_rank: + ahead = True + merged.append(ReconciledTask(id=task["id"], status=wt_task.status, source="worktree")) + else: + behind = behind or wt_rank < dev_rank + merged.append(ReconciledTask(id=task["id"], status=dev_status, source="dev")) + + # Tasks the worktree knows about and dev does not (generated in-worktree). + for task_id, wt_task in wt_by_id.items(): + if task_id in dev_by_id: + continue + ahead = True + merged.append(ReconciledTask(id=task_id, status=wt_task.status, source="worktree")) + + return ReconciledFeature( + feature_slug=dev_index.get("feature") or (report.feature_slug if report else ""), + feature_id=dev_index.get("feature_id") or (report.feature_id if report else None), + spec=dev_index.get("spec"), + index_path=dev_index.get("_index_path"), + worktree_branch=report.branch if report else None, + worktree_path=report.worktree_path if report else None, + worktree_ahead=ahead, + worktree_stale=bool(wt_by_id) and not ahead and behind, + dev_closed=dev_closed, + worktree_only=not dev_tasks and bool(wt_by_id), + tasks=merged, + ) + + +def _load_dev_indexes(repo_root: Path) -> list[dict]: + """Load every per-spec index on the current branch (``_orphans.json`` aside).""" + index_dir = repo_root / "sdd" / "tasks" / "index" + indexes: list[dict] = [] + if not index_dir.is_dir(): + return indexes + for path in sorted(index_dir.glob("*.json")): + if path.name == "_orphans.json": + continue + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + continue + if isinstance(data, dict): + data["_index_path"] = str(path.relative_to(repo_root)) + indexes.append(data) + return indexes + + +def reconcile_reports( + repo_root: Path, + reports: list[WorktreeReport], +) -> list[ReconciledFeature]: + """Reconcile every per-spec index on this branch with its worktree, if any. + + Features whose work only ever existed inside a worktree (no index on the + current branch) are appended so the task board can surface them too. + + Args: + repo_root: The primary checkout's root. + reports: Worktree reports from :func:`discover_worktree_reports`. + + Returns: + One :class:`ReconciledFeature` per dev index, plus one per + worktree-only feature, in index-filename order. + """ + by_feature_id = {r.feature_id: r for r in reports if r.feature_id} + by_slug = {r.feature_slug: r for r in reports} + + reconciled: list[ReconciledFeature] = [] + matched: set[str] = set() + + for dev_index in _load_dev_indexes(repo_root): + report = by_feature_id.get(dev_index.get("feature_id")) or by_slug.get(dev_index.get("feature")) + if report is not None: + matched.add(report.branch) + reconciled.append(reconcile_feature(dev_index, report)) + + for report in reports: + if report.branch in matched or not report.index_found: + continue + reconciled.append(reconcile_feature(None, report)) + + return reconciled + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + """CLI entry point. + + ``--json`` prints ``list[WorktreeReport]``; plain prints a table. + ``--reconcile`` switches both outputs to ``list[ReconciledFeature]``, the + dev-index/worktree merge the ``/sdd-status`` task board consumes. + """ + import argparse + + parser = argparse.ArgumentParser(description="Discover SDD worktrees and report their task state and health.") + parser.add_argument( + "--json", + action="store_true", + help="Output as JSON array of WorktreeReport objects", + ) + parser.add_argument( + "--reconcile", + action="store_true", + help=( + "Merge each worktree index into the dev-branch index (worktree may only advance a task) " + "and report list[ReconciledFeature] instead of the raw worktree reports" + ), + ) + args = parser.parse_args() + + # Resolve repo root + repo_root_proc = _git("rev-parse", "--show-toplevel", cwd=Path.cwd()) + if repo_root_proc.returncode != 0: + print("error: not a git repository", file=sys.stderr) + return 1 + repo_root = Path(repo_root_proc.stdout.strip()) + + # Get reports + reports = discover_worktree_reports(repo_root) + + if args.reconcile: + features = reconcile_reports(repo_root, reports) + if args.json: + print(json.dumps([f.model_dump() for f in features], indent=2)) + else: + print(f"{'Feature':<45} {'Id':<12} {'Tasks (done/total)':<20} {'Source'}") + print("-" * 100) + for feature in features: + done = sum(1 for t in feature.tasks if t.status in ("done", "done-with-issues")) + flags = [] + if feature.worktree_ahead: + flags.append(f"worktree ahead: {feature.worktree_branch}") + if feature.worktree_stale: + flags.append(f"stale worktree: {feature.worktree_branch}") + if feature.worktree_only: + flags.append("worktree only") + source = ", ".join(flags) if flags else "dev" + tasks_str = f"{done}/{len(feature.tasks)}" + print(f"{feature.feature_slug:<45} {feature.feature_id or '-':<12} {tasks_str:<20} {source}") + return 0 + + if args.json: + # JSON output + output = json.dumps([r.model_dump() for r in reports], indent=2) + print(output) + else: + # Plain table output + print(f"{'Name':<40} {'Branch':<30} {'Feature':<15} {'Tasks':<12} {'Health':<20} {'Ready'}") + print("-" * 130) + for r in reports: + # Name: feature_slug + name = r.feature_slug + if r.feature_id: + name = f"{r.feature_slug} ({r.feature_id})" + + # Branch + branch = r.branch + + # Tasks: N done / M total + done_count = sum(1 for t in r.tasks if t.status in ("done", "done-with-issues")) + total_count = len(r.tasks) + tasks_str = f"{done_count}/{total_count}" if total_count > 0 else "0/0" + + # Health flags + health_parts = [] + if r.health.dirty_count > 0: + health_parts.append(f"dirty:{r.health.dirty_count}") + if r.health.unpushed_count > 0: + health_parts.append(f"unpushed:{r.health.unpushed_count}") + if r.health.live_process_count > 0: + health_parts.append(f"live:{r.health.live_process_count}") + health_str = ", ".join(health_parts) if health_parts else "clean" + + # Ready flag + ready_str = "✓" if r.ready_for_done else "" + + print(f"{name:<40} {branch:<30} {r.feature_id or '-':<15} {tasks_str:<12} {health_str:<20} {ready_str}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sweep_worktrees.py b/scripts/sweep_worktrees.py new file mode 100644 index 00000000..1e06a545 --- /dev/null +++ b/scripts/sweep_worktrees.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +"""Reconcile dev-loop git worktrees against their PR state. + +Webhook-less fallback for dev-loop worktree cleanup. The flow defers worktree +removal to GitHub's ``pull_request.closed`` webhook (see +``parrot.flows.dev_loop.webhook``); when no public endpoint receives that event +(local runs), finished worktrees accumulate. This script does the same job on +demand: it removes worktrees whose PR is already merged or closed, keeps those +with an open PR (the revision loop may still reuse them), and — with +``--remove-orphans`` — also drops abandoned worktrees that never opened a PR. + +Usage:: + + source .venv/bin/activate + python scripts/sweep_worktrees.py --dry-run # preview + python scripts/sweep_worktrees.py # remove merged/closed + python scripts/sweep_worktrees.py --remove-orphans # also drop no-PR worktrees + +Requires the ``gh`` CLI (authenticated) to resolve PR state. +""" +from __future__ import annotations + +import argparse +import asyncio +import sys + +from parrot.flows.dev_loop import sweep_finished_worktrees + + +async def _amain(args: argparse.Namespace) -> int: + report = await sweep_finished_worktrees( + remove_orphans=args.remove_orphans, + dry_run=args.dry_run, + cwd=args.cwd, + ) + verb = "Would remove" if args.dry_run else "Removed" + for item in report["removed"]: + print(f" {verb}: {item['branch']} ({item['reason']})") + for item in report["kept"]: + print(f" Kept: {item['branch']} ({item['reason']})") + for item in report["errors"]: + print(f" ERROR: {item['branch']} — {item['error']}", file=sys.stderr) + print( + f"\n{verb.lower()} {len(report['removed'])}, " + f"kept {len(report['kept'])}, errors {len(report['errors'])}." + ) + return 1 if report["errors"] else 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--remove-orphans", action="store_true", + help="Also remove dev-loop worktrees that have no PR.", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Show what would be removed without touching anything.", + ) + parser.add_argument( + "--cwd", default=None, + help="Repo root for git/gh subprocesses (default: current dir).", + ) + args = parser.parse_args() + raise SystemExit(asyncio.run(_amain(args))) + + +if __name__ == "__main__": + main() diff --git a/sdd/WORKFLOW.md b/sdd/WORKFLOW.md new file mode 100644 index 00000000..01d49ed7 --- /dev/null +++ b/sdd/WORKFLOW.md @@ -0,0 +1,434 @@ +# AI-Parrot SDD Workflow + +## Overview + +This document defines the **Spec-Driven Development (SDD)** methodology for AI-Parrot, unified across Claude Code, Codex, and Google Gemini via Antigravity CLI (`agy`) with multi-agent task distribution. + +The key idea: specifications are the Single Source of Truth (SSOT). Agents across all supported platforms consume spec documents and produce **Task Artifacts** — discrete, self-contained files in `sdd/tasks/active/` that can be independently picked up and executed by agents in parallel. + +--- + +## The SDD Lifecycle + +``` + ┌─ /sdd-fromjira → jira-issue → brainstorm ────┐ + │ │ + ├─ /sdd-proposal → discuss → brainstorm ──────────┤ + │ │ + ├─ /sdd-spec → scaffold spec ─────────────────────┤ +[Human] ────────────────────────┤ Feature Spec → [Planner] Tasks → [Executors] Code → [Reviewer] Validation + │ ↑ ↑ | + ├─ /sdd-tojira → jira-issue ──────────────────────┘ └────────── Feedback Loop ──────────────┘ + │ | + └────────── /sdd-task → decomposes spec into tasks ──────────────────────────────────────────────────────┘ +``` + +### Phase 0 — Exploration *(optional)* +Start here when the idea is not yet well-defined: +- `/sdd-brainstorm` compares options and writes `sdd/proposals/.brainstorm.md`. +- `/sdd-proposal` researches a Jira issue, inline request or notes file and + writes `sdd/proposals/.proposal.md` (research state under `sdd/state//`). +- `/sdd-fromjira` bootstraps a brainstorm from a Jira ticket. + +### Phase 1 — Feature Specification +`/sdd-spec` writes `sdd/specs/.spec.md` from a brainstorm/proposal, a +direct request, or an intake interview (FEAT-577), and reserves the +`FEAT-` via `scripts/sdd/reserve_ids.py` (not for hotfixes). + +### Phase 2 — Task Generation (Planner) +`/sdd-task sdd/specs/.spec.md` decomposes the spec into Task Artifacts +at `sdd/tasks/active/TASK--.md` and creates the **per-spec index** +`sdd/tasks/index/.json` (FEAT-145). TASK numbers come from +`reserve_ids.py`; `/sdd-task` creates no worktree (FEAT-552). + +Tasks are designed to be: +- **Atomic** — completable independently +- **Bounded** — clear scope, no ambiguity +- **Testable** — every task includes its own test criteria +- **Assignable** — formatted so any agent can start immediately + +### Phase 3 — Task Execution (Executors) +`/sdd-start TASK-` (one task) or the `sdd-worker` agent (a whole feature) +creates/reuses the feature worktree via `python -m scripts.sdd.ensure_worktree`, +marks the task `in-progress` in the per-spec index, implements it, and closes it +with `scripts/sdd/close_task.sh`, which moves the file to +`sdd/tasks/completed/` and stamps the index. Code and SDD state are committed +together on the feature branch. The manual (no-command) procedure is in +`docs/sdd/WORKFLOW.md`. + +### Phase 4 — Validation (Reviewer) +`/sdd-codereview` reviews completed tasks (with an adversarial `codex` cross-check); +`/sdd-done FEAT-` verifies every task, snapshots the ledger, pushes, and +opens a PR against `base_branch` (`--merge` merges directly). + +--- + +## Task Artifact Format + +Every task file follows `sdd/templates/task.md`. Header: **Feature**, **Spec**, +**Status**, **Priority**, **Estimated effort**, **Depends-on**, **Assigned-to**. +Sections, in order: Context · Scope (with *NOT in scope*) · Files to Create / +Modify · Codebase Contract (Verified Imports / Existing Signatures / Does NOT +Exist) · Complexity Contract · Delegation Contract *(optional)* · Implementation +Notes · Implementation Blueprint · Acceptance Criteria · Validation Commands · +Test Specification · Agent Instructions · Completion Note. + +Index `status` values: `pending` → `in-progress` → `done` | `done-with-issues`. + +--- + +## Git Configuration (FEAT-187) + +The Git Parrot Flow uses three long-lived branches: + +- **`main`** — tagged releases only. Hotfixes land here via PR; + no feature work ever bases on `main`. +- **`staging`** — release candidate branch. Cut from `dev` when the + team decides to freeze a release. Receives `main → staging` syncs + automatically (via `.github/workflows/sync-down.yml`); the + `dev → staging` direction is a manual cut at freeze time. +- **`dev`** — integration branch for all feature work. Default base + for `type: feature` flows. + +**Sync-down automation** (FEAT-187): `.github/workflows/sync-down.yml` +listens for pushes to `main` and tries to fast-forward both `staging` and +`dev`. When fast-forward is not possible, it opens a sync PR against the +lagging branch. `/sdd-done --sync-down` is the manual fallback. + +--- + +## Flow Types (FEAT-145, refined by FEAT-187) + +Every brainstorm/proposal/spec declares its flow type via YAML frontmatter +at the top of the document: + +```yaml +--- +type: feature # one of: feature | hotfix +base_branch: dev # for feature: dev or staging; for hotfix: must be "main" +--- +``` + +| Type | base_branch | When to use | +|-----------|---------------------|--------------------------------------------------------------| +| `feature` | `dev` (default) | Most work. Lands on `dev` via `/sdd-done`. | +| `feature` | `staging` | Stabilization fix during a release freeze. Lands on `staging`. | +| `feature` | `` | Sub-features extending another feature branch. | +| `hotfix` | `main` (required) | Production hotfixes. Land on `main` via manual PR. | + +Features MUST NOT base on `main`. `/sdd-done` enforces: hotfixes are NEVER +auto-pushed or auto-PR'd to `main`. The user opens the PR manually; afterwards, +`.github/workflows/sync-down.yml` propagates the change back to `staging` and +`dev` automatically. If the Action fails, run `/sdd-done --sync-down` locally. + +--- + +## Document Taxonomy (FEAT-576) + +Brainstorms, proposals and specs carry two organizational frontmatter keys: + +```yaml +projects: [ai-parrot, ai-parrot-server] # parts of the codebase the doc concerns +tags: [memory, compaction] # free-form kebab-case keywords +``` + +- **projects** — `packages/*` distribution names plus the areas `sdd-tooling`, + `dev-loop`, `admin-ui`, `docs`, `ci` (`KNOWN_PROJECTS`). Aliases normalize: + `parrot-core` → `ai-parrot`, `formdesigner` → `parrot-formdesigner`, … + Unknown values **warn but are kept**. +- **tags** — lowercased, spaces/underscores → `-`, deduplicated. +- Missing keys read as empty lists; the per-spec task index does not cache them. +- `/sdd-spec` copies both from the brainstorm/proposal. + +### Querying + +List documents by taxonomy: + +```bash +python -m scripts.sdd.doc_taxonomy [--root .] [--kind spec|brainstorm|proposal|all] \ + [--project P ...] [--tag T ...] [--paths-only | --json | --summary] +``` + +- `--paths-only` — print relative paths only (one per line). +- `--json` — full metadata JSON for each doc. +- `--summary` — project and tag frequency tables. +- With none of the three flags, prints one TSV row per doc (`path`, `kind`, + comma-joined `projects`, comma-joined `tags`). + +### Where they are used + +- `/sdd-status` and `/sdd-next` accept `--project` and `--tag` to filter the + displayed tasks. +- `/sdd-tojira` maps `projects ∪ tags` to Jira labels (additive on update). +- The wiki spec-page summary shows the taxonomy for each spec. + +### Backfill + +Infer missing `projects` frontmatter for existing docs (dry-run by default): + +```bash +python -m scripts.sdd.backfill_taxonomy [--root .] [--kind spec|brainstorm|proposal|all] \ + [--limit N] [--apply] +``` + +- Default is a dry run: prints `: projects = [...]` and never writes. +- `--apply` writes the edits as text insertions (never a YAML re-dump); + non-empty `projects` are never overwritten. +- Review the proposed diff; if correct, re-run with `--apply` and commit + yourself — the script never commits. + +--- + +## Release Cut + +When the team decides to freeze a release, a maintainer cuts `staging` +from `dev`. This is a manual, infrequent operation today (see open +question in `sdd/specs/git-parrot-flow.spec.md` §8 about a future +`/sdd-release-cut` command). + +### Cutting the branch + +```bash +git checkout dev +git pull --ff-only origin dev + +# First freeze (creates the branch): +git checkout -B staging origin/dev +git push --set-upstream origin staging + +# Subsequent freezes (staging already exists): +git checkout staging +git merge --ff-only dev +git push origin staging +``` + +### During the freeze + +- New feature work continues on `dev`. It is destined for the NEXT + release, not this one. +- Hotfixes land on `main` as usual. The sync-down Action propagates + them to `staging` and `dev` automatically. +- Stabilization fixes that target THIS release should land on + `staging` directly: open a brainstorm/spec with + `type: feature, base_branch: staging`. + +### Releasing + +1. Open PR `staging → main`. +2. Review, run full CI, ensure all stabilization fixes are in. +3. Merge the PR. +4. Tag the merge commit: `git tag vX.Y.Z && git push origin vX.Y.Z`. +5. The Action propagates the merge commit back to `dev` (and to + `staging` if any post-merge fixes were on `main`). +6. `.github/workflows/release.yml` fires on the tag event and + publishes the release artifacts. + +### Recommended Branch Protection + +`main` (and `staging` once in active use) should require PRs, passing CI +status checks, and signed commits. Configure via GitHub repo settings — +not declaratively in this repo (open question in spec §8). + +--- + +## Per-Spec Index Schema (`sdd/tasks/index/.json`, FEAT-145) + +> **Migration history**: the legacy monolithic `sdd/tasks/.index.json` was +> split into per-spec files by `scripts/sdd/migrate_index.py`. The original +> monolith is preserved as a historical artifact. New tooling reads only +> per-spec indexes. + +Each per-spec index file contains a header describing the feature plus +the `tasks[]` array for that feature only. Two parallel features touch +disjoint files and never collide on merge. + +```json +{ + "feature": "feature-slug", + "feature_id": "FEAT-NNN", + "spec": "sdd/specs/feature-slug.spec.md", + "type": "feature", + "base_branch": "dev", + "created_at": "ISO-8601", + "completed_at": null, + "tasks": [ + { + "id": "TASK-001", + "slug": "base-loader-interface", + "title": "Define BaseLoader abstract interface", + "feature_id": "FEAT-NNN", + "feature": "feature-slug", + "status": "done", + "priority": "high", + "depends_on": [], + "assigned_to": null, + "started_at": null, + "completed_at": "ISO-8601", + "file": "sdd/tasks/completed/TASK-001-base-loader-interface.md" + } + ] +} +``` + +Tasks orphaned by the migration (no resolvable `feature`) live in +`sdd/tasks/index/_orphans.json` with the same schema and `feature: "_orphans"`. +`/sdd-status` surfaces them in a dedicated panel; `/sdd-next` skips them. + +See `sdd/specs/sdd-flow-types-and-per-spec-index.spec.md` (FEAT-145) for +the authoritative design rationale. + +--- + +## TASK/FEAT ID Allocation (FEAT-387) + +`TASK-` and `FEAT-` numbers are allocated by a tiny, git-native +compare-and-swap ledger, not by scanning existing files for the highest +number and incrementing — that scan-and-increment approach has no lock and +no re-check against `origin/` immediately before committing, +so two `/sdd-task`/`/sdd-spec` runs racing each other (e.g. concurrent +dev-loop planner dispatches) can silently allocate the same number to two +different features. Different filenames mean git's merge machinery never +flags the collision — this is exactly how six real `TASK-` collisions +between FEAT-380 (sandbox-hardening) and two unrelated features went +undetected until `/sdd-done`'s closeout tooling stumbled on them. + +- **`sdd/tasks/.id_ledger.json`** — the ledger itself: a single, git-tracked + JSON file holding `next_task_id` and `next_feature_id`. Every allocation + reads this file, computes a reservation, and races to commit+push an + update — the push itself is the compare-and-swap (a non-fast-forward + rejection means someone else already advanced the counter). +- **`scripts/sdd/id_ledger.py`** — the `IdLedger` Pydantic model plus + `load_ledger`/`save_ledger` and the one-time `bootstrap_ledger()` used to + seed the ledger strictly ahead of every ID already in use. +- **`scripts/sdd/reserve_ids.py`** — the allocator `/sdd-task` and + `/sdd-spec` call instead of hand-computing a number: + ```bash + python -m scripts.sdd.reserve_ids --kind task --count 8 \ + --base-branch dev --label + ``` + Reads the ledger **as of `origin/`**, builds a *ledger-only* + commit on that tip, and pushes it; on a rejected (non-fast-forward) push + it fetches, re-reads the now-current ledger, recomputes, and retries + (bounded, with jittered backoff) instead of silently succeeding with a + stale, already-claimed number. Prints the reserved IDs one per line. + + **The reservation never touches local history.** The candidate commit is + assembled with git plumbing in a throwaway index, so no attempt mutates + the local branch, index or working tree, and the push carries that one + commit (`:refs/heads/`) rather than `HEAD` — local-only + commits on the base branch are therefore neither published nor + destroyed. This is a fix for a real incident: the original implementation + committed on top of `HEAD`, pushed the whole branch, and ran + `git reset --hard origin/` on a lost race, silently eating + two unpushed commits and still exiting 0. After a successful push the + local branch is fast-forwarded onto the reservation as a convenience; if + it carries unpushed commits the fast-forward is skipped and a warning + tells you to reconcile with `git pull --no-rebase`. +- **`scripts/sdd/check_id_collisions.py`** — an independent, read-only + defense-in-depth backstop (no dependency on the ledger/allocator): scans + `sdd/tasks/index/*.json`, `sdd/tasks/active/*.md`, and + `sdd/tasks/completed/*.md` for any `TASK-` number claimed by more + than one distinct feature. Wired into CI (`.github/workflows/ci.yml`, + `lint-and-registry` job) against a one-time baseline exception file + (`scripts/sdd/.collision_baseline.json`) so historical, pre-ledger + collisions don't retroactively fail the build — only a genuinely NEW + collision does. `FEAT-` reuse across specs (an accepted, + intentional pattern — e.g. FEAT-380 split across three specs) is + reported informationally and never fails the build. + +An intentional, explicit `FEAT-` reuse (splitting one initiative +across multiple specs, the FEAT-380 pattern) skips the reservation call +via a `reuse_feature_id: FEAT-` frontmatter field — see +`.claude/commands/sdd-spec.md`. + +--- + +## Starting from an interview: `/sdd-spec` intake mode (FEAT-577) + +When you already know what you want and have no brainstorm or proposal, run +`/sdd-spec` with no `--` notes (or pass `--interview`). It asks a fixed intake +batch, researches the codebase (`--research full|light|none`, default `full`), +runs 2–4 follow-up rounds informed by the findings, and writes a **spec** +directly. The full procedure is `sdd/templates/intake.procedure.md`. + +### Flow +``` +[Intake Interview] ──> [Codebase Research] ──> [2-4 Follow-up Rounds] ──> [Spec Generation] +``` + +### Flags +- `--interview`: Force intake mode (warns and ignores if an exploration doc exists). +- `--no-interview`: Skip intake and follow the standard path (unattended lanes always pass this). +- `--resume []`: Resume an interrupted intake run from a staging directory. +- `--research `: Set codebase research depth (default: `full`). +- `--no-gate`: Skip the research gate check. +- `--budget `: Set token/time budget for the intake run. + +### Brainstorm Hand-off (G12) +If the user decides during the interview that the feature is too complex or needs more exploration, +they can decline the hand-off to spec generation. The intake run can then hand off to `/sdd-brainstorm` +or `/sdd-proposal` to explore options first. + +### Staging & Retention (G13) +Intake runs are staged under `sdd/state/.intake/` (which is git-ignored). Staged runs are pruned +after 10 days by a daily git hook. Install this hook once with: +```bash +python -m scripts.sdd.install_hooks +``` +To uninstall, run with `--uninstall`. The hook refuses to install if `core.hooksPath` is set to a custom path. +Note that `/sdd-status` remains read-only during this process. + +--- + +## Parallelism Rules + +Claude Code agents can work in parallel when tasks have no shared dependencies: + +``` +TASK-001 (base interface) + ├── TASK-002 (pgvector) ← parallel after 001 + ├── TASK-003 (arangodb) ← parallel after 001 + └── TASK-004 (embeddings) ← parallel after 001 + └── TASK-005 (rag-pipeline) ← waits for 002, 003, 004 +``` + +An agent should **never start a task** unless every id in its `depends_on` +is `"done"` in the per-spec index. + +--- + +## Commands Reference + +The SDD workflow is unified across all three developer platforms: +- **Claude Code**: Slash commands in `.claude/commands/sdd-*.md` +- **Codex**: Repository skills in `.agents/skills/sdd-*` (invoked as `$sdd-*`) and agent `.codex/agents/sdd-worker.toml` +- **Antigravity CLI (Google Gemini)**: Slash command workflows in `.agent/workflows/sdd-*.md` (invoked as `/sdd-*`), skills in `.agents/skills/sdd-*`, and subagents in `.agents/agents/` + +| Command | Skill | Description | +|---|---|---| +| `/sdd-proposal` | `sdd-proposal` | Research a Jira issue, inline request, or notes file before writing a spec | +| `/sdd-brainstorm` | `sdd-brainstorm` | Explore a feature idea, compare options, and write a brainstorm document | +| `/sdd-spec` | `sdd-spec` | Scaffold a formal Feature Specification from exploration, a direct request, or an intake interview (FEAT-577) | +| `/sdd-task ` | `sdd-task` | Decompose an approved spec into atomic task files and a per-spec index | +| `/sdd-start ` | `sdd-start` | Implement and close one task inside the feature worktree | +| `/sdd-done ` | `sdd-done` | Verify, push, open or describe PR, and clean up the worktree | +| `/sdd-codereview ` | `sdd-codereview` | Code review a completed task with adversarial cross-checks | +| `/sdd-explain ` | `sdd-explain` | Code-grounded architectural map or deep implementation trace | +| `/sdd-status` | `sdd-status` | Show task index status board across all per-spec indexes | +| `/sdd-next` | `sdd-next` | Suggest next unblocked tasks to assign | +| `/sdd-fix [issue-id]` | `sdd-fix` | Drain the work ledger: plan-fix, claim, route to the Fast or SDD lane, close by evidence, release the rest | +| `/sdd-fromjira` | `sdd-fromjira` | Bootstrap an SDD brainstorm from a Jira ticket | +| `/sdd-tojira` | `sdd-tojira` | Export an SDD specification to a Jira Story and subtasks | +| `/sdd-insight` | `sdd-insight` | Analyze collaboration transcripts and repo-level SDD process adherence | + +--- + +## Quality Rules for Agents + +1. **Never modify files outside the task scope** — respect boundaries +2. **Follow existing patterns** — reference code mentioned in the task +3. **Write tests first** — TDD approach per task +4. **Update the index** — close tasks with `scripts/sdd/close_task.sh` (per-spec index), never the legacy monolith +5. **Small commits** — one task = one logical commit +6. **Ask via the spec** — if unclear, note the ambiguity in the completion note + and let the Planner agent refine the spec for the next iteration diff --git a/sdd/proposals/async-vault-read.proposal.md b/sdd/proposals/async-vault-read.proposal.md new file mode 100644 index 00000000..d3e63eef --- /dev/null +++ b/sdd/proposals/async-vault-read.proposal.md @@ -0,0 +1,221 @@ +--- +id: FEAT-001 +title: Cache Vault documents per path and support async Vault load + cache +slug: async-vault-read +type: feature +mode: enrichment +status: accepted +source: + kind: inline + jira_key: null + jira_url: null + fetched_at: 2026-09-29 + summary_oneline: Cache Vault documents by path, drop the remote exists()+get() double read, and check that async load + cache is feasible +overall_confidence: high +base_branch: main +projects: [navconfig] +tags: [vault, cache, async, performance] +research_state: null +created: 2026-09-29 +updated: 2026-09-29 +--- + +# FEAT-001: Cache Vault documents per path and support async Vault load + cache + +> **Mode**: enrichment +> **Confidence**: high (localization), medium (async approach) +> **Source**: `inline` +> **Spec**: sdd/specs/async-vault-read.spec.md + +--- + +## 0. Origin + +> Navconfig: cachear documentos de Vault por ruta y evitar exists() + get() +> remotos, adicionalmente verificar que podemos hacer la carga + cache del +> vault de manera asíncrona + +**Initial signals**: +- Verbs: "cachear", "evitar", "verificar", which point to a performance change plus a feasibility check. +- Named entities: Vault, `exists()`, `get()`, carga asíncrona (async loading). +- Acceptance criteria provided: none yet (see §5). + +--- + +## 1. Synthesis Summary + +A Kardex key lookup that misses `os.environ` and `_mapping_` goes through +`Kardex._get_external`. That method calls `reader.exists(key)` and then +`reader.get(key)` on every external reader. For `VaultReader`, each of those +calls is a separate HTTP request that downloads the **same whole KV document** +(`read_secret_version(path=...)`). One lookup therefore costs two remote reads, +and nothing is cached. Misses cost the same, so a key that isn't there hits +Vault twice every time it is requested. The proposal: +(a) cache each KV document in memory, keyed by `(mount, path)`, so that +`exists()`, `get()` and `list()` all read from one fetched copy; +(b) collapse `_get_external` into a single lookup; +(c) add an async load/prefetch path, which needs a transport decision +because `hvac` is synchronous. + +--- + +## 2. Codebase Findings + +### 2.1 Localization + +| # | Path | Symbol | Role | Evidence | +|---|------|--------|------|----------| +| 1 | `navconfig/readers/vault.py` | `VaultReader.get` | Parses `path/key`, reads the full KV doc remotely, and returns one key | F001 | +| 2 | `navconfig/readers/vault.py` | `VaultReader.exists` | Duplicates `get`'s logic and does another full remote read | F001 | +| 3 | `navconfig/readers/vault.py` | `VaultReader.list` | Reads the full KV doc at `path`, and optionally filters it | F001 | +| 4 | `navconfig/readers/vault.py` | `set` / `delete` | Read-modify-write on the doc. The cache must be invalidated or updated here. | F001 | +| 5 | `navconfig/kardex.py` | `Kardex._get_external` | `exists(key)` followed by `get(key)`, which means 2 remote calls per lookup | F002 | +| 6 | `navconfig/kardex.py` | `Kardex.get` / `exists` / `__contains__` | Callers of `_get_external`. `exists()` goes through `_get_external`, so it costs 2 more calls. | F002 | +| 7 | `navconfig/kardex.py` | `_init_external_readers` | Builds `VaultReader(env=self.ENV)` when `VAULT_ENABLED` is set | F002 | +| 8 | `navconfig/loaders/vault.py` | `vaultLoader._load_from_vault` / `_init_vault_reader` | Startup bulk load through `VaultReader.list(path=vault_env)`. It builds a *second*, separate `VaultReader`. | F003 | + +### 2.2 Constraints Discovered + +- **Kardex is synchronous and initializes at import time.** `navconfig.config` + is built when the module is imported, and every public accessor is sync. + *Implication*: an async path has to be **additive** (an opt-in prefetch or + `async` methods). It can't replace the sync API, and it can't require a + running loop at import. *Evidence*: F002 +- **`hvac` is sync-only** (it uses `requests` under the hood). Doing real async I/O + means either `asyncio.to_thread(...)` around hvac, or calling the Vault + HTTP API directly with `aiohttp`, which is already in the ecosystem. *Evidence*: F001 +- **Key syntax overloading.** `get("a/b/KEY")` means doc `a/b` and field + `KEY`. A bare `KEY` means doc `self._env` (VAULT_ENV > env > ENV). + `set()` and `delete()` split on the *first* `/`, but `get()` and `exists()` split on the *last* one. + That inconsistency affects how cache keys are computed. *Evidence*: F001 +- **Secrets in memory.** Values are already copied into `os.environ` and + `_mapping_` by the loader, so an in-process cache doesn't change the + exposure surface. It should never be written to disk or to Redis. *Evidence*: F003 +- **Two VaultReader instances.** The loader and the Kardex external reader + each build their own reader, so a per-instance cache would fetch the same + document twice at startup. *Evidence*: F002, F003 + +### 2.3 Recent History + +| Commit | Message | +|--------|---------| +| `5dfb34c` | Make uvloop optional and lazy for Windows and support Python 3.14 | +| `c69acfa` | Reorganize kardex into sub-commands and fix the initialization order | +| `710ff7c` | Add VAULT_ENV to override the vault path segment independently of ENV | + +No tests currently cover `VaultReader` or `_get_external`. + +--- + +## 3. Probable Scope + +### What's New + +- **Per-path document cache in `VaultReader`**: stores + `{(mount, path): (data | MISSING, fetched_at)}`, has an optional TTL + (e.g. `VAULT_CACHE_TTL`), caches negative results (missing paths), and + exposes `invalidate(path=None)` / `refresh()`. +- **Single internal `_read_document(path)`**: the one place that talks to + Vault. `get`, `exists` and `list` all go through it. +- **Async load + cache**: something like `await reader.aload(paths)` / + `await config.aload_vault()`, which pre-fills the cache concurrently. + The transport (`to_thread` vs. `aiohttp`) is still open, see §5. + +### What Changes + +- **`VaultReader.get` / `exists` / `list`**: served from the cache, and the + duplicated parsing moves into one helper. +- **`VaultReader.set` / `delete`**: update or invalidate the cached document + after the write. +- **`Kardex._get_external`**: one call per reader, e.g. `get(key, default=_SENTINEL)`, + instead of `exists()` followed by `get()`. +- **`vaultLoader`**: could share or seed the Kardex reader's cache so the + startup bulk load isn't repeated. + +### What's Untouched (Non-Goals) + +- Public sync API of `Kardex` (`config.get`, `config.KEY`, `in`). +- The Redis reader's behavior. +- Vault authentication methods (token only). AppRole etc. are out of scope. +- Persisting the cache outside process memory. + +### Integration Risks + +- **Stale secrets** after rotation in Vault. *Mitigation*: TTL, + explicit `invalidate()`, and no TTL by default only if you agree to that (§5). +- **`__contains__` bug**: it returns after the *first* reader + (`return val is True` inside the loop). It's worth fixing while in there. +- **Thread-safety**: the cache is a dict shared across threads, so it needs a lock + or an idempotent fill pattern. + +--- + +## 4. Confidence Map + +| ID | Claim | Evidence | Confidence | +|----|-------|----------|------------| +| C1 | `_get_external` does `exists()` then `get()`, i.e. 2 remote reads per lookup | F002 (kardex.py `_get_external`) | high | +| C2 | `exists()` and `get()` each download the whole KV document | F001 | high | +| C3 | There is no caching at all in `VaultReader` | F001 | high | +| C4 | Misses are re-fetched every time | F001, F002 | high | +| C5 | `hvac` has no async API, so async needs to_thread or direct HTTP | library knowledge | medium | +| C6 | Loader and Kardex hold separate `VaultReader` instances | F002, F003 | high | + +Distribution: **5** high, **1** medium, **0** low. + +--- + +## 5. Open Questions + +### Resolved (during proposal phase) + +- [x] **Cache freshness**: *Resolved*: configurable TTL via `VAULT_CACHE_TTL` + (default e.g. 300s; `0` = process lifetime), plus manual `invalidate()` / `refresh()`. +- [x] **Async transport**: *Resolved*: `asyncio.to_thread` wrapping the existing + hvac client. No new dependencies and a single KV v1/v2 code path. +- [x] **Async entry point**: *Resolved*: both an explicit prefetch + (`await config.aload_vault(paths)` for `on_startup`) and lazy async getters + (`await config.aget(key)` / `aexists(key)`). +- [x] **Cache scope**: *Resolved*: shared at module level, keyed by + `(url, mount, path)`, so that the loader and Kardex reuse the same documents. +- [x] **Negative caching**: *Resolved*: yes. Missing paths/keys are cached with the same TTL. + +### Unresolved (defer to spec) + +- [ ] **Default TTL value**: is 300s acceptable? *Owner*: tbd +- [ ] **Concurrent miss de-duplication**: should concurrent `aget()` calls for the same path + share one in-flight fetch (per-path lock / future)? The recommendation is yes. + +--- + +## 6. Recommended Next Step + +**`/sdd-spec FEAT-001`**, once the open questions in §5 are answered. +Localization is high-confidence and the sync part (cache + single lookup) +is well-bounded. The async part is additive. + +### Parallelism + +**Mixed**: (1) the sync per-path cache and the `_get_external` single lookup, and +(2) the async load/prefetch, are separable. (2) builds on the cache from (1), +so it should run sequentially in the same worktree (per-spec). + +--- + +## 7. Research Audit + +| Finding | Description | +|---------|-------------| +| F001 | `navconfig/readers/vault.py`, a full read of `VaultReader` | +| F002 | `navconfig/kardex.py`: `_init_external_readers`, `_get_external`, `get`, `exists`, `__contains__` | +| F003 | `navconfig/loaders/vault.py`: `_load_from_vault`, `_init_vault_reader` | + +--- + +## 8. Provenance + +| Field | Value | +|-------|-------| +| Generated by | `/sdd-proposal` | +| Template | `ai-parrot/sdd/templates/proposal.md` (navconfig has no `sdd/templates/` yet) | +| Operator | Claude Code | diff --git a/sdd/specs/async-vault-read.spec.md b/sdd/specs/async-vault-read.spec.md new file mode 100644 index 00000000..474b40e2 --- /dev/null +++ b/sdd/specs/async-vault-read.spec.md @@ -0,0 +1,466 @@ +--- +type: feature +base_branch: main +projects: [navconfig] +tags: [vault, cache, async, performance] +--- + +# Feature Specification: Vault Document Cache & Async Vault Read + +**Feature ID**: FEAT-001 +**Date**: 2026-09-29 +**Author**: Jesus Lara (with Claude Code) +**Status**: approved +**Target version**: 2.6.0 +**Proposal**: `sdd/proposals/async-vault-read.proposal.md` + +--- + +## 1. Motivation & Business Requirements + +### Problem Statement + +When a Kardex lookup misses both `os.environ` and `_mapping_`, it falls through +to `Kardex._get_external`, which calls `reader.exists(key)` and then +`reader.get(key)`. For `VaultReader`, each of those calls is its own HTTP round trip +that downloads the **whole** KV document at the resolved path. The consequences: + +- Every external lookup costs **2 remote reads** of the same document. +- `Kardex.exists()` costs 2 more, because it also goes through `_get_external`. +- Nothing is cached, so a key that doesn't exist hits Vault twice on *every* + access. +- `vaultLoader` and the Kardex external reader each create their own + `VaultReader`, so the same document is fetched twice at startup. +- The only way to talk to Vault is synchronously (`hvac` is sync-only). Async apps + (navigator/aiohttp) block the event loop on Vault I/O and can't prefetch + secrets concurrently during `on_startup`. + +### Goals + +- G1: At most **one** remote read per `(vault, mount, path)` within the TTL, shared + across all `VaultReader` instances in the process. +- G2: `_get_external` makes a single `get()` call per reader, with no `exists()` + `get()` pair. +- G3: Configurable TTL (`VAULT_CACHE_TTL`, default 300s, `0` = never expire), + plus explicit `invalidate()` / `refresh()`. +- G4: Missing documents (`InvalidPath`) are negatively cached with the same TTL. + Transient errors are **never** cached. +- G5: Async API: `await config.aload_vault(paths)` for prefetching, plus + `await config.aget(key)` / `await config.aexists(key)`. Both are implemented with + `asyncio.to_thread` over the existing hvac client. +- G6: Concurrent misses on the same path (sync threads or async tasks) share a + single in-flight fetch (single-flight). +- G7: Fix `Kardex.__contains__` so it checks every reader, not only the first. + +### Non-Goals + +- Changing the synchronous public API of `Kardex` (`get`, `__getattr__`, `in`, `exists`). +- A native `aiohttp` Vault client. The transport stays hvac. +- Vault auth methods other than token (AppRole, K8s, …). +- Persisting the cache outside process memory (no disk, no Redis). +- Changes to the Redis reader. + +--- + +## 2. Architectural Design + +### Overview + +A new module-level `VaultDocumentCache` (thread-safe, TTL, negative entries, +per-key single-flight lock) stores whole KV documents. Its key is +`(url, token_fingerprint, mount, kv_version, path)`, so readers pointing at +the same Vault with the same token share entries, and different tokens (which +may have different ACLs) never do. + +`VaultReader` gets a single fetch point, `_read_document(path)`, that goes through the +cache. `get`, `exists` and `list` all read through it. `set` and `delete` write +through and then store the new document in the cache. The async methods are thin +`asyncio.to_thread` wrappers over the same sync code path. Because the cache locks +are `threading` primitives, sync and async callers share one consistent cache. + +Kardex's `_get_external` switches to a single `get()`. New `aget`, `aexists` +and `aload_vault` methods, plus `invalidate_vault_cache`, are added. +`vaultLoader` needs no code change: its `VaultReader.list()` call fills the +shared cache, and the Kardex reader then reuses it. + +### Component Diagram +``` +Kardex.get / exists / __contains__ ──→ _get_external ──→ reader.get(key) (1 call) +Kardex.aget / aexists / aload_vault ─→ VaultReader.aget / aexists / aload + │ asyncio.to_thread + ▼ +vaultLoader._load_from_vault ──→ VaultReader.get / exists / list / set / delete + │ + ▼ + VaultReader._read_document(path) + │ + ▼ + VaultDocumentCache (module singleton) + hit ─→ dict | MISSING + miss ─→ per-key lock ─→ _fetch_document (hvac, 1 HTTP) +``` + +### Integration Points + +| Existing Component | Integration Type | Notes | +|---|---|---| +| `navconfig.readers.vault.VaultReader` | modifies | Reads go through the cache; adds async methods and `invalidate()` | +| `navconfig.kardex.Kardex` | modifies | Single-call `_get_external`, `__contains__` fix, async API, cache invalidation | +| `navconfig.loaders.vault.vaultLoader` | uses (no change) | Its `list()` call now fills the shared cache | +| `navconfig.readers.abstract.AbstractReader` | unchanged | No new abstract methods, so the Redis reader is unaffected | + +### Data Models + +navconfig does **not** depend on pydantic. Use stdlib `dataclasses` (no new deps). + +```python +MISSING: Final = object() # negative-cache marker (path does not exist) + +@dataclass(frozen=True) +class CacheKey: + url: str + token_fp: str # sha256(token)[:16], so the raw token is never stored in the key + mount: str + version: int + path: str + +@dataclass +class CacheEntry: + data: Union[dict, object] # dict of the KV document, or MISSING + fetched_at: float # time.monotonic() +``` + +### New Public Interfaces +```python +# navconfig/readers/vault.py +class VaultReader(AbstractReader): + def invalidate(self, path: Optional[str] = None) -> None: ... + def refresh(self, path: Optional[str] = None) -> dict: ... + async def aget(self, key: str, default: Any = None, sub_key: Optional[str] = None) -> Any: ... + async def aexists(self, key: str) -> bool: ... + async def alist(self, path: Optional[str] = None, filter: Optional[str] = None) -> dict: ... + async def aload(self, paths: Optional[Iterable[str]] = None) -> Dict[str, bool]: ... + +# navconfig/kardex.py +class Kardex: + async def aget(self, key: str, section: Optional[str] = None, fallback: Any = None) -> Any: ... + async def aexists(self, key: str) -> bool: ... + async def aload_vault(self, paths: Optional[Iterable[str]] = None) -> Dict[str, bool]: ... + def invalidate_vault_cache(self, path: Optional[str] = None) -> None: ... +``` + +--- + +## 3. Module Breakdown + +#### Delegation-eligible modules + +| Module | Eligible? | Decided patterns / exact contracts | Why not (if no) | +|---|---|---|---| +| M1: VaultDocumentCache | yes | Key/entry dataclasses, TTL semantics, single-flight via per-key `threading.Lock`, module singleton | — | +| M2: VaultReader refactor | yes | `_split_key` = last `/`; cache-through reads; write-through set/delete; `to_thread` async | — | +| M3: Kardex integration | yes | Single `get()` in `_get_external`; async methods listed in §2 | — | +| M4: Tests & docs | yes | Fake hvac client counting calls; `@pytest.mark.asyncio` | — | + +### Module 1: VaultDocumentCache +- **Path**: `navconfig/readers/vault_cache.py` (new) +- **Responsibility**: A thread-safe, process-wide TTL cache of KV documents with + negative entries and single-flight loading. +- **Depends on**: stdlib only (`threading`, `time`, `hashlib`, `dataclasses`). +- **Interface Skeleton**: + ```python + # navconfig/readers/vault_cache.py (new) + MISSING: Final[object] + + def token_fingerprint(token: str) -> str: + """Return sha256(token) hex truncated to 16 chars.""" + + class VaultDocumentCache: + """Process-wide cache of Vault KV documents keyed by CacheKey.""" + def __init__(self, ttl: float = 300.0) -> None: + """ttl <= 0 means entries never expire.""" + @property + def ttl(self) -> float: ... + def get_or_load(self, key: CacheKey, loader: Callable[[], Union[dict, object]]) -> Union[dict, object]: + """Return a fresh cached value, or call loader() under a per-key lock (single-flight). + loader returns a dict, or MISSING when the path doesn't exist. Exceptions raised by + loader propagate and are NOT cached.""" + def put(self, key: CacheKey, data: Union[dict, object]) -> None: + """Store/replace an entry (used by set/delete write-through).""" + def invalidate(self, predicate: Optional[Callable[[CacheKey], bool]] = None) -> int: + """Drop matching entries (all when predicate is None). Returns count dropped.""" + + def get_document_cache() -> VaultDocumentCache: + """Module singleton; TTL read once from VAULT_CACHE_TTL (float seconds, default 300).""" + ``` + +### Module 2: VaultReader refactor +- **Path**: `navconfig/readers/vault.py` (modifies) +- **Responsibility**: Route every read through the cache, unify key parsing, + write-through on `set`/`delete`, and add the async wrappers. +- **Depends on**: Module 1 +- **Interface Skeleton**: + ```python + class VaultReader(AbstractReader): + def __init__(self, env: str = None) -> None: + """Unchanged behavior; additionally stores self._url, self._token_fp, self._cache.""" + def _split_key(self, key: str) -> Tuple[str, str]: + """'a/b/KEY' -> ('a/b', 'KEY'); 'KEY' or '/KEY' -> (self._env, 'KEY'). Splits on LAST '/'.""" + def _cache_key(self, path: str) -> CacheKey: ... + def _fetch_document(self, path: str) -> Union[dict, object]: + """One hvac read (kv v1/v2). Returns dict, or MISSING on hvac.exceptions.InvalidPath. + Any other exception propagates (never cached).""" + def _read_document(self, path: str) -> Union[dict, object]: + """self._cache.get_or_load(self._cache_key(path), lambda: self._fetch_document(path)).""" + def get(self, key, default=None, path="secrets", sub_key=None) -> Any: + """Same signature and return semantics as today; served from _read_document. + Transient errors -> log at debug level and return default (unchanged behavior).""" + def exists(self, key: str) -> bool: ... + def list(self, path: str = None, filter: str = None) -> dict: + """Returns a shallow COPY of the cached document (callers must not mutate the cache).""" + def set(self, key: str, value: Any, **kwargs) -> None: + """Read-modify-write as today, then cache.put(new document).""" + def delete(self, key: str, secret_path: str = None) -> bool: + """As today, then cache.put(new document).""" + def invalidate(self, path: Optional[str] = None) -> None: + """Drop this reader's entries (same url/token/mount/version); one path, or all.""" + def refresh(self, path: Optional[str] = None) -> dict: + """invalidate(path) and re-read it (path defaults to self._env).""" + async def aget(...) -> Any: # asyncio.to_thread(self.get, ...) + async def aexists(...) -> bool: + async def alist(...) -> dict: + async def aload(self, paths=None) -> Dict[str, bool]: + """Prefetch paths concurrently (asyncio.gather over to_thread(_read_document)). + paths defaults to [self._env]. Returns {path: found}. A failing path logs a warning + and maps to False; it does not raise.""" + ``` + +### Module 3: Kardex integration +- **Path**: `navconfig/kardex.py` (modifies) +- **Responsibility**: Single-call external lookup, fix `__contains__`, and expose the async API + and cache invalidation. +- **Depends on**: Module 2 +- **Interface Skeleton**: + ```python + def _get_external(self, key: str) -> Any: + """For each enabled reader: val = reader.get(key); return val if not None. + Keeps the existing `except RuntimeError: continue`. No exists() call.""" + def __contains__(self, key: str) -> bool: + """environ/_mapping_ first, then any(reader.exists(key)) across ALL enabled readers.""" + async def aget(self, key, section=None, fallback=None) -> Any: + """Same resolution order as get(). The external step awaits reader.aget when present, + otherwise asyncio.to_thread(reader.get, key). Applies _unserialize like get().""" + async def aexists(self, key: str) -> bool: ... + async def aload_vault(self, paths=None) -> Dict[str, bool]: + """Delegates to self._readers['vault'].aload(paths) when vault is enabled; else {}.""" + def invalidate_vault_cache(self, path: Optional[str] = None) -> None: + """Delegates to the vault reader's invalidate(); no-op when vault is disabled.""" + def reload_current_env(self): + """Now calls invalidate_vault_cache() before set_env(..., reload=True).""" + ``` + +### Module 4: Tests & docs +- **Path**: `tests/test_vault_cache.py`, `tests/test_vault_reader.py`, + `tests/test_kardex_vault.py` (new); `docs/` + `CHANGELOG.md` (modifies) +- **Responsibility**: Unit tests with a fake hvac client that counts reads. Document + `VAULT_CACHE_TTL` and the async API. +- **Depends on**: Modules 1–3 + +--- + +## 4. Test Specification + +### Unit Tests +| Test | Module | Description | +|---|---|---| +| `test_cache_hit_within_ttl` | M1 | The second `get_or_load` doesn't call the loader | +| `test_cache_expires_after_ttl` | M1 | Monkeypatched `time.monotonic`, so the loader is called again after the TTL | +| `test_cache_ttl_zero_never_expires` | M1 | `ttl=0` → never reloads | +| `test_cache_negative_entry` | M1 | `MISSING` is cached; the loader is called once | +| `test_cache_exception_not_cached` | M1 | The loader raises → the next call retries | +| `test_cache_single_flight_threads` | M1 | N threads missing the same key → the loader is called once | +| `test_cache_invalidate_predicate` | M1 | Only matching keys are dropped | +| `test_reader_get_exists_one_fetch` | M2 | `exists()`, `get()` and `list()` on one path → 1 hvac read | +| `test_reader_shared_cache_across_instances` | M2 | Two readers with the same url/token → 1 read | +| `test_reader_different_token_not_shared` | M2 | Different tokens → separate reads | +| `test_reader_missing_path_cached` | M2 | `InvalidPath` → `default`/`False`, and 1 read across repeats | +| `test_reader_transient_error_not_cached` | M2 | Generic exception → `default`, then retried | +| `test_reader_split_key_last_slash` | M2 | `set`, `get` and `delete` agree on `a/b/KEY` | +| `test_reader_set_write_through` | M2 | After `set`, `get` returns the new value with no extra read | +| `test_reader_list_returns_copy` | M2 | Mutating the result of `list()` doesn't affect the cache | +| `test_reader_aget_aexists` | M2 | Async wrappers return the same results as the sync ones | +| `test_reader_aload_concurrent` | M2 | `aload(['a','b','missing'])` → `{'a':True,'b':True,'missing':False}`, 1 read each | +| `test_kardex_get_external_single_call` | M3 | Fake reader: `get` called once, `exists` never | +| `test_kardex_contains_checks_all_readers` | M3 | A key found only in the second reader → `True` | +| `test_kardex_aget_resolution_order` | M3 | `_mapping_` > environ > readers, same as `get()` | +| `test_kardex_aload_vault_disabled` | M3 | Vault not enabled → `{}` | + +### Test Data / Fixtures +```python +@pytest.fixture +def fake_hvac(monkeypatch): + """Patch hvac.Client with an in-memory KV store that counts read calls + and raises hvac.exceptions.InvalidPath for unknown paths.""" + +@pytest.fixture(autouse=True) +def fresh_cache(monkeypatch): + """Reset the module singleton between tests.""" +``` + +No real Vault is required. `@pytest.mark.asyncio` goes on async tests (pytest-asyncio +is already a dev dependency). + +--- + +## 5. Acceptance Criteria + +- [ ] `pytest` passes, including all the new tests in §4. +- [ ] A `Kardex.get()` of a Vault-only key performs **1** hvac read (was 2), and 0 on + a repeat within the TTL. +- [ ] `Kardex.exists()` / `in` on a missing key performs at most 1 hvac read per path + per TTL window. +- [ ] Startup (`vaultLoader` + Kardex vault reader) reads `{mount}/{VAULT_ENV}` once. +- [ ] `await config.aload_vault([...])` prefetches without blocking the event loop. + After that, sync `config.get()` performs 0 hvac reads. +- [ ] `VAULT_CACHE_TTL` is documented. `0` disables expiry. +- [ ] No breaking changes to the sync public API. `AbstractReader` is unchanged. +- [ ] `CHANGELOG.md` has an entry covering the cache, the async API, the `__contains__` fix, and + the unified key parsing in `set`/`delete`. + +--- + +## 6. Codebase Contract + +### Verified Imports +```python +import hvac # navconfig/readers/vault.py:4 +from navconfig.exceptions import ReaderNotSet # used at navconfig/readers/vault.py:5 +from navconfig.readers.abstract import AbstractReader # navconfig/readers/abstract.py +from navconfig.readers.vault import VaultReader # navconfig/kardex.py:37 (relative import) +``` + +### Existing Class Signatures +```python +# navconfig/readers/vault.py +class VaultReader(AbstractReader): + def __init__(self, env: str = None) -> None # reads VAULT_URL, VAULT_TOKEN, VAULT_VERSION, + # VAULT_MOUNT_POINT, VAULT_ENV; sets self.client, + # self.version, self._mount, self._env, self.enabled + def get(self, key: str, default: Any = None, path: str = "secrets", sub_key: str = None) -> Any + def exists(self, key: str) -> bool + def set(self, key: str, value: Any, **kwargs) -> None # splits key on FIRST '/' today + def delete(self, key: str, secret_path: str = None) -> bool # splits on FIRST '/' today + def list(self, path: str = None, filter: str = None) -> dict + def list_paths(self, path: str = None) -> list # untouched + +# navconfig/readers/redis.py +class mredis(AbstractReader): + def get(self, key) # returns None on miss, so it's compatible with the single-get _get_external + def exists(self, key, *keys) + +# navconfig/kardex.py +class Kardex: + _readers: dict = {} # CLASS-level dict (line 51) + def _init_external_readers(self) # line 183 + def _get_external(self, key: str) -> Any # line 396 + def get(self, key: str, section: str = None, fallback: Any = None) -> Any # line 496 + def __contains__(self, key: str) -> bool # line 538 + def exists(self, key: str) -> bool # line 547 + def reload_current_env(self) # line 790 + +# navconfig/loaders/vault.py +class vaultLoader(BaseLoader): + def _load_from_vault(self) -> Dict[str, Any] # calls self.vault_reader.list(path=self.vault_env) + def _init_vault_reader(self) -> None # builds its own VaultReader(env=self.vault_env) +``` + +### Integration Points +| New Component | Connects To | Via | Verified At | +|---|---|---|---| +| `VaultDocumentCache` | `VaultReader._read_document` | `get_or_load` | new | +| `VaultReader.aget` etc. | `VaultReader.get` etc. | `asyncio.to_thread` | new | +| `Kardex.aload_vault` | `self._readers["vault"]` | `.aload()` | `kardex.py:217` | +| `vaultLoader` | shared cache | `VaultReader.list()` | `loaders/vault.py:222` | + +### Does NOT Exist (Anti-Hallucination) +- ~~`hvac.AsyncClient`~~ / any async hvac API: does not exist. +- ~~`pydantic`~~: not a navconfig dependency. Use `dataclasses`. +- ~~`VaultReader.url` / `VaultReader.token`~~: not stored today. M2 adds `_url` / `_token_fp`. +- ~~`Kardex.aget` / `aexists` / `aload_vault` / `invalidate_vault_cache`~~: don't exist yet. +- ~~`AbstractReader.aget`~~: don't add it. Kardex feature-detects with `hasattr`. + +### Edit Sites (Blueprint Anchors) + +Verified against: `1e6516d` + +| File | Action | Verbatim anchor line | Verified at | Occurrences | +|---|---|---|---|---| +| `navconfig/readers/vault_cache.py` | CREATE | — | — | — | +| `navconfig/readers/vault.py` | MODIFY | `class VaultReader(AbstractReader):` | `vault.py:14` | 1 | +| `navconfig/kardex.py` | MODIFY | `def _get_external(self, key: str) -> Any:` | `kardex.py:396` | 1 | +| `navconfig/kardex.py` | MODIFY | `def __contains__(self, key: str) -> bool:` | `kardex.py:538` | 1 | +| `navconfig/kardex.py` | MODIFY | `def reload_current_env(self):` | `kardex.py:790` | 1 | +| `tests/test_vault_cache.py` | CREATE | — | — | — | +| `tests/test_vault_reader.py` | CREATE | — | — | — | +| `tests/test_kardex_vault.py` | CREATE | — | — | — | +| `CHANGELOG.md` | MODIFY | (top of file) | — | — | + +--- + +## 7. Implementation Notes & Constraints + +### Patterns to Follow +- Keep the existing reader error contract: `get` → `default`, and `exists` → `False` on errors. +- `logging` module-level calls, as the existing reader does. +- Google-style docstrings and type hints on all new and changed methods. +- Async methods must not hold the event loop: all hvac I/O goes through `asyncio.to_thread`. + +### Known Risks / Gotchas +- **Stale secrets after rotation**: the TTL defaults to 300s. `invalidate()`, + `refresh()` and `reload_current_env()` give manual control. +- **Key-parsing unification** (`set`/`delete` switch from first `/` to last `/`): for + single-level keys (`dev/KEY`, `KEY`) the result is the same. It only changes nested keys + (`a/b/KEY`), which were inconsistent with `get()` until now. This must be called out in the CHANGELOG. +- **Mutation leaks**: `list()` must return a copy, and `get()` of a dict-valued secret must + not let callers mutate the cached document (return shallow copies of dict values). +- **Negative cache hides newly created secrets** until the TTL expires or `set()` + (write-through) runs. That's acceptable and should be documented. +- **Deadlock**: never call the loader while holding the global cache lock. Only + the per-key lock is held during I/O. +- **Kardex `_readers` is class-level**: tests must isolate or reset it. +- **Truthiness in `Kardex.get`** (`if val := self._get_external(key)`) is kept as is. It's + out of scope. + +### External Dependencies +None new. `hvac>=2.3.0` is already required, and `pytest-asyncio` is already a dev dependency. + +--- + +## 8. Open Questions + +- [x] Is `VAULT_CACHE_TTL` default = 300s acceptable for production secret rotation? *Owner: Jesus Lara*: default=300s +- [x] Should `set_env()` to a different env also invalidate? The proposed answer is no: a different + path means a different cache key, so it's already correct. *Owner: Jesus Lara*: no + +--- + +## Worktree Strategy + +- **Isolation**: `per-spec`. M1 → M2 → M3 → M4 is a strict dependency chain in the + same files, so tasks run sequentially in one worktree. +- **Parallelizable**: only the M1 tests could be written alongside M2. The gain isn't + worth a second worktree. +- **Cross-feature dependencies**: none. + +--- + +## 9. Design Research Cross-Check + +Status: skipped (no `codex` seat configured for navconfig). + +--- + +## Revision History + +| Version | Date | Author | Change | +|---|---|---|---| +| 0.1 | 2026-09-29 | Jesus Lara / Claude Code | Initial draft from proposal FEAT-001 | diff --git a/sdd/tasks/.index.json b/sdd/tasks/.index.json new file mode 100644 index 00000000..bf14c742 --- /dev/null +++ b/sdd/tasks/.index.json @@ -0,0 +1,78 @@ +{ + "tasks": [ + { + "id": "TASK-001", + "slug": "vault-document-cache", + "title": "VaultDocumentCache module (TTL, negative entries, single-flight)", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "high", + "effort": "M", + "depends_on": [], + "parallel": false, + "parallelism_notes": "Foundation module, stdlib only; every later task imports it.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-001-vault-document-cache.md" + }, + { + "id": "TASK-002", + "slug": "vault-reader-cache-async", + "title": "VaultReader refactor (cache-through reads, write-through, async API)", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "high", + "effort": "L", + "depends_on": [ + "TASK-001" + ], + "parallel": false, + "parallelism_notes": "Imports vault_cache from TASK-001; modifies vault.py which TASK-003 depends on.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-002-vault-reader-cache-async.md" + }, + { + "id": "TASK-003", + "slug": "kardex-vault-integration", + "title": "Kardex integration (single-get lookup, __contains__ fix, async API)", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "high", + "effort": "M", + "depends_on": [ + "TASK-002" + ], + "parallel": false, + "parallelism_notes": "Calls VaultReader.aget/aload/invalidate added in TASK-002.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-003-kardex-vault-integration.md" + }, + { + "id": "TASK-004", + "slug": "vault-cache-docs-changelog", + "title": "Docs + CHANGELOG for Vault cache & async API", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "medium", + "effort": "S", + "depends_on": [ + "TASK-003" + ], + "parallel": false, + "parallelism_notes": "Documents final API and runs full suite; must follow all code tasks.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-004-vault-cache-docs-changelog.md" + } + ] +} diff --git a/sdd/tasks/active/TASK-001-vault-document-cache.md b/sdd/tasks/active/TASK-001-vault-document-cache.md new file mode 100644 index 00000000..383480e0 --- /dev/null +++ b/sdd/tasks/active/TASK-001-vault-document-cache.md @@ -0,0 +1,255 @@ +# TASK-001: VaultDocumentCache module (TTL, negative entries, single-flight) + +**Feature**: FEAT-001 — Vault Document Cache & Async Vault Read +**Spec**: `sdd/specs/async-vault-read.spec.md` +**Status**: pending +**Priority**: high +**Estimated effort**: M (2-4h) +**Depends-on**: none +**Assigned-to**: unassigned + +--- + +## Context + +Implements spec §3 **Module 1**. This is the storage layer for the whole feature: +a process-wide, thread-safe cache of whole Vault KV documents. `VaultReader` +(TASK-002) routes every read through it, which gives the spec's goals: +G1 (one read per path per TTL, shared across instances), G3 (TTL), G4 (negative +caching, but no caching of transient errors) and G6 (single-flight). + +--- + +## Scope + +- Create `navconfig/readers/vault_cache.py`, containing: + - the `MISSING` sentinel; + - `token_fingerprint()`; + - the `CacheKey` and `CacheEntry` dataclasses; + - `VaultDocumentCache` with `ttl`, `get_or_load`, `put` and `invalidate`; + - the `get_document_cache()` module singleton, plus a private reset helper for tests. +- The TTL is read **once** from `VAULT_CACHE_TTL` (float seconds, default `300`). `<= 0` means entries never expire. +- Single-flight: a per-key `threading.Lock`. **Never** hold the global lock while calling the loader. +- Exceptions raised by the loader propagate and are **not** cached. +- Write `tests/test_vault_cache.py` covering the 7 M1 tests in spec §4. + +**NOT in scope**: any change to `VaultReader` (TASK-002), to Kardex (TASK-003), or to docs and the CHANGELOG (TASK-004). + +--- + +## Files to Create / Modify + +| File | Action | Description | +|---|---|---| +| `navconfig/readers/vault_cache.py` | CREATE | Cache module | +| `tests/test_vault_cache.py` | CREATE | Unit tests for M1 | + +--- + +## Codebase Contract (Anti-Hallucination) + +### Verified Imports +```python +# stdlib only. No third-party imports in this module. +import hashlib, os, threading, time +from dataclasses import dataclass +from typing import Callable, Dict, Final, Optional, Union +``` + +### Existing Signatures to Use +None. This is a new, self-contained module. + +### Does NOT Exist +- ~~`pydantic`~~: not a navconfig dependency. Use `dataclasses`. +- ~~`navconfig.readers.vault_cache`~~: doesn't exist yet. This task creates it. +- ~~`cachetools`~~: not a dependency. Don't add one. + +--- + +## Complexity Contract + +```json +{ + "schema_version": 1, + "targets": [ + {"path": "navconfig/readers/vault_cache.py", "action": "CREATE"}, + {"path": "tests/test_vault_cache.py", "action": "CREATE"} + ], + "contract_symbols": [] +} +``` + +--- + +## Implementation Notes + +### Key Constraints +- Stdlib only, with Google-style docstrings and type hints. +- Use `time.monotonic()` for timestamps, so tests can monkeypatch `navconfig.readers.vault_cache.time.monotonic`. +- The raw token never appears in `CacheKey` or in logs. Only `token_fingerprint()` output (the first 16 hex chars of the sha256) does. +- `pyproject.toml` sets `filterwarnings = ["error", ...]`, so the tests must not emit warnings. + +### Locking design (decided) +- `self._lock` (global) guards `self._entries: Dict[CacheKey, CacheEntry]` and `self._key_locks: Dict[CacheKey, threading.Lock]`. +- `get_or_load` works in these steps: + 1. Under the global lock, return the value if the entry is fresh. Otherwise get or create the per-key lock. + 2. Acquire the per-key lock. + 3. Re-check freshness under the global lock (another thread may have filled it). + 4. Call `loader()` **outside** the global lock. + 5. Store the entry under the global lock and return. + +--- + +## Implementation Blueprint + +### `navconfig/readers/vault_cache.py` (CREATE) +```python +"""Process-wide cache of HashiCorp Vault KV documents.""" +from __future__ import annotations + +import hashlib +import os +import threading +import time +from dataclasses import dataclass +from typing import Callable, Dict, Final, Optional, Union + +MISSING: Final = object() +"""Negative-cache marker: the Vault path does not exist (InvalidPath).""" + + +def token_fingerprint(token: str) -> str: + """Return sha256(token) hex digest truncated to 16 chars.""" + return hashlib.sha256((token or "").encode("utf-8")).hexdigest()[:16] + + +@dataclass(frozen=True) +class CacheKey: + """Identity of a cached KV document.""" + url: str + token_fp: str + mount: str + version: int + path: str + + +@dataclass +class CacheEntry: + """Cached KV document (dict) or MISSING, with its fetch time.""" + data: Union[dict, object] + fetched_at: float + + +class VaultDocumentCache: + """Thread-safe TTL cache with negative entries and single-flight loading.""" + + def __init__(self, ttl: float = 300.0) -> None: + # FILL IN: store ttl, _entries, _key_locks, _lock (threading.Lock) + raise NotImplementedError + + @property + def ttl(self) -> float: + # FILL IN + raise NotImplementedError + + def _is_fresh(self, entry: CacheEntry) -> bool: + # FILL IN: ttl <= 0 -> True; else monotonic() - fetched_at < ttl + raise NotImplementedError + + def get_or_load( + self, key: CacheKey, loader: Callable[[], Union[dict, object]] + ) -> Union[dict, object]: + # FILL IN: locking design above; loader exceptions propagate, not cached + raise NotImplementedError + + def put(self, key: CacheKey, data: Union[dict, object]) -> None: + # FILL IN + raise NotImplementedError + + def invalidate( + self, predicate: Optional[Callable[[CacheKey], bool]] = None + ) -> int: + # FILL IN: drop all (predicate None) or matching; return count + raise NotImplementedError + + +_cache: Optional[VaultDocumentCache] = None +_cache_lock = threading.Lock() + + +def get_document_cache() -> VaultDocumentCache: + """Return the module singleton; TTL read once from VAULT_CACHE_TTL.""" + # FILL IN: double-checked init; invalid VAULT_CACHE_TTL -> 300.0 + raise NotImplementedError + + +def _reset_document_cache() -> None: + """Testing helper: drop the singleton so the next call re-reads the env.""" + # FILL IN + raise NotImplementedError +``` +**Why this shape**: it follows spec §2 (Data Models) and §3 M1 exactly. Keeping the cache in its own module lets `VaultReader` and the tests share the singleton without import cycles. + +### FILL IN checklist +- [ ] `VaultDocumentCache.get_or_load`: single-flight without holding the global lock during I/O +- [ ] `get_document_cache`: parses `VAULT_CACHE_TTL` as float, falling back to 300 on bad input +- [ ] `invalidate`: also prunes the `_key_locks` entries that were dropped + +--- + +## Acceptance Criteria + +- [ ] `pytest tests/test_vault_cache.py -v` passes (7 tests). +- [ ] `from navconfig.readers.vault_cache import VaultDocumentCache, get_document_cache, MISSING, CacheKey, token_fingerprint` works. +- [ ] `test_cache_single_flight_threads`: the loader is called exactly once with 8+ threads. +- [ ] The full `pytest` suite still passes. + +--- + +## Validation Commands + +- `pytest tests/test_vault_cache.py -q` + +--- + +## Test Specification + +```python +# tests/test_vault_cache.py +import threading +import pytest +from navconfig.readers import vault_cache as vc +from navconfig.readers.vault_cache import CacheKey, MISSING, VaultDocumentCache + +KEY = CacheKey("http://v", "fp", "navigator", 2, "dev") + +def test_cache_hit_within_ttl(): ... # loader called once for 2 gets +def test_cache_expires_after_ttl(monkeypatch): ... # patch vc.time.monotonic +def test_cache_ttl_zero_never_expires(monkeypatch): ... +def test_cache_negative_entry(): ... # loader returns MISSING once +def test_cache_exception_not_cached(): ... # raises, then succeeds on retry +def test_cache_single_flight_threads(): ... # threading.Barrier + slow loader +def test_cache_invalidate_predicate(): ... # drop only path == "a" +``` + +--- + +## Agent Instructions + +1. Work in the feature worktree `.claude/worktrees/feat-FEAT-001-async-vault-read`, never on `new-release`. +2. Read the spec for full context. +3. Set this task to `"in-progress"` in `sdd/tasks/.index.json`. +4. Implement from the blueprint, run the validation commands and the full `pytest`, and commit only the listed files. +5. Move this file to `sdd/tasks/completed/`, set its index status to `"done"`, and fill in the Completion Note. + +--- + +## Completion Note + +*(Agent fills this in when done)* + +**Completed by**: +**Date**: +**Notes**: + +**Deviations from spec**: diff --git a/sdd/tasks/active/TASK-002-vault-reader-cache-async.md b/sdd/tasks/active/TASK-002-vault-reader-cache-async.md new file mode 100644 index 00000000..fc01a232 --- /dev/null +++ b/sdd/tasks/active/TASK-002-vault-reader-cache-async.md @@ -0,0 +1,254 @@ +# TASK-002: VaultReader refactor (cache-through reads, write-through, async API) + +**Feature**: FEAT-001 — Vault Document Cache & Async Vault Read +**Spec**: `sdd/specs/async-vault-read.spec.md` +**Status**: pending +**Priority**: high +**Estimated effort**: L (4-8h) +**Depends-on**: TASK-001 +**Assigned-to**: unassigned + +--- + +## Context + +Implements spec §3 **Module 2**. Today `VaultReader.get`, `exists`, `list`, `set` and +`delete` each call hvac directly and duplicate the KV v1/v2 read logic. This task +gives the reader a single fetch point (`_fetch_document`), a cache-through read +(`_read_document`) and unified key parsing (`_split_key`, which splits on the last `/`). +It also adds async wrappers (`asyncio.to_thread`) and prefetching (`aload`). + +--- + +## Scope + +- In `__init__`, store `self._url`, `self._token_fp` (via `token_fingerprint`) and `self._cache = get_document_cache()`. Construction behavior is otherwise unchanged. +- Add these private helpers: + - `_split_key(key)`: splits on the last `/`. + - `_cache_key(path)`. + - `_fetch_document(path)`: one hvac read, v1 or v2. Returns `MISSING` on `hvac.exceptions.InvalidPath`; other exceptions propagate. + - `_read_document(path)`. +- Rewrite `get`, `exists` and `list` on top of `_read_document`, keeping their current signatures and error contracts: + - `get` returns `default` on any error or missing path. It keeps the `"*"` and `sub_key` behavior. + - `exists` returns `False` on error or a missing path, and `True` for `"*"` when the doc exists. + - `list` returns `{}` on error or a missing path, and otherwise a **shallow copy**, filtered if `filter` is given. + - `get` of a dict-valued secret (and `"*"`) returns a shallow copy, so callers can't mutate the cache. +- Rewrite `set` and `delete` to use `_split_key`, so they split on the last `/` like `get` does. Each reads the current doc fresh through `_fetch_document` (bypassing the cache), writes it back, then calls `self._cache.put(key, new_doc)`. + - `set` keeps its `ValueError("Error writing to Vault: ...")` contract. + - `delete` keeps returning a bool. +- Add `invalidate(path=None)` and `refresh(path=None)`. +- Add `aget`, `aexists`, `alist` (each `asyncio.to_thread` over the sync method) and `aload(paths=None)`. +- Write `tests/test_vault_reader.py` covering the 10 M2 tests in spec §4, using a `fake_hvac` fixture. + +**NOT in scope**: `list_paths` (leave it untouched), Kardex changes (TASK-003), docs and the CHANGELOG (TASK-004), and `AbstractReader` (must stay unchanged). + +--- + +## Files to Create / Modify + +| File | Action | Description | +|---|---|---| +| `navconfig/readers/vault.py` | MODIFY | Cache-through reader + async API | +| `tests/test_vault_reader.py` | CREATE | Unit tests for M2 | + +--- + +## Codebase Contract (Anti-Hallucination) + +### Verified Imports +```python +import hvac # navconfig/readers/vault.py:4 +from ..exceptions import ReaderNotSet # navconfig/readers/vault.py:5 +from .abstract import AbstractReader # navconfig/readers/vault.py:6 +from .vault_cache import ( # created by TASK-001 + MISSING, CacheKey, get_document_cache, token_fingerprint, +) +``` + +### Existing Signatures to Use +```python +# navconfig/readers/vault.py +class VaultReader(AbstractReader): # line 14 + def __init__(self, env: str = None) -> None # line 25; locals url, token + # self.version (int, VAULT_VERSION default 2) # line 31 + # self._mount (VAULT_MOUNT_POINT default "navigator") # line 32 + # self._env (VAULT_ENV > env > ENV) # line 33 + # self.client = hvac.Client(url=url, token=token); self.open() # lines 37-38 + def get(self, key, default=None, path="secrets", sub_key=None) -> Any # line 53 + def exists(self, key: str) -> bool # line 97 + def set(self, key: str, value: Any, **kwargs) -> None # line 134 (splits on FIRST '/') + def delete(self, key: str, secret_path: str = None) -> bool # line 198 (splits on FIRST '/') + def list(self, path: str = None, filter: str = None) -> dict # line 242 + def list_paths(self, path: str = None) -> list # line 289 (DO NOT TOUCH) + +# hvac calls in use today: +client.secrets.kv.v1.read_secret(path=..., mount_point=...)["data"] +client.secrets.kv.v2.read_secret_version(path=..., mount_point=...)["data"]["data"] +client.secrets.kv.v1.create_or_update_secret(path=..., secret=..., mount_point=...) +client.secrets.kv.v2.create_or_update_secret(path=..., secret=..., mount_point=...) +hvac.exceptions.InvalidPath +``` + +### Does NOT Exist +- ~~`hvac.AsyncClient`~~ / any async hvac API: use `asyncio.to_thread`. +- ~~`VaultReader.url` / `VaultReader.token`~~: not stored today. This task adds `_url` / `_token_fp` only. **Never** store the raw token on the instance beyond what `hvac.Client` already holds. +- ~~`AbstractReader.aget`~~: don't add it. + +--- + +## Complexity Contract + +```json +{ + "schema_version": 1, + "targets": [ + {"path": "navconfig/readers/vault.py", "action": "MODIFY"}, + {"path": "tests/test_vault_reader.py", "action": "CREATE"} + ], + "contract_symbols": [ + "sym:navconfig/readers/vault.py#VaultReader", + "sym:navconfig/readers/vault.py#VaultReader.__init__", + "sym:navconfig/readers/vault.py#VaultReader.get", + "sym:navconfig/readers/vault.py#VaultReader.exists", + "sym:navconfig/readers/vault.py#VaultReader.set", + "sym:navconfig/readers/vault.py#VaultReader.delete", + "sym:navconfig/readers/vault.py#VaultReader.list" + ] +} +``` + +--- + +## Implementation Notes + +### Key Constraints +- `_split_key`: `"a/b/KEY"` → `("a/b", "KEY")`. Both `"KEY"` and `"/KEY"` → `(self._env, "KEY")`. +- `_fetch_document` with an unsupported `self.version` raises `ValueError`, which propagates (not cached), matching `list()` today. +- Error logging: the transient errors that `get` and `exists` hit are logged at `logging.debug`, as today, and those in `list` at `logging.warning`. +- `aload(paths)`: + - `paths` defaults to `[self._env]`. + - It runs `asyncio.gather(*(asyncio.to_thread(self._read_document, p) for p in paths), return_exceptions=True)`. + - It returns `{path: isinstance(result, dict)}`. An exception is logged as a warning and maps to `False`. +- `invalidate(path=None)` drops entries through a predicate that matches this reader's `url`, `token_fp`, `mount` and `version`, plus `path` when given. +- `refresh(path=None)` calls `invalidate(path or self._env)`, then returns `self.list(path or self._env)`. +- `pyproject.toml` has `filterwarnings = error`. Use `@pytest.mark.asyncio` and make sure no coroutine is left unawaited. + +### Test fixture (decided) +```python +@pytest.fixture +def fake_hvac(monkeypatch): + """monkeypatch hvac.Client -> FakeClient with: + - is_authenticated() -> True + - secrets.kv.v2.read_secret_version(path, mount_point): counts reads per path, + returns {"data": {"data": deepcopy(store[path])}} or raises hvac.exceptions.InvalidPath + - secrets.kv.v2.create_or_update_secret(path, secret, mount_point): store[path] = dict(secret) + - an optional 'fail_next' flag that raises a generic Exception once (transient error) + Also sets VAULT_TOKEN / VAULT_URL / VAULT_ENV via monkeypatch.setenv.""" + +@pytest.fixture(autouse=True) +def fresh_cache(): + from navconfig.readers import vault_cache + vault_cache._reset_document_cache() + yield + vault_cache._reset_document_cache() +``` + +--- + +## Implementation Blueprint + +### Steps (in order) +1. Add the imports and the `_url`, `_token_fp` and `_cache` attributes in `__init__`. *Why*: the cache key needs them. +2. Add `_split_key`, `_cache_key`, `_fetch_document` and `_read_document`. *Why*: this gives a single hvac read site. +3. Rewrite `get`, `exists` and `list` over `_read_document`. *Why*: goal G1, and AC "1 read". +4. Rewrite `set` and `delete` with `_split_key` and write-through `put`. *Why*: unified parsing and cache coherency. +5. Add `invalidate`, `refresh` and the async methods. *Why*: goals G3 and G5. +6. Write the tests. + +### `navconfig/readers/vault.py` (MODIFY) +```python +# AFTER `from .abstract import AbstractReader` (verified: vault.py:6) +import asyncio +from typing import Dict, Iterable, Optional, Tuple, Union +from .vault_cache import MISSING, CacheKey, get_document_cache, token_fingerprint + +# In __init__, AFTER `self._env = os.getenv("VAULT_ENV") or env or os.getenv("ENV", "")` (vault.py:33) + self._url = url + self._token_fp = token_fingerprint(token or "") + self._cache = get_document_cache() + + def _split_key(self, key: str) -> Tuple[str, str]: + """Split 'a/b/KEY' on the LAST '/'; bare keys resolve to self._env.""" + # FILL IN + def _cache_key(self, path: str) -> CacheKey: + return CacheKey(self._url, self._token_fp, self._mount, self.version, path) + def _fetch_document(self, path: str) -> Union[dict, object]: + # FILL IN: v1/v2 read; InvalidPath -> MISSING; other version -> ValueError + def _read_document(self, path: str) -> Union[dict, object]: + return self._cache.get_or_load( + self._cache_key(path), lambda: self._fetch_document(path) + ) +``` + +### FILL IN checklist +- [ ] `get` / `exists` / `list`: keep their existing return contracts. Only the I/O path changes. +- [ ] `set` / `delete`: split on the last `/` and write the new document through to the cache. +- [ ] `aload`: `gather` with `return_exceptions=True`. It never raises. + +--- + +## Acceptance Criteria + +- [ ] `pytest tests/test_vault_reader.py -v` passes (10 tests). +- [ ] `exists()`, then `get()`, then `list()` on the same path results in exactly **1** `read_secret_version` call. +- [ ] Two `VaultReader()` instances with the same URL and token share reads. Different tokens don't. +- [ ] `list_paths` is unchanged, and `AbstractReader` is unchanged. +- [ ] The full `pytest` suite still passes. + +--- + +## Validation Commands + +- `pytest tests/test_vault_reader.py -q` +- `pytest tests/test_vault_cache.py -q` + +--- + +## Test Specification + +```python +# tests/test_vault_reader.py +def test_reader_get_exists_one_fetch(fake_hvac): ... +def test_reader_shared_cache_across_instances(fake_hvac): ... +def test_reader_different_token_not_shared(fake_hvac, monkeypatch): ... +def test_reader_missing_path_cached(fake_hvac): ... +def test_reader_transient_error_not_cached(fake_hvac): ... +def test_reader_split_key_last_slash(fake_hvac): ... +def test_reader_set_write_through(fake_hvac): ... +def test_reader_list_returns_copy(fake_hvac): ... +@pytest.mark.asyncio +async def test_reader_aget_aexists(fake_hvac): ... +@pytest.mark.asyncio +async def test_reader_aload_concurrent(fake_hvac): ... +``` + +--- + +## Agent Instructions + +1. Work in `.claude/worktrees/feat-FEAT-001-async-vault-read`. +2. Check that TASK-001 is `"done"` in `sdd/tasks/.index.json`. +3. Verify the contract line numbers still match, set this task to `"in-progress"`, implement, validate, and commit only the listed files. +4. Move this file to `sdd/tasks/completed/`, set its index status to `"done"`, and fill in the Completion Note. + +--- + +## Completion Note + +*(Agent fills this in when done)* + +**Completed by**: +**Date**: +**Notes**: + +**Deviations from spec**: diff --git a/sdd/tasks/active/TASK-003-kardex-vault-integration.md b/sdd/tasks/active/TASK-003-kardex-vault-integration.md new file mode 100644 index 00000000..4cf4a5e4 --- /dev/null +++ b/sdd/tasks/active/TASK-003-kardex-vault-integration.md @@ -0,0 +1,213 @@ +# TASK-003: Kardex integration (single-get lookup, __contains__ fix, async API) + +**Feature**: FEAT-001 — Vault Document Cache & Async Vault Read +**Spec**: `sdd/specs/async-vault-read.spec.md` +**Status**: pending +**Priority**: high +**Estimated effort**: M (2-4h) +**Depends-on**: TASK-002 +**Assigned-to**: unassigned + +--- + +## Context + +Implements spec §3 **Module 3**. `Kardex._get_external` currently calls +`reader.exists(key)` and then `reader.get(key)`, which is 2 remote reads. +`__contains__` returns after checking only the first reader. This task: +- collapses the lookup into one `get()` (goal G2); +- fixes `__contains__` (goal G7); +- exposes the async API and cache invalidation on the config object (goals G3 and G5). + +--- + +## Scope + +- `_get_external`: for each enabled reader, `val = reader.get(key)`, and return it if it isn't `None`. Keep `except RuntimeError: continue`, and never call `exists()`. +- `__contains__`: check environ and `_mapping_` first, then return `True` if **any** enabled reader's `exists(key)` is `True`. Catch `RuntimeError` per reader. +- Add these methods: + - `async aget(key, section=None, fallback=None)`: the same resolution order as `get()`. For the external step, use `await reader.aget(key)` when `hasattr(reader, "aget")`, and otherwise `await asyncio.to_thread(reader.get, key)`. Then apply `_unserialize`. + - `async aexists(key)`. + - `async aload_vault(paths=None)`: delegates to `self._readers["vault"].aload(paths)` when vault is enabled, and returns `{}` otherwise. + - `invalidate_vault_cache(path=None)`: delegates to the vault reader's `invalidate()`, and is a no-op otherwise. +- `reload_current_env`: call `self.invalidate_vault_cache()` before `set_env(..., reload=True)`. +- Write `tests/test_kardex_vault.py` covering the 4 M3 tests in spec §4. + +**NOT in scope**: +- The truthiness check in `Kardex.get` (`if val := self._get_external(key)`). Keep it as is. +- `set_env()` invalidation. The spec §8 decision is no. +- Loader changes. +- Docs. + +--- + +## Files to Create / Modify + +| File | Action | Description | +|---|---|---| +| `navconfig/kardex.py` | MODIFY | Single-get, `__contains__` fix, async API, invalidation | +| `tests/test_kardex_vault.py` | CREATE | Unit tests for M3 | + +--- + +## Codebase Contract (Anti-Hallucination) + +### Verified Imports +```python +import asyncio # navconfig/kardex.py:9 (already imported) +import logging # navconfig/kardex.py:12 +from .readers.vault import VaultReader # navconfig/kardex.py:37 (try/except; bound as HVAULT_LOADER) +``` + +### Existing Signatures to Use +```python +# navconfig/kardex.py +class Kardex: + _readers: dict = {} # line 51 — CLASS-level; "redis" may alias "cache" + _mapping_: dict = {} # line 52 + def _init_external_readers(self) # line 183; vault stored at self._readers["vault"] (line 217) + # self._use_vault: bool # line 214 + def _get_external(self, key: str) -> Any # line 396 + def get(self, key, section=None, fallback=None) -> Any # line 496 (resolution order to mirror) + def __contains__(self, key: str) -> bool # line 538 (BUG: returns inside loop) + def exists(self, key: str) -> bool # line 547 (uses _get_external) + def _unserialize(self, value: Any) -> str # line 583 + def reload_current_env(self) # line 790 + +# navconfig/readers/redis.py +class mredis(AbstractReader): + def get(self, key) # returns None on miss + def exists(self, key, *keys) # line 60 + +# navconfig/readers/vault.py (after TASK-002) +VaultReader.aget / aexists / aload / invalidate +``` + +### Does NOT Exist +- ~~`Kardex.aget` / `aexists` / `aload_vault` / `invalidate_vault_cache`~~: this task creates them. +- ~~`mredis.aget`~~: doesn't exist, so fall back to `asyncio.to_thread(reader.get, key)`. +- ~~`AbstractReader.aget`~~: don't add it. Use `hasattr`. + +--- + +## Complexity Contract + +```json +{ + "schema_version": 1, + "targets": [ + {"path": "navconfig/kardex.py", "action": "MODIFY"}, + {"path": "tests/test_kardex_vault.py", "action": "CREATE"} + ], + "contract_symbols": [ + "sym:navconfig/kardex.py#Kardex", + "sym:navconfig/kardex.py#Kardex._get_external", + "sym:navconfig/kardex.py#Kardex.get", + "sym:navconfig/kardex.py#Kardex.__contains__", + "sym:navconfig/kardex.py#Kardex.exists", + "sym:navconfig/kardex.py#Kardex._unserialize", + "sym:navconfig/kardex.py#Kardex.reload_current_env" + ] +} +``` + +--- + +## Implementation Notes + +### Key Constraints +- `Kardex` is a Singleton, and `_readers` is class-level. In tests, `monkeypatch.setitem(Kardex._readers, ...)` / `monkeypatch.setattr(config, "_readers", {...})` is enough to isolate them. Don't construct a new Kardex. +- Fake readers in the tests are plain objects with `enabled = True`, and with `get`/`exists` counters. That means TASK-003 tests don't need `fake_hvac`. +- Use `getattr(self, "_use_vault", False)` and `self._readers.get("vault")`, because `_init_external_readers` may not have set `vault`. +- `filterwarnings = error`: every coroutine must be awaited. + +--- + +## Implementation Blueprint + +### `navconfig/kardex.py` (MODIFY) +```python +# REPLACE body of `def _get_external(self, key: str) -> Any:` (kardex.py:396) + """Get a value from the external readers (a single get() per reader).""" + for _, reader in self._readers.items(): + try: + if reader.enabled is True: + val = reader.get(key) + if val is not None: + return val + except RuntimeError: + continue + return None + +# REPLACE body of `def __contains__(self, key: str) -> bool:` (kardex.py:538) + # FILL IN: environ/_mapping_ -> True; any(enabled reader.exists(key)) with RuntimeError guard + +# ADD after `def exists` (kardex.py:547) — async API + async def aget(self, key: str, section: Optional[str] = None, fallback: Any = None) -> Any: + # FILL IN: mirror get(); external step via reader.aget or asyncio.to_thread(reader.get, key) + async def aexists(self, key: str) -> bool: + # FILL IN + async def aload_vault(self, paths: Optional[Iterable[str]] = None) -> Dict[str, bool]: + # FILL IN + def invalidate_vault_cache(self, path: Optional[str] = None) -> None: + # FILL IN + +# MODIFY `def reload_current_env(self):` (kardex.py:790) + self.invalidate_vault_cache() + self.set_env(self._current_env, reload=True) +``` + +### FILL IN checklist +- [ ] Check that `Iterable` and `Dict` are imported in the `typing` block at `kardex.py:1`, and add them if missing. +- [ ] `aget`: identical resolution order to `get()`, including `section` handling. + +--- + +## Acceptance Criteria + +- [ ] `pytest tests/test_kardex_vault.py -v` passes (4 tests). +- [ ] With a fake reader, `config.get("X")` calls `reader.get` once and `reader.exists` never. +- [ ] `"X" in config` is `True` when only the second reader has X. +- [ ] The full `pytest` suite passes, including the existing `tests/test_config.py`. + +--- + +## Validation Commands + +- `pytest tests/test_kardex_vault.py -q` +- `pytest tests/test_config.py -q` + +--- + +## Test Specification + +```python +# tests/test_kardex_vault.py +def test_kardex_get_external_single_call(monkeypatch): ... +def test_kardex_contains_checks_all_readers(monkeypatch): ... +@pytest.mark.asyncio +async def test_kardex_aget_resolution_order(monkeypatch): ... +@pytest.mark.asyncio +async def test_kardex_aload_vault_disabled(monkeypatch): ... +``` + +--- + +## Agent Instructions + +1. Work in `.claude/worktrees/feat-FEAT-001-async-vault-read`. +2. Check that TASK-002 is `"done"` in `sdd/tasks/.index.json`. +3. Verify the contract, set this task to `"in-progress"`, implement, validate, and commit only the listed files. +4. Move this file to `sdd/tasks/completed/`, set its index status to `"done"`, and fill in the Completion Note. + +--- + +## Completion Note + +*(Agent fills this in when done)* + +**Completed by**: +**Date**: +**Notes**: + +**Deviations from spec**: diff --git a/sdd/tasks/active/TASK-004-vault-cache-docs-changelog.md b/sdd/tasks/active/TASK-004-vault-cache-docs-changelog.md new file mode 100644 index 00000000..b3e282c5 --- /dev/null +++ b/sdd/tasks/active/TASK-004-vault-cache-docs-changelog.md @@ -0,0 +1,119 @@ +# TASK-004: Docs + CHANGELOG for Vault cache & async API + +**Feature**: FEAT-001 — Vault Document Cache & Async Vault Read +**Spec**: `sdd/specs/async-vault-read.spec.md` +**Status**: pending +**Priority**: medium +**Estimated effort**: S (< 2h) +**Depends-on**: TASK-003 +**Assigned-to**: unassigned + +--- + +## Context + +Implements the documentation part of spec §3 **Module 4**, and closes the remaining +acceptance criteria in spec §5: +- `VAULT_CACHE_TTL` is documented; +- there's a CHANGELOG entry; +- a final end-to-end check that the full suite passes. + +--- + +## Scope + +- Document these in `docs/config.rst`, next to the existing Vault/`VAULT_*` settings (check with `grep -n VAULT docs/*.rst`): + - `VAULT_CACHE_TTL`: float seconds, default `300`, `0` means never expire. + - Negative caching: a newly created secret is invisible until the TTL passes, `set()` runs, or `invalidate()` is called. + - `invalidate_vault_cache()` and `reload_current_env()`. + - The async API: `await config.aload_vault([...])` from `on_startup`, and `await config.aget(...)` / `aexists(...)`. +- Add a CHANGELOG entry in an `## [Unreleased]` section at the top of `CHANGELOG.md`, in Keep a Changelog format: + - **Added**: the Vault document cache, `VAULT_CACHE_TTL`, the async API, and `invalidate`/`refresh`. + - **Changed**: + - `_get_external` makes a single read. + - `VaultReader.set`/`delete` now split nested keys on the **last** `/`. This is a behavior change for `a/b/KEY`. + - **Fixed**: `Kardex.__contains__` now checks all readers. +- Run the full `pytest` suite and save the output to `artifacts/logs/FEAT-001-pytest.log`. + +**NOT in scope**: +- Any code change. +- Version bumps. `navconfig/version.py` says `2.5.2`, but `CHANGELOG.md` already has `[3.0.0]`. **Flag this mismatch to the user** and let the release decide the version, so keep the entry as `[Unreleased]`. + +--- + +## Files to Create / Modify + +| File | Action | Description | +|---|---|---| +| `docs/config.rst` | MODIFY | Vault cache + async API docs | +| `CHANGELOG.md` | MODIFY | Unreleased entry | + +--- + +## Codebase Contract (Anti-Hallucination) + +### Verified Imports +None. This task is docs only. + +### Existing Signatures to Use +```text +CHANGELOG.md:1 "# Changelog"; Keep a Changelog format; latest section "## [3.0.0] - 2026-08-21" (line 8) +docs/config.rst existing reStructuredText config reference +navconfig/version.py __version__ = "2.5.2" +``` + +### Does NOT Exist +- ~~`docs/vault.rst`~~: doesn't exist. Don't create a new page unless `docs/config.rst` has no Vault section, and if you do, add it to the `docs/index.rst` toctree. + +--- + +## Complexity Contract + +```json +{ + "schema_version": 1, + "targets": [ + {"path": "docs/config.rst", "action": "MODIFY"}, + {"path": "CHANGELOG.md", "action": "MODIFY"} + ], + "contract_symbols": [] +} +``` + +--- + +## Acceptance Criteria + +- [ ] `VAULT_CACHE_TTL` and the async API are documented in `docs/config.rst`. +- [ ] `CHANGELOG.md` has an `[Unreleased]` entry covering the cache, the async API, the `__contains__` fix, and the key-parsing change. +- [ ] The full `pytest` suite passes, and its log is saved to `artifacts/logs/FEAT-001-pytest.log`. +- [ ] Every checkbox in spec §5 is verified. + +--- + +## Validation Commands + +- `pytest tests/test_vault_cache.py tests/test_vault_reader.py tests/test_kardex_vault.py -q` +- `pytest -q` + +--- + +## Agent Instructions + +1. Work in `.claude/worktrees/feat-FEAT-001-async-vault-read`. +2. Check that TASK-003 is `"done"`. +3. Update the docs, run the full suite, and commit only the listed files. +4. Move this file to `sdd/tasks/completed/`, set its index status to `"done"`, and fill in the Completion Note. +5. Tell the user about the `version.py` / `CHANGELOG` version mismatch. + +--- + +## Completion Note + +*(Agent fills this in when done)* + +**Completed by**: +**Date**: +**Notes**: + +**Deviations from spec**: diff --git a/sdd/tasks/index/async-vault-read.json b/sdd/tasks/index/async-vault-read.json new file mode 100644 index 00000000..5036f8ff --- /dev/null +++ b/sdd/tasks/index/async-vault-read.json @@ -0,0 +1,85 @@ +{ + "feature": "async-vault-read", + "feature_id": "FEAT-001", + "spec": "sdd/specs/async-vault-read.spec.md", + "type": "feature", + "base_branch": "dev", + "created_at": null, + "completed_at": null, + "tasks": [ + { + "id": "TASK-001", + "slug": "vault-document-cache", + "title": "VaultDocumentCache module (TTL, negative entries, single-flight)", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "high", + "effort": "M", + "depends_on": [], + "parallel": false, + "parallelism_notes": "Foundation module, stdlib only; every later task imports it.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-001-vault-document-cache.md" + }, + { + "id": "TASK-002", + "slug": "vault-reader-cache-async", + "title": "VaultReader refactor (cache-through reads, write-through, async API)", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "high", + "effort": "L", + "depends_on": [ + "TASK-001" + ], + "parallel": false, + "parallelism_notes": "Imports vault_cache from TASK-001; modifies vault.py which TASK-003 depends on.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-002-vault-reader-cache-async.md" + }, + { + "id": "TASK-003", + "slug": "kardex-vault-integration", + "title": "Kardex integration (single-get lookup, __contains__ fix, async API)", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "high", + "effort": "M", + "depends_on": [ + "TASK-002" + ], + "parallel": false, + "parallelism_notes": "Calls VaultReader.aget/aload/invalidate added in TASK-002.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-003-kardex-vault-integration.md" + }, + { + "id": "TASK-004", + "slug": "vault-cache-docs-changelog", + "title": "Docs + CHANGELOG for Vault cache & async API", + "feature_id": "FEAT-001", + "feature": "async-vault-read", + "spec": "sdd/specs/async-vault-read.spec.md", + "status": "pending", + "priority": "medium", + "effort": "S", + "depends_on": [ + "TASK-003" + ], + "parallel": false, + "parallelism_notes": "Documents final API and runs full suite; must follow all code tasks.", + "assigned_to": null, + "started_at": null, + "file": "sdd/tasks/active/TASK-004-vault-cache-docs-changelog.md" + } + ] +} diff --git a/sdd/templates/brainstorm.md b/sdd/templates/brainstorm.md new file mode 100644 index 00000000..e66e92ab --- /dev/null +++ b/sdd/templates/brainstorm.md @@ -0,0 +1,228 @@ +--- +# SDD flow type and base branch (FEAT-145). +# - type: feature (default) → base_branch: dev (or any non-main branch) +# - type: hotfix → base_branch MUST be: main +type: feature +base_branch: dev +# projects: parts of the codebase this doc concerns. Use `packages/*` dir names +# (ai-parrot, ai-parrot-server, parrot-formdesigner, …) or an area +# (sdd-tooling, dev-loop, admin-ui, docs, ci). Unknown values warn, not fail. +projects: [] +# tags: free-form kebab-case keywords for organizing specs (e.g. memory, mcp). +tags: [] +--- + +# Brainstorm: + +**Date**: YYYY-MM-DD +**Author**: <name> +**Status**: exploration | accepted | rejected +**Recommended Option**: <Option Letter> + +--- + +## Problem Statement + +<!-- What problem are we solving? Who is affected? + Why is this needed now? Be specific about the pain point or opportunity. --> + +## Constraints & Requirements + +<!-- Hard constraints that any solution must satisfy. + e.g. performance targets, compatibility, budget, timeline, security. --> + +- Constraint 1 +- Constraint 2 + +--- + +## Options Explored + +### Option A: <Name> + +<!-- Describe this approach in detail. Focus on WHAT it does, not HOW to code it. --> + +✅ **Pros:** +- Benefit 1 +- Benefit 2 + +❌ **Cons:** +- Drawback 1 + +📊 **Effort:** Low | Medium | High + +📦 **Libraries / Tools:** +| Package | Purpose | Notes | +|---|---|---| +| `package-name` | What it does | version, maturity, etc. | + +🔗 **Existing Code to Reuse:** +- `path/to/module.py` — description of what to reuse + +--- + +### Option B: <Name> + +<!-- Describe this approach in detail. --> + +✅ **Pros:** +- Benefit 1 + +❌ **Cons:** +- Drawback 1 + +📊 **Effort:** Low | Medium | High + +📦 **Libraries / Tools:** +| Package | Purpose | Notes | +|---|---|---| +| `package-name` | What it does | version, maturity, etc. | + +🔗 **Existing Code to Reuse:** +- `path/to/module.py` — description of what to reuse + +--- + +### Option C: <Name> + +<!-- Describe this approach in detail. --> + +✅ **Pros:** +- Benefit 1 + +❌ **Cons:** +- Drawback 1 + +📊 **Effort:** Low | Medium | High + +📦 **Libraries / Tools:** +| Package | Purpose | Notes | +|---|---|---| +| `package-name` | What it does | version, maturity, etc. | + +🔗 **Existing Code to Reuse:** +- `path/to/module.py` — description of what to reuse + +--- + +## Recommendation + +**Option <X>** is recommended because: + +<!-- Explain the reasoning. Reference tradeoffs from the options above. + Be honest about what you're trading off. --> + +--- + +## Feature Description + +<!-- Detailed explanation of the feature as it would be built using the recommended option. + This should be thorough enough to feed directly into a spec. + Cover: user-facing behavior, internal behavior, edge cases, error handling. --> + +### User-Facing Behavior +<!-- What does the end user see or experience? --> + +### Internal Behavior +<!-- How does it work at a high level? No implementation code — describe flow and responsibilities. --> + +### Edge Cases & Error Handling +<!-- What happens when things go wrong? Boundary conditions? --> + +--- + +## Capabilities + +### New Capabilities +<!-- Capabilities being introduced. + Use kebab-case identifiers (e.g., user-auth, data-export). + Each accepted capability maps to a spec file at docs/sdd/specs/<name>.spec.md --> +- `<name>`: <brief description> + +### Modified Capabilities +<!-- Existing capabilities whose requirements change. + Use existing spec names from docs/sdd/specs/. Leave empty if none. --> + +--- + +## Impact & Integration + +<!-- Which existing components are affected? + Are there breaking changes? New dependencies? Deployment changes? + Consider: APIs, data models, configuration, CI/CD. --> + +| Affected Component | Impact Type | Notes | +|---|---|---| +| `component` | extends / modifies / depends on | ... | + +--- + +## Code Context + +<!-- CRITICAL: This section preserves verified code references so they survive + the brainstorm → spec → task pipeline and prevent hallucinations during implementation. + Include ONLY code that has been verified to exist in the current codebase. --> + +### User-Provided Code +<!-- Paste any code snippets the user provided during brainstorming. + Tag each with its source (file path if known, or "user-provided"). --> + +```python +# Source: <file_path or "user-provided"> +# <paste code here> +``` + +### Verified Codebase References +<!-- Actual signatures, imports, and attributes discovered during codebase research. + Each entry MUST include the file path and line number where it was verified. --> + +#### Classes & Signatures +```python +# From parrot/path/to/file.py:NN +class ExistingClass(BaseClass): + attribute: Type # line NN + async def method(self, param: Type) -> ReturnType: # line NN + ... +``` + +#### Verified Imports +```python +# These imports have been confirmed to work: +from parrot.module import ClassName # parrot/module/__init__.py:NN +``` + +#### Key Attributes & Constants +<!-- List attributes/constants that tasks will need to reference --> +- `ClassName.attribute_name` → `Type` (parrot/path/file.py:NN) + +### Does NOT Exist (Anti-Hallucination) +<!-- List things that might seem like they should exist but DO NOT. + This prevents implementing agents from assuming these are available. --> +- ~~`parrot.module.NonExistentThing`~~ — does not exist +- ~~`ClassName.phantom_attribute`~~ — not a real attribute + +--- + +## Parallelism Assessment + +<!-- Evaluate the feature's decomposition potential for parallel development. + This informs sdd-spec's worktree strategy section. --> + +- **Internal parallelism**: <!-- Can this feature's tasks be split into independent worktrees? --> +- **Cross-feature independence**: <!-- Does this feature conflict with any in-flight specs? List shared files or modules. --> +- **Recommended isolation**: <!-- per-spec | mixed --> +- **Rationale**: <!-- Brief explanation of why the recommended isolation makes sense. --> + +--- + +## Open Questions + +<!-- Anything unresolved. Each should have an owner if possible. + Convention (important — consumed by /sdd-spec): + [ ] unresolved question — *Owner: name* + [x] resolved question — *Owner: name*: <answer text> + When you resolve a question, flip the checkbox to [x] and append the + answer after the final `:` on the owner line. /sdd-spec carries these + forward into the spec body instead of re-asking them. --> +- [ ] Question 1 — *Owner: name* +- [ ] Question 2 — *Owner: name* diff --git a/sdd/templates/design_research.prompt.md b/sdd/templates/design_research.prompt.md new file mode 100644 index 00000000..d2fc667f --- /dev/null +++ b/sdd/templates/design_research.prompt.md @@ -0,0 +1,50 @@ +<!-- + sdd/templates/design_research.prompt.md — neutral design-research brief (FEAT-545). + Rendered by /sdd-spec section 3b and piped to `codex exec ... --output-schema + design_research.schema.json`. + FORBIDDEN INPUTS: never paste the spec draft, the spec author's reasoning, a preferred + conclusion, or any text written by the model that will author the spec. The brief carries + ONLY the accepted exploration document (brainstorm/proposal) and verified code anchors. + Placeholders (double-curly-brace tokens, deliberately NOT written with literal braces in + this comment — a renderer that does a naive whole-document string replace must not also + rewrite this sentence): problem_statement, constraints_and_goals, + recommended_option_or_scope, code_context_paths, open_questions, question. +--> +# Independent design review — read-only + +You are an independent design reviewer for **ai-parrot**, an async-first Python +framework for AI agents (aiohttp, Pydantic v2, `uv` workspace under `packages/`). +You have read-only access to the repository in your working directory. + +## Rules +1. Read the code you cite. Every `affected_paths` entry must be a repo-relative + path you actually opened; suggestions with unverifiable paths are discarded. +2. Judge the design intent below against what exists in the repository: what is + missing, what is risky, what would be simpler, what the codebase already + provides that the intent re-invents. +3. Do not restate the intent, do not praise it, do not write code. Propose at + most 12 concrete, falsifiable suggestions, each tagged with a kind + (`architecture` | `api` | `testing` | `risk` | `alternative`), a risk level and + your confidence. +4. Output exactly ONE JSON object conforming to the schema you were given — no + markdown fences, no prose before or after. + +## Accepted design intent (verbatim from the exploration document) + +### Problem statement +{{problem_statement}} + +### Constraints and goals +{{constraints_and_goals}} + +### Recommended option / probable scope +{{recommended_option_or_scope}} + +### Verified code anchors (paths only — open them yourself) +{{code_context_paths}} + +### Questions still open in the exploration document +{{open_questions}} + +## Question +{{question}} diff --git a/sdd/templates/design_research.schema.json b/sdd/templates/design_research.schema.json new file mode 100644 index 00000000..d623ad19 --- /dev/null +++ b/sdd/templates/design_research.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SDD design research suggestions (FEAT-545)", + "type": "object", + "required": ["summary", "suggestions"], + "additionalProperties": false, + "properties": { + "summary": { + "type": "string", + "description": "At most three sentences: how the reviewer read the brief and what it focused on." + }, + "suggestions": { + "type": "array", + "maxItems": 12, + "items": { + "type": "object", + "required": ["id", "kind", "title", "rationale", "affected_paths", "risk", "confidence"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^S[0-9]{1,2}$" }, + "kind": { "type": "string", "enum": ["architecture", "api", "testing", "risk", "alternative"] }, + "title": { "type": "string", "maxLength": 120 }, + "rationale": { "type": "string", "description": "Why this matters and what evidence in the repo supports it." }, + "affected_paths": { "type": "array", "items": { "type": "string" }, "description": "Repo-relative paths the reviewer actually opened. Unverifiable paths cause the suggestion to be rejected." }, + "risk": { "type": "string", "enum": ["low", "medium", "high"] }, + "confidence": { "type": "string", "enum": ["low", "medium", "high"] } + } + } + } + } +} diff --git a/sdd/templates/finding.md b/sdd/templates/finding.md new file mode 100644 index 00000000..d7c6dc23 --- /dev/null +++ b/sdd/templates/finding.md @@ -0,0 +1,88 @@ +--- +id: F001 +query_id: Q001 +type: grep | glob | read | git_log | tree +intent: <copied from the query's intent> +executed_at: YYYY-MM-DDTHH:MM:SSZ +duration_ms: 234 +parent_id: null # set if this finding came from a recursive follow-up +depth: 0 +--- + +# F001 — <one-line title derived from query intent> + +## Summary + +<2-4 sentences. What did this query reveal? Be concrete and information-dense. +The synthesis agent will read MANY findings; brevity matters.> + +## Citations + +> **Format rule**: every path/symbol the synthesis agent might reference must +> appear here. The lint phase rejects synthesis output that names paths absent +> from any finding's citations. + +- path: `app/modules/nextstop/pdf.py` + lines: 47-89 + symbol: `generate_pdf` + excerpt: | + async def generate_pdf(trip: Trip) -> bytes: + """Generate PDF for a trip.""" + chunks = await self._build_chunks(trip) + rendered = await render(chunks) # <-- await on sync render + return rendered + +- path: `app/modules/nextstop/pdf.py` + lines: 142-160 + symbol: `render_chunk` + excerpt: | + def render_chunk(chunk: Chunk) -> bytes: + ... + +## Notes + +<Optional: anything not fitting into Citations. Cross-references to other +findings, edge cases observed, hypotheses spawned. Keep brief.> + +--- + +<!-- =================== TEMPLATE GUIDE =================== --> +<!-- +Type-specific content guidelines: + +## type: grep +Summary: how many matches, where they cluster, what symbols dominate. +Citations: 5-15 representative match locations. Don't dump all matches. + +## type: glob +Summary: how many files, breakdown by directory. +Citations: full file list (tree-style if helpful). + +## type: read +Summary: 2-3 sentence description of what the file does. +Citations: line ranges for the most important blocks (function defs, +class definitions, key constants). Excerpts ≤10 lines each. + +## type: git_log +Summary: how many commits, who authored, time range, dominant theme. +Citations: per-commit entry with SHA, date, author, message, files touched. +Optional: include diff hunks for the most relevant commits if --with_diff. + +## type: tree +Summary: top-level structure observed. +Citations: directory tree, max 30 lines. + +## Content rules + +1. NEVER paste the full content of a read file. Excerpts only. +2. Excerpts ≤10 lines each. If more is needed, that's a sign you should split + into multiple targeted reads. +3. Always preserve line numbers — they're how the synthesis agent grounds + claims spatially. +4. If a query returned NOTHING (no matches, empty file, etc.), still produce + a finding — set Summary to "no matches found for <pattern>". This is + useful evidence (proves absence). +5. If a query FAILED (path doesn't exist, git error), record it under + Summary with the error and skip Citations. + +--> diff --git a/sdd/templates/intake.procedure.md b/sdd/templates/intake.procedure.md new file mode 100644 index 00000000..1cd28fa5 --- /dev/null +++ b/sdd/templates/intake.procedure.md @@ -0,0 +1,397 @@ +# /sdd-spec Intake Procedure (FEAT-577) + +> Single source for `/sdd-spec` intake mode. Both `/sdd-spec` twins point here +> from their §1.5. Outputs a confirmed `intake.json` (validated by +> `sdd/templates/intake.schema.json`) and, for `--research full`, a +> `synthesis.json`, then hands back to `/sdd-spec` §2d. + +## 0. When this runs + +The intake mode is triggered by the following rule (bounded by G1, G11): + +``` +if --resume: → resume intake (G6) +elif --no-interview: → today's path +elif --interview: → intake (warn and ignore if an exploration doc exists: carry-forward wins) +elif no exploration doc and no `--` notes: → intake +else: → today's path +``` + +- **Resume** (`--resume`): resumes an interrupted intake run from the staging directory. +- **No interview** (`--no-interview`): skips intake and follows today's path (used by unattended agents). +- **Interview** (`--interview`): forces intake mode. If an exploration doc (brainstorm or proposal) already exists, warn the user and let the carry-forward path win. +- **Auto-trigger**: when no exploration doc exists AND no `--` notes were given, intake mode starts automatically. + +**Agent fallback**: An agent that cannot ask the user anything (running as a subagent, no interactive question tool) must behave as `--no-interview` even without the flag. This is a defensive fallback on top of G11. + +## 1. Staging + +Create a staging directory for the intake run: + +```bash +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +# If slug is not yet known, stage under _pending- +STAGE="sdd/state/.intake/<slug>-${RUN_ID}" +# Once slug is confirmed, rename: mv "sdd/state/.intake/_pending-${RUN_ID}" "sdd/state/.intake/<slug>-${RUN_ID}" +mkdir -p "$STAGE" +``` + +The staging directory is **git-ignored** (add `sdd/state/.intake/` to `.gitignore` if not already present). + +Initialize `intake.json` with phase `started`: + +```json +{ + "schema_version": "1.0", + "run_id": "<RUN_ID>", + "feature_slug": null, + "feat_id": null, + "started_at": "<ISO timestamp>", + "updated_at": "<ISO timestamp>", + "phase": "started", + "flow": {"type": "feature", "base_branch": "dev"}, + "research": { + "depth": "full", + "gate": true, + "budget": "default", + "degraded_from": null, + "failure_reason": null, + "synthesis_path": null, + "handoff_declined": false + }, + "answers": { + "feature_name": "", + "projects": [], + "tags": [], + "overview": "", + "problem": "", + "why_important": "", + "jira": {"mode": "none", "key": null} + }, + "rounds": [], + "spec_path": null, + "errors": [] +} +``` + +**Staging retention (G13)**: The staging directory is git-ignored and pruned after 10 days by a daily git hook (installed separately by TASK-3473/TASK-3476). The hook runs on `post-checkout`, `post-merge`, and `post-commit`. `/sdd-status` remains read-only. + +## 2. Round 0 + fixed intake batch + +Ask one batch of questions to collect the core feature information. Use an interactive question tool when available; otherwise ask in plain text. + +**Questions in order:** + +1. **Flow type and base branch**: `type: feature | hotfix`, `base_branch: dev | main | staging` + - Default: `feature` / `dev` + - If `type: hotfix`, enforce `base_branch: main` + +2. **Feature name / slug**: The kebab-case slug for the feature. + - Validate: does `sdd/specs/<slug>.spec.md` already exist? If yes, abort with a pointer to the existing spec. + - Validate: does `sdd/proposals/<slug>.brainstorm.md` or `<slug>.proposal.md` exist? If yes, stop intake and fall back to the carry-forward path (the exploration doc wins). + +3. **Related project(s)**: Select from `KNOWN_PROJECTS` (FEAT-576) if available. Also allow free text, normalized with `normalize_project`. If FEAT-576 is not importable, use free text as fallback. + +4. **Overview**: Free-text description of the feature. + +5. **Problem being solved**: What user pain point or gap does this feature address? + +6. **Jira**: Choose one of: + - `existing`: Provide a Jira key (validate with regex `^[A-Z][A-Z0-9]+-\d+$`) + - `create`: Create a Jira ticket after the spec is committed + - `none`: No Jira ticket + +7. **Why it matters**: Business value or motivation for this feature. + +**Confirmation**: After collecting all answers, show a summary and ask the user to confirm or edit: + +``` +📋 Intake Summary — <feature-name> + +Type: <feature|hotfix> Base: <base_branch> +Project(s): <projects> +Jira: <existing KEY | create | none> + +Overview: <overview> + +Problem: <problem> + +Why it matters: <why_important> + +Confirm? [y / edit / abort] +``` + +- `y`: Proceed to research. +- `edit`: Re-open the specific question(s) the user wants to change. +- `abort`: Mark phase as `failed`, record the reason, and exit. + +Update `intake.json`: +- Set `phase` to `intake_confirmed` +- Populate `answers` with the confirmed values +- Set `feature_slug` to the confirmed slug +- Update `flow.type` and `flow.base_branch` +- Update `updated_at` + +This step is bounded by G2, G9. + +## 3. Research by depth + +Run research between the fixed batch and the adaptive rounds, at the depth specified by `--research` (default: `full`). + +### 3a. Write source.md + +Create `${STAGE}/source.md` with: + +```yaml +--- +kind: intake +fetched_at: <ISO timestamp> +summary_oneline: <one-line summary from intake answers> +--- +``` + +And the intake answers as body text. + +Also create `${STAGE}/state.json` per `sdd/templates/state.schema.json`: +- `feat_id: null` (intake mode keeps it null until §5) +- `source.kind: "intake"` +- `source.raw_path: "sdd/state/.intake/<slug>-<RUN_ID>/source.md"` + +### 3b. Full research (depth=full) + +Dispatch a research subagent that follows `.claude/commands/sdd-proposal.md` Phases 1–3 verbatim: + +1. **Phase 1**: Generate research plan using `sdd/templates/research_plan.prompt.md`. Present the gate to the user unless `--no-gate` is set. +2. **Phase 2**: Execute the plan, writing findings to `findings/F*.md` using `sdd/templates/finding.md`. +3. **Phase 3**: Run synthesis using `sdd/templates/synthesis.prompt.md`, producing `synthesis.json`. + +**Critical**: The brief for the research subagent must: +- Set `feat_id: null` (the synthesis should not allocate an ID) +- Instruct it to return only the `synthesis.json` path when done +- Pass the `--budget` flag value (default `default`) to control research depth + +**Budget table** (from `.claude/commands/sdd-proposal.md`): + +| `--budget` | files_read | grep_calls | git_calls | depth | wall_seconds | +|------------|-----------|-----------|----------:|------:|-------------:| +| `tight` | 15 | 10 | 5 | 1 | 120 | +| `default` | 40 | 25 | 10 | 2 | 300 | +| `loose` | 100 | 60 | 20 | 3 | 900 | + +**Failure handling**: If the research subagent fails or times out: +- Degrade to `light` research +- Set `research.degraded_from: "full"` +- Set `research.failure_reason` to the error message +- Continue with the intake (never abort the spec) + +Update `intake.json`: +- Set `phase` to `research_running`, then `research_complete` (or `research_degraded`) +- Set `research.synthesis_path` to `"synthesis.json"` when full succeeds +- Update `updated_at` + +### 3c. Light research (depth=light) + +Run today's §4 scan (codebase contract build) immediately: +- Grep/glob for relevant files based on the intake answers +- Write findings to `findings/F*.md` digests +- No synthesis is produced + +### 3d. No research (depth=none) + +Skip research entirely. §4 will build the codebase contract later from scratch. + +This step is bounded by G3. + +## 3b. Brainstorm hand-off offer + +This step runs only when: +- `research.depth == "full"` AND +- `synthesis.json.recommended_next_command.command == "sdd-brainstorm"` + +Show the rationale from the synthesis and offer the user a choice: + +``` +🧠 Research recommends `/sdd-brainstorm` + +Rationale: <synthesis.recommended_next_command.rationale> + +The research found competing architectural approaches that benefit from +exploration before committing to a spec. + +Switch to `/sdd-brainstorm` (seeded with this intake), or continue to the spec? + +[s] Switch to brainstorm [c] Continue to spec +``` + +- **Switch (`s`)**: + - Set `intake.json.phase = "handed_off"` + - Set `research.handoff_declined = false` + - Stop `/sdd-spec` (no §2d–§6, no FEAT-ID) + - Print: `Run: /sdd-brainstorm <slug> -- intake: <STAGE>` + - The staging dir stays in place and is pruned under G13 + +- **Continue (`c`)**: + - Set `research.handoff_declined = true` + - Proceed to adaptive rounds + +**Other recommended commands**: +- `manual-review`: Print the rationale as a warning and continue (no switch offered) +- `sdd-spec`, `sdd-task`, `light`, `none`: No offer is made + +This step is bounded by G12. + +## 4. Adaptive rounds (2–4) + +Run 2–4 rounds of targeted Q&A, each collecting 3–5 questions. + +**Question sources**: +- **Gap**: Questions about information missing from the intake answers +- **Synthesis**: Questions derived from `synthesis.unknowns` and competing hypotheses (when research was `full`) + +**Question format** (recorded in `intake.json.rounds`): + +```json +{ + "round": 1, + "questions": [ + { + "id": "Q1", + "question": "<question text>", + "source": "gap | synthesis", + "answer": "<user answer> | null" + } + ] +} +``` + +**Stop conditions**: +- After round 2: if no critical gap remains, stop early +- After round 4: any remaining unresolved items go to spec §8 as `[ ]` + +**Critical gap definition**: A gap that would materially affect the spec's architectural decisions or acceptance criteria. + +Update `intake.json`: +- Append each round to `rounds` array +- Set `phase` to `rounds_complete` when done +- Update `updated_at` + +This step is bounded by G4. + +## 5. Hand-off to /sdd-spec §2d–§6 + +Once intake is complete, hand off to the normal `/sdd-spec` pipeline: + +### 5a. Resolve flow + +Call `resolve_flow(doc_path=None, type_override, base_branch_override)` with the values from Round 0: +- `type_override` = `intake.json.flow.type` +- `base_branch_override` = `intake.json.flow.base_branch` + +### 5b. Spec mapping + +Map intake data to spec sections: + +| Intake source | Spec target | +|---|---| +| problem | §1 Problem Statement | +| why it matters | §1 Goals (business value) + Problem Statement motivation | +| overview | §2 Overview seed | +| projects / suggested tags | frontmatter `projects:` / `tags:` (FEAT-576) | +| Jira existing key | header `**Jira**: [KEY](<instance>/browse/KEY)` | +| synthesis `localization`, `constraints` | §6 Codebase Contract seeds (re-verified in §4) + §7 Known Risks | +| synthesis `unknowns` not answered in rounds | §8 `[ ]` | +| answered adaptive questions | route into the section they decide, plus §8 `[x] … — *Resolved in intake*: <answer>` | + +### 5c. §3b in intake mode + +The codex design-research pass runs with the extended precondition: +- "exploration doc with status `accepted`" **OR** `intake.json.phase >= rounds_complete` + +Brief sources in intake mode: +- `problem_statement.txt` ← intake problem + why it matters +- `constraints_and_goals.txt` ← synthesis `constraints` (or "none") +- `recommended_option_or_scope.txt` ← intake overview + answered adaptive questions +- `code_context_paths.txt` ← synthesis `localization[].path` (or `light` findings' paths) +- `open_questions.txt` ← unresolved items + +Forbidden-content rule unchanged: nothing from the spec draft goes in. + +### 5d. §4 seeded by synthesis + +The codebase contract build is seeded by: +- `synthesis.localization[].path` for code context +- `synthesis.constraints` for known constraints +- Re-verify all paths in §4 + +### 5e. §5 reserve FEAT-ID + +Reserve the FEAT-ID as usual via `scripts/sdd/reserve_ids.py`. Write the reserved ID to `intake.json.feat_id`. + +### 5f. §6 promote and commit + +Promote the staging directory: +```bash +# Create the final directory +mkdir -p "sdd/state/<FEAT-ID>/intake" +# Move (not copy) contents +mv "sdd/state/.intake/<slug>-<RUN_ID>"/* "sdd/state/<FEAT-ID>/intake/" +# Remove now-empty staging dir +rmdir "sdd/state/.intake/<slug>-<RUN_ID>" 2>/dev/null || true +``` + +**No overwrite**: If `sdd/state/<FEAT-ID>/intake/` already exists, abort with an error. + +Set `intake.json.phase = "committed"` and commit the spec along with the intake directory. + +This step is bounded by G5, G7. + +## 6. Jira + +After the spec is committed (§6): + +- **Existing key**: Stamp `**Jira**: [KEY](<instance>/browse/KEY)` in the spec header. +- **Create**: Run `/sdd-tojira sdd/specs/<slug>.spec.md`. It commits its own stamp. On failure, report the error and print the manual command. The spec stays committed. +- **None**: Do nothing. + +This step is bounded by G8. + +## 7. Resume + +Resume an interrupted intake run: + +```bash +/sdd-spec <slug> --resume +# or +/sdd-spec --resume <staging-dir> +``` + +**Selection logic**: +1. Find the newest `sdd/state/.intake/<slug>-*/intake.json` for the given slug +2. Filter to those where `phase` is NOT `committed` and NOT `handed_off` +3. If no staging dir is given, use the newest matching one + +**Validation**: +- Validate `intake.json` against `sdd/templates/intake.schema.json` +- If invalid, report the error and offer to start fresh + +**Resume point**: +- Continue from the first incomplete phase +- If `phase == "research_complete"` or `"research_degraded"`, do NOT re-run research +- If `phase == "research_running"`, resume with `/sdd-proposal` Step R semantics against the staged `state.json` +- If `phase == "rounds_complete"` or later, continue from there + +**No resume found**: Report "nothing to resume for this slug" and suggest `--interview`. + +**FEAT-ID resume**: Reject `--resume FEAT-<NNN>` with a message explaining that intake has no FEAT-ID until §5. + +**Pruned runs**: If the staging directory was pruned (older than 10 days), it's gone — the user must start fresh with `--interview`. + +This step is bounded by G6, G13. + +## 8. Failure handling + +- **Research failures**: Never abort the spec. Degrade gracefully (full → light → none) and continue. +- **Invalid intake.json on resume**: Report the validation error and offer to start fresh. +- **Unrecoverable errors**: Set `phase = "failed"` and populate `errors[]` with descriptive messages. + +This step is bounded by spec §2 and §7 Known Risks. \ No newline at end of file diff --git a/sdd/templates/intake.schema.json b/sdd/templates/intake.schema.json new file mode 100644 index 00000000..088c8faf --- /dev/null +++ b/sdd/templates/intake.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "sdd/templates/intake.schema.json", + "title": "SDD /sdd-spec intake record", + "description": "One file per intake run: sdd/state/.intake/<slug>-<RUN_ID>/intake.json, promoted to sdd/state/<FEAT-ID>/intake/intake.json by /sdd-spec §6 (FEAT-577).", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "run_id", "started_at", "updated_at", "phase", "flow", "research", "answers", "rounds"], + "properties": { + "schema_version": {"const": "1.0"}, + "run_id": {"type": "string", "minLength": 1}, + "feature_slug": {"type": ["string", "null"], "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"}, + "feat_id": {"type": ["string", "null"], "pattern": "^FEAT-[0-9]{3,}$"}, + "started_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "phase": {"enum": ["started", "intake_confirmed", "research_running", "research_complete", "research_degraded", "rounds_complete", "spec_drafted", "committed", "handed_off", "failed"]}, + "flow": { + "type": "object", "additionalProperties": false, "required": ["type", "base_branch"], + "properties": {"type": {"enum": ["feature", "hotfix"]}, "base_branch": {"type": "string", "minLength": 1}} + }, + "research": { + "type": "object", + "additionalProperties": false, + "required": ["depth", "gate", "budget", "degraded_from", "failure_reason", "synthesis_path", "handoff_declined"], + "properties": { + "depth": {"enum": ["full", "light", "none"]}, + "gate": {"type": "boolean"}, + "budget": {"enum": ["tight", "default", "loose"]}, + "degraded_from": {"enum": [null, "full"]}, + "failure_reason": {"type": ["string", "null"]}, + "synthesis_path": {"type": ["string", "null"]}, + "handoff_declined": {"type": "boolean"} + } + }, + "answers": { + "type": "object", + "additionalProperties": false, + "required": ["feature_name", "projects", "tags", "overview", "problem", "why_important", "jira"], + "properties": { + "feature_name": {"type": "string"}, + "projects": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "overview": {"type": "string"}, + "problem": {"type": "string"}, + "why_important": {"type": "string"}, + "jira": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "key"], + "properties": { + "mode": {"enum": ["existing", "create", "none"]}, + "key": {"type": ["string", "null"], "pattern": "^[A-Z][A-Z0-9]+-[0-9]+$"} + } + } + } + }, + "rounds": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["round", "questions"], + "properties": { + "round": {"type": "integer", "minimum": 1}, + "questions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "question", "source", "answer"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "question": {"type": "string"}, + "source": {"enum": ["gap", "synthesis"]}, + "answer": {"type": ["string", "null"]} + } + } + } + } + } + }, + "spec_path": {"type": ["string", "null"]}, + "errors": {"type": "array", "items": {"type": "string"}} + } +} diff --git a/sdd/templates/proposal.md b/sdd/templates/proposal.md new file mode 100644 index 00000000..72221f3c --- /dev/null +++ b/sdd/templates/proposal.md @@ -0,0 +1,253 @@ +--- +id: FEAT-XXX +title: <one-line title — replace with synthesis.summary_oneline> +slug: <kebab-case-slug> +type: feature | hotfix | bug-investigation +mode: investigation | enrichment +status: discussion | review | accepted +source: + kind: jira | inline | file + jira_key: NAV-XXXX | null + jira_url: https://trocglobal.atlassian.net/browse/NAV-XXXX | null + fetched_at: YYYY-MM-DD + summary_oneline: <≤120 chars> +overall_confidence: high | medium | low +base_branch: dev +# projects: parts of the codebase this doc concerns. Use `packages/*` dir names +# (ai-parrot, ai-parrot-server, parrot-formdesigner, …) or an area +# (sdd-tooling, dev-loop, admin-ui, docs, ci). Unknown values warn, not fail. +projects: [] +# tags: free-form kebab-case keywords for organizing specs (e.g. memory, mcp). +tags: [] +research_state: sdd/state/FEAT-XXX/ +created: YYYY-MM-DD +updated: YYYY-MM-DD +--- + +# FEAT-XXX — <title> + +> **Mode**: investigation | enrichment +> **Confidence**: high | medium | low +> **Source**: [NAV-XXXX](url) — *or* `inline` — *or* `file: path/to/notes.md` +> **Audit**: [`sdd/state/FEAT-XXX/`](../state/FEAT-XXX/) + +--- + +## 0. Origin + +The original request, preserved verbatim. The full source (with Jira metadata, +comments, etc.) is at `sdd/state/FEAT-XXX/source.md`. + +> <verbatim quote of source — for Jira, use the description; for inline, the +> input string; for file, an excerpt with elision marks if long> + +**Initial signals** (extracted, not interpreted): +- Verbs: <e.g. "no genera" → suggests bug> +- Named entities: <e.g. "Nextstop", "PDF"> +- Components / labels: <from Jira metadata> +- Acceptance criteria provided: <yes / no / count> + +--- + +## 1. Synthesis Summary + +<One paragraph, ≤6 sentences. States the request in our own words, names the +likely affected area, and previews the recommendation. Every named path or +symbol here MUST also appear in §2.1 (with finding citations).> + +--- + +## 2. Codebase Findings + +> All entries in this section are grounded in the research findings persisted +> at `sdd/state/FEAT-XXX/findings/`. Each cites the finding ID(s) that justify +> its inclusion. **No fabricated paths or symbols.** + +### 2.1 Localization + +The code areas relevant to this request: + +| # | Path | Symbol | Lines | Role | Evidence | +|---|------|--------|-------|------|----------| +| 1 | `app/modules/nextstop/pdf.py` | `generate_pdf` | 47-89 | entrypoint orchestrating PDF rendering | F001, F003 | +| 2 | `app/modules/nextstop/pdf.py` | `render_chunk` | 142-160 | per-section render helper | F003 | +| 3 | `libs/pdf_renderer.py` | `render` | 12-58 | shared sync renderer (used elsewhere) | F004 | + +> If localization is empty or thin, that itself is a finding — record it +> in §4 Confidence Map as a low-confidence claim. + +### 2.2 Constraints Discovered + +Conventions, contracts, or downstream callers any solution must respect. + +- **Shared sync renderer.** `libs/pdf_renderer.render` is called from at least + 3 other modules (legacy reports, invoicing, audit) and is synchronous. + *Implication*: any async refactor in nextstop must not break sync callers. + *Evidence*: F004 + +- **Test isolation.** PDF tests are skipped under `pytest -m "not pdf"` in CI + by default; they only run on tagged builds. + *Implication*: regressions can land silently in `dev`. + *Evidence*: F006 + +- **Async-first convention.** AI-Parrot conventions require async public + methods; this module recently migrated. + *Implication*: rolling back to sync is not an option. + *Evidence*: F002 + +### 2.3 Recent History (Relevant) + +Commits on the affected paths in the last 30 days, ordered newest first. + +| Commit | When | Author | Message | Touched files | +|--------|------|--------|---------|---------------| +| `a3f4b2` | 2 days ago | @dev1 | refactor: switch to async PDF render | `app/modules/nextstop/pdf.py`, `tests/nextstop/test_pdf.py` | +| `e891cc` | 8 days ago | @dev2 | fix: PDF margins for landscape mode | `libs/pdf_renderer.py` | + +> If history is empty, state so explicitly. Absence of recent activity is itself +> useful evidence (rules out recent-change regression). + +--- + +## 3. Hypothesis *(if mode = investigation)* + +> Replace this entire section with §3-Scope below if mode = enrichment. + +### Hypothesis 1 — <one-line statement> · Confidence: medium + +**Supporting evidence**: F002, F005 +**Contradicting evidence**: — +**Reasoning**: The async refactor in `a3f4b2` added `await` to a function +(`libs.pdf_renderer.render`) that is still synchronous; it now returns a +coroutine that the caller never awaits. The test in F005 was skipped at the +same commit, which would have caught the regression. + +**Suggested next probe**: +```bash +pytest tests/nextstop/test_pdf.py::test_generate_pdf -s +# look for "coroutine 'render' was never awaited" warning +``` + +### Hypothesis 2 — <one-line statement> · Confidence: low + +<Repeat structure. Keep at most 3 hypotheses, ranked.> + +--- + +## 3. Probable Scope *(if mode = enrichment)* + +> Replace this entire section with §3-Hypothesis above if mode = investigation. + +### What's New + +- **<New module / function / endpoint>** — <one-line role> + +### What Changes + +- **`<path>`::<symbol>** — <change summary> *Evidence*: F00X + +### What's Untouched (Non-Goals) + +Explicitly out of scope, to prevent later scope creep: +- <thing 1> +- <thing 2> + +### Patterns to Follow + +- <pattern from existing code> *Evidence*: F00X + +### Integration Risks + +- <risk>: <impact + mitigation> *Evidence*: F00X + +--- + +## 4. Confidence Map + +Every atomic claim in this proposal, with its evidence and confidence level. +Readers can audit the proposal by walking this table. + +| ID | Claim | Evidence | Confidence | Reasoning | +|----|-------|----------|------------|-----------| +| C1 | PDF entrypoint lives at `app/modules/nextstop/pdf.py:generate_pdf` | F001 | high | direct grep + read confirmation | +| C2 | The bug was introduced in commit `a3f4b2` | F002 | medium | timing aligns; no log/stack trace confirms causality | +| C3 | The shared `render` helper is sync | F004 | high | direct read of function signature | +| C4 | Test was skipped intentionally | — | low | inferred from absence of skip-reason comment | + +Distribution: **<H>** high, **<M>** medium, **<L>** low. + +> If `low` claims are critical to the conclusion, the overall confidence +> should reflect that — not be averaged up. + +--- + +## 5. Open Questions + +### Resolved (during proposal phase) + +- [x] **<question text>** — *Resolved*: <user's answer verbatim> + *Resolves claims*: C2 + +### Unresolved (defer to spec / implementation) + +- [ ] **<question text>** — *Owner*: <name | "tbd"> + *Blocks claims*: C4 + *Plausible answers*: a) intentional skip · b) accidental skip from refactor + +> If §5 has more than 5 unresolved entries, the research budget was likely +> too tight or the source too vague for a proposal — consider escalating to +> `/sdd-brainstorm` instead. + +--- + +## 6. Recommended Next Step + +**`/sdd-spec FEAT-XXX`** — *Rationale*: localization is high-confidence +(C1, C3) and the fix scope is well-bounded; no architectural fork to explore. + +### Alternatives + +- **`/sdd-brainstorm FEAT-XXX`** — if you want to explore alternative + architectural approaches (e.g., make `render` async vs. wrap with + `asyncio.to_thread()`). +- **`/sdd-task FEAT-XXX`** — if you accept the recommended fix as-is and + want a single task in the queue (suitable only for trivial localized fixes). +- **Manual review** — if research was truncated (`overall_confidence: low`) + or contradictions surfaced in findings. + +--- + +## 7. Research Audit + +Full state of the research session, for reproducibility and review. + +| Artifact | Path | +|----------|------| +| State checkpoints | `sdd/state/FEAT-XXX/state.json` | +| Source (raw) | `sdd/state/FEAT-XXX/source.md` | +| Research plan | `sdd/state/FEAT-XXX/research_plan.json` | +| Findings (digests) | `sdd/state/FEAT-XXX/findings/F001-*.md`, ... | +| Synthesis (JSON) | `sdd/state/FEAT-XXX/synthesis.json` | +| Synthesis reasoning | `sdd/state/FEAT-XXX/synthesis.thinking.log` (if persisted) | + +**Budget consumed**: +- Files read: 11 / 40 +- Grep calls: 9 / 25 +- Git calls: 3 / 10 +- Wall time: 215.6s / 300s +- Truncated: **no** + +**Mode determination**: `auto` → resolved to `investigation` (negation in +source: "no genera"). + +--- + +## 8. Provenance + +| Field | Value | +|-------|-------| +| Generated by | `/sdd-proposal v1.0` | +| Synthesis prompt | `sdd/templates/synthesis.prompt.md v1.0` | +| Plan prompt | `sdd/templates/research_plan.prompt.md v1.0` | +| Schema versions | state=1.0, synthesis=1.0, research_plan=1.0 | +| Operator | <user / agent name> | diff --git a/sdd/templates/research_plan.prompt.md b/sdd/templates/research_plan.prompt.md new file mode 100644 index 00000000..8699d712 --- /dev/null +++ b/sdd/templates/research_plan.prompt.md @@ -0,0 +1,323 @@ +<!-- sdd/templates/research_plan.prompt.md v1.0 --> + +# Role + +You are the Research Planner for the AI-Parrot SDD pipeline, Phase 1 of `/sdd-proposal`. + +Your job: read a sparse source (typically a one-line ticket like "Nextstop module +no genera el PDF") and produce a **research plan** — an ordered list of concrete +queries against the codebase that, when executed, will give the synthesis agent +enough evidence to produce a grounded proposal. + +You are NOT investigating yet. You are designing what to investigate. The +research executor will run your plan in Phase 2. + +You are also NOT asking the user questions. The whole point of `/sdd-proposal` +is research-first: the codebase is the primary source of truth, and the user is +only consulted later for genuine unknowns. + +--- + +# Input format + +You will receive a single user message containing: + +``` +<source> + <kind>jira | inline | file</kind> + <jira_key>NAV-XXXX | null</jira_key> + <raw>{full text of the ticket / brief / file}</raw> +</source> + +<budget> + <profile>tight | default | loose</profile> + <max_files_read>40</max_files_read> + <max_grep_calls>25</max_grep_calls> + <max_git_calls>10</max_git_calls> + <max_depth>2</max_depth> + <max_wall_seconds>300</max_wall_seconds> +</budget> + +<repo_root> + <files>{output of `ls -la` at repo root, truncated to ~50 entries}</files> + <dirs>{output of `find . -type d -maxdepth 3 -not -path './node_modules/*' -not -path './.git/*'`}</dirs> +</repo_root> + +<conventions> + {Optional: project-specific conventions, e.g. AI-Parrot module layout} +</conventions> +``` + +--- + +# Reasoning protocol + +Before producing JSON, reason step by step **inside `<thinking>` tags**. +The reasoning will be discarded — only the final JSON is consumed. + +## Step 1 — Extract signals from the source. + +Identify: +- **Named entities** — module names, file names, components mentioned literally + (e.g. "Nextstop", "PDF generator", "OAuth callback"). These are the highest- + value grep targets. +- **Verbs and their polarity** — "no genera", "doesn't work", "fails", + "broken" → bug-shaped intent. "Add", "support", "implement", "integrate" + → feature-shaped intent. +- **Implicit components** — if the ticket mentions a behavior, what subsystems + likely participate? (e.g. "PDF generation" implies a renderer, templates, + storage, possibly async I/O.) +- **Acceptance criteria, if present** — these define what "done" looks like + and inform what to verify in research. + +## Step 2 — Mode hint. + +Predict whether the synthesis agent will resolve mode to `investigation` or +`enrichment` based on the signals above. This biases your plan: +- **investigation** plans emphasize: locate the broken thing, find recent + changes (`git_log`), find tests (especially skipped ones), find error + paths. The hypothesis is built backward from symptoms. +- **enrichment** plans emphasize: find existing patterns to extend, find + integration points, find conventions to follow, find similar features + already implemented in the codebase. + +## Step 3 — Generate queries. + +Each query has a clear **intent** and one of these types: + +| Type | Use for | Counts against budget | +|----------------|------------------------------------------------------------|-----------------------| +| `wiki_query` | Semantic search for modules/subsystems in the knowledge graph | **free** (0) | +| `wiki_page` | Read a specific wiki page by ID (full summary + API outline) | **free** (0) | +| `wiki_related` | Follow typed edges to neighbouring files/modules | **free** (0) | +| `grep` | Locating named entities, finding usages of a symbol | grep_calls | +| `glob` | Listing files matching a pattern | grep_calls | +| `read` | Reading a specific file or line range | files_read | +| `git_log` | Recent commit history on a path | git_calls | +| `tree` | Listing the structure of a directory | grep_calls | + +Wiki queries use the ``wikitoolkit`` CLI (``wikitoolkit query``, +``wikitoolkit page``, ``wikitoolkit related``). They are **free** — they +do not count against any budget counter — and they return token-budgeted, +ranked results that are faster and more precise than broad grep sweeps. + +**Query design rules**: + +1. **Wiki first, grep second.** Always start with 1-2 `wiki_query` calls + to orient. Use the returned page IDs + scores to decide which modules + matter. Follow up with `wiki_page` or `wiki_related` for the top + results. Only then fall back to `grep`/`glob`/`read` for details the + wiki cannot answer (line-level content, exact symbol usages, runtime + config). If ``wikitoolkit`` is not available (wiki not built), skip + wiki queries and fall through to grep-first as before. +2. **Start broad, then narrow.** After wiki orientation, grep narrows to + specific symbols; later reads inspect them. Don't read files you + haven't located. +3. **Every query has a specific intent.** "Look around the codebase" is not + an intent. "Locate the entrypoint where Nextstop generates a PDF" is. +4. **No catch-all queries.** A grep for "pdf" alone will return hundreds of + hits. Pair it: `grep "generate_pdf|render_pdf"` or scope by path: + `grep "pdf" -- app/modules/nextstop/`. +5. **Budget-aware ordering.** Place high-value, low-cost queries first. + Wiki queries are free, so they always come first (priority 0). Then + grep before read, broad before deep. The executor stops when budget + runs out — your plan must front-load the most informative queries. +6. **Stay within budget.** Total queries should target ~70% of budget so + the executor has headroom for recursive follow-ups (depth > 1). + Wiki queries are excluded from budget arithmetic. + +**Query categories to consider** (not all required): + +- **Wiki orientation** (ALWAYS first): semantic search for the affected + module/subsystem. (1-2 wiki_query + 1-3 wiki_page/wiki_related) + This replaces the initial grep-based localization when the wiki is + available. The wiki returns file summaries, API outlines, and typed + relationships (contains, references) — use these to skip broad greps. +- **Localization**: where does the named entity live? Use wiki results + first; only add greps (1-3) if the wiki didn't surface the entity. +- **Functionality**: what symbols/functions implement the behavior? (1-3 greps) +- **Existing patterns**: are there similar features done elsewhere? (1-2 greps + or wiki_query for the pattern name) +- **Tests**: what test files exist for this area? (1 glob + 1 read) +- **Recent history**: what changed recently in the affected paths? (1-2 git_log) +- **Configuration**: are there settings/env vars relevant? (1 grep) +- **Callers / dependents**: who uses the affected symbols? (1-2 greps or + wiki_related to follow `references` edges) +- **Contracts**: are there public APIs or interfaces involved? (1-2 reads + or wiki_page for the module's API outline) + +## Step 4 — Order by priority. + +Assign each query a `priority` from 0 (wiki orientation) to 5 (lowest). +Priority 0: wiki queries — always run first, free, no budget cost. +Priority 1: queries that must run; without them, no synthesis is possible. +Priority 5: optional follow-ups that add color but aren't blocking. + +The executor walks priority 0 → 5. Wiki queries (priority 0) always +execute. Budget exhaustion only stops priority 1-5 queries. + +## Step 5 — Self-check. + +Before emitting JSON: + +- The plan starts with at least one `wiki_query` (priority 0) unless the + wiki is known to be unavailable. Wiki queries are free and do not count + toward budget limits. +- Total non-wiki query count ≤ ~70% of budget for each resource type. +- Every query has a non-empty `intent` that explains *why* this query. +- No two queries are duplicates (same type + same parameters). +- The plan addresses BOTH localization AND context, not just one. +- For investigation mode: at least one `git_log` query is included. +- For enrichment mode: at least one "existing patterns" grep or + `wiki_query` is included. + +--- + +# Output format + +After `</thinking>`, emit a single JSON object. No prose, no markdown fences. + +```json +{ + "schema_version": "1.0", + "mode_hint": "investigation", + "rationale": "Source contains 'no genera' (negation) → bug-shaped. Named entities: Nextstop, PDF.", + "queries": [ + { + "id": "Q001", + "intent": "Orient: find the Nextstop module and its relationships in the knowledge graph.", + "type": "wiki_query", + "priority": 0, + "params": { + "question": "Nextstop module PDF generation" + } + }, + { + "id": "Q002", + "intent": "Read the wiki page for the Nextstop module to get its API outline and file summary.", + "type": "wiki_page", + "priority": 0, + "params": { + "page_id": "<id from Q001 top result>" + } + }, + { + "id": "Q003", + "intent": "Discover modules that reference or are referenced by Nextstop.", + "type": "wiki_related", + "priority": 0, + "params": { + "page_id": "<id from Q001 top result>" + } + }, + { + "id": "Q004", + "intent": "Find PDF generation entrypoints — narrowed by wiki findings.", + "type": "grep", + "priority": 1, + "params": { + "pattern": "generate_pdf|render_pdf|create_pdf|build_pdf", + "path": ".", + "case_sensitive": false, + "max_results": 30 + } + }, + { + "id": "Q005", + "intent": "Recent commits touching the Nextstop module — looks for regressions.", + "type": "git_log", + "priority": 1, + "params": { + "path": "app/modules/nextstop/", + "since": "30.days.ago", + "max_results": 20 + } + }, + { + "id": "Q006", + "intent": "Read the existing PDF tests to understand expected behavior and discover skip markers.", + "type": "read", + "priority": 2, + "params": { + "path": "tests/nextstop/test_pdf.py", + "lines": null + } + }, + { + "id": "Q007", + "intent": "Locate the shared PDF renderer to understand the contract Nextstop depends on.", + "type": "grep", + "priority": 2, + "params": { + "pattern": "class .*PdfRenderer|def render", + "path": "libs/", + "case_sensitive": true, + "max_results": 20 + } + }, + { + "id": "Q008", + "intent": "Find PDF-related configuration / env vars (template paths, storage).", + "type": "grep", + "priority": 3, + "params": { + "pattern": "PDF_|pdf_template|pdf_storage", + "path": ".", + "case_sensitive": false, + "max_results": 20 + } + } + ], + "expected_findings": [ + "Wiki orientation: module location, API outline, relationships", + "Localization of Nextstop module (refined by wiki)", + "Path of PDF entrypoint and shared renderer", + "Recent change(s) potentially related to the bug", + "Existing test coverage and any skip markers", + "Integration contract between Nextstop and shared renderer" + ], + "meta": { + "source_signals": { + "named_entities": ["Nextstop", "PDF"], + "verbs": ["no genera"], + "polarity": "negative" + }, + "estimated_budget_use": { + "wiki_queries": 3, + "files_read": 1, + "grep_calls": 4, + "git_calls": 1 + } + } +} +``` + +--- + +# Hard rules + +1. **Wiki-first orientation is mandatory.** Every plan MUST start with at + least one `wiki_query` (priority 0) to orient before any grep/read. + Wiki queries are free and don't count against budget. The only + exception is when the wiki is known to be unavailable (not built). + +2. **No query may reference a path that doesn't plausibly exist.** Use + wiki results or directory listings from `<repo_root><dirs>` to ground + path parameters. If you don't know whether `app/modules/nextstop/` + exists, use `wiki_query` or a glob/grep to find it first — not a read + of a guessed path. + +3. **`read` queries must have specific paths.** `read .` is not allowed. + Use wiki_query, grep, or glob first to find the path, then read. + +4. **Budget arithmetic must hold.** Sum of (grep + glob + tree) queries + ≤ 0.7 × max_grep_calls. Same for files_read and git_calls. + Wiki queries (wiki_query, wiki_page, wiki_related) are excluded from + all budget counters. + +5. **Output must be valid JSON.** No trailing commas, no comments, no + markdown fencing. + +6. **Plan must produce at least one finding even if mostly empty repo.** + Include at least one "what does the repo look like at all" query + (`wiki_query` preferred, or `tree`/`glob` fallback) for ambiguous sources. diff --git a/sdd/templates/research_plan.schema.json b/sdd/templates/research_plan.schema.json new file mode 100644 index 00000000..d2f5a49a --- /dev/null +++ b/sdd/templates/research_plan.schema.json @@ -0,0 +1,175 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "https://ai-parrot.local/sdd/research_plan.schema.json", + "title": "SDDResearchPlan", + "description": "Output of Phase 1 (research planner). Lists ordered queries that the executor will run against the codebase. Persisted at sdd/state/<FEAT-ID>/research_plan.json.", + "type": "object", + "required": ["schema_version", "mode_hint", "rationale", "queries"], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "1.0" + }, + "mode_hint": { + "enum": ["investigation", "enrichment"], + "description": "Planner's prediction of what the synthesis agent will resolve mode to. Biases query selection but is not authoritative." + }, + "rationale": { + "type": "string", + "minLength": 10, + "maxLength": 500, + "description": "One- or two-sentence rationale for the chosen mode_hint and overall plan shape." + }, + "queries": { + "type": "array", + "minItems": 1, + "maxItems": 30, + "items": {"$ref": "#/$defs/query"} + }, + "expected_findings": { + "type": "array", + "items": {"type": "string"}, + "description": "Free-text list of what the planner expects to learn. Used by the synthesis linter to flag findings that diverged from the plan." + }, + "meta": { + "type": "object", + "additionalProperties": true, + "properties": { + "source_signals": { + "type": "object", + "additionalProperties": false, + "properties": { + "named_entities": { + "type": "array", + "items": {"type": "string"} + }, + "verbs": { + "type": "array", + "items": {"type": "string"} + }, + "polarity": {"enum": ["positive", "negative", "neutral"]} + } + }, + "estimated_budget_use": { + "type": "object", + "additionalProperties": false, + "properties": { + "files_read": {"type": "integer", "minimum": 0}, + "grep_calls": {"type": "integer", "minimum": 0}, + "git_calls": {"type": "integer", "minimum": 0} + } + } + } + } + }, + "$defs": { + "query": { + "type": "object", + "required": ["id", "intent", "type", "priority", "params"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^Q[0-9]{3}$", + "description": "Stable query ID, used in finding IDs and parent_id linkage." + }, + "intent": { + "type": "string", + "minLength": 10, + "maxLength": 200, + "description": "Why this query exists. Must be specific — not 'look around'." + }, + "type": { + "enum": ["grep", "glob", "read", "git_log", "tree"] + }, + "priority": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "1 = must run, 5 = optional. Executor walks 1 → 5." + }, + "parent_id": { + "type": ["string", "null"], + "pattern": "^F[0-9]{3}$", + "description": "If this query was added as a recursive follow-up, the finding ID that triggered it." + }, + "depth": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Recursion depth. Original plan is depth 0; follow-ups increment." + }, + "params": { + "oneOf": [ + {"$ref": "#/$defs/params_grep"}, + {"$ref": "#/$defs/params_glob"}, + {"$ref": "#/$defs/params_read"}, + {"$ref": "#/$defs/params_git_log"}, + {"$ref": "#/$defs/params_tree"} + ] + } + } + }, + "params_grep": { + "type": "object", + "required": ["pattern"], + "additionalProperties": false, + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string", "default": "."}, + "case_sensitive": {"type": "boolean", "default": false}, + "max_results": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, + "include": {"type": "string", "description": "glob filter, e.g. '*.py'"}, + "exclude": {"type": "string"} + } + }, + "params_glob": { + "type": "object", + "required": ["pattern"], + "additionalProperties": false, + "properties": { + "pattern": {"type": "string"}, + "max_results": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100} + } + }, + "params_read": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": {"type": "string"}, + "lines": { + "type": ["array", "null"], + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + "description": "Optional [start, end] line range (1-indexed, inclusive). null = full file." + } + } + }, + "params_git_log": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": {"type": "string"}, + "since": { + "type": "string", + "default": "30.days.ago", + "description": "git --since spec, e.g. '30.days.ago', '2026-01-01'" + }, + "max_results": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}, + "with_diff": {"type": "boolean", "default": false} + } + }, + "params_tree": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": {"type": "string"}, + "max_depth": {"type": "integer", "minimum": 1, "maximum": 5, "default": 2} + } + } + } +} diff --git a/sdd/templates/spec.md b/sdd/templates/spec.md new file mode 100644 index 00000000..212d2d11 --- /dev/null +++ b/sdd/templates/spec.md @@ -0,0 +1,326 @@ +--- +# SDD flow type and base branch (FEAT-145). +# - type: feature (default) → base_branch: dev (or any non-main branch) +# - type: hotfix → base_branch MUST be: main +type: feature +base_branch: dev +# projects: parts of the codebase this doc concerns. Use `packages/*` dir names +# (ai-parrot, ai-parrot-server, parrot-formdesigner, …) or an area +# (sdd-tooling, dev-loop, admin-ui, docs, ci). Unknown values warn, not fail. +projects: [] +# tags: free-form kebab-case keywords for organizing specs (e.g. memory, mcp). +tags: [] +# e2e: optional deterministic-E2E-gate policy metadata (FEAT-581). Absent +# entirely (the default for every spec written before this feature) means +# the generated e2e-plan.md's policy defaults to `optional` when loaded. +# `policy` MUST be exactly `required`, `optional` or `none` — an +# unrecognized value fails plan loading closed, it is never coerced to a +# default. `scenario_ids` are the plan's stable `ScenarioSpec.id` values, +# kept in sync with the "### E2E Scenarios" subsection below (§4). Omit +# this whole key for a feature with no E2E surface. +# e2e: +# policy: optional +# scenario_ids: [] +--- + +# Feature Specification: <Feature Name> + +**Feature ID**: FEAT-<NNN> +**Date**: YYYY-MM-DD +**Author**: <name> +**Status**: draft | review | approved +**Target version**: x.y.z + +--- + +## 1. Motivation & Business Requirements + +> Why does this feature exist? What problem does it solve? + +### Problem Statement +<!-- Describe the pain point or capability gap --> + +### Goals +- Goal 1 +- Goal 2 + +### Non-Goals (explicitly out of scope) +- Non-goal 1 + +--- + +## 2. Architectural Design + +### Overview +<!-- High-level description of the solution approach --> + +### Component Diagram +``` +ComponentA ──→ ComponentB ──→ ComponentC + │ + └──→ ComponentD +``` + +### Integration Points +<!-- How does this feature integrate with existing AI-Parrot components? --> + +| Existing Component | Integration Type | Notes | +|---|---|---| +| `AbstractClient` | extends | ... | +| `AgentCrew` | uses | ... | + +### Data Models +```python +# Key data structures / Pydantic models +class FeatureModel(BaseModel): + field: type +``` + +### New Public Interfaces +```python +# New classes/functions exposed to users +class NewComponent: + async def method(self, param: Type) -> ReturnType: + ... +``` + +--- + +## 3. Module Breakdown + +> Define the discrete modules that will be implemented. +> These directly map to Task Artifacts in Phase 2. + +#### Delegation-eligible modules + +Mark which modules can be implemented by the targeted writer from an +already-decided TASK packet. Architecture decisions always stay with the +thinking model; eligibility only means "the design is complete enough that +writing the code is mechanical". + +| Module | Eligible? | Decided patterns / exact contracts | Why not (if no) | +|---|---|---|---| +| M1: <name> | yes / no | <signatures, error codes, file layout already fixed> | <open design question> | + + +### Module 1: <Name> +- **Path**: `parrot/path/to/module.py` +- **Responsibility**: What this module does +- **Depends on**: existing module or Module N from this spec +- **Interface Skeleton** *(signatures + docstrings only — bodies belong to task blueprints, FEAT-545)*: + ```python + # parrot/path/to/module.py (new | modifies parrot/path/to/module.py:NN) + class NewComponent(ExistingBase): # ExistingBase verified: parrot/base.py:NN + """<purpose>.""" + async def method(self, param: Type) -> ReturnType: + """<contract: returns …; raises … when …>.""" + ``` + +### Module 2: <Name> +- **Path**: `parrot/path/to/module2.py` +- **Responsibility**: ... +- **Depends on**: Module 1 +- **Interface Skeleton** *(signatures + docstrings only — bodies belong to task blueprints, FEAT-545)*: + ```python + # parrot/path/to/module.py (new | modifies parrot/path/to/module.py:NN) + class NewComponent(ExistingBase): # ExistingBase verified: parrot/base.py:NN + """<purpose>.""" + async def method(self, param: Type) -> ReturnType: + """<contract: returns …; raises … when …>.""" + ``` + +--- + +## 4. Test Specification + +### Unit Tests +| Test | Module | Description | +|---|---|---| +| `test_component_init` | Module 1 | Validates initialization with valid config | +| `test_component_error` | Module 1 | Handles invalid input gracefully | + +### Integration Tests +| Test | Description | +|---|---| +| `test_end_to_end_flow` | Full pipeline from input to output | + +### Test Data / Fixtures +```python +# Key fixtures needed +@pytest.fixture +def sample_config(): + return {...} +``` + +### E2E Scenarios + +> Optional (FEAT-581) — only for a feature that exercises the deterministic +> E2E gate (`parrot e2e run` / `parrot e2e verify`). Omit this whole +> subsection, and the frontmatter `e2e` key above, for a feature with no E2E +> surface; an absent policy defaults to `optional` once a plan is loaded. + +- **Policy**: `required` | `optional` | `none` — must match frontmatter + `e2e.policy` exactly. An invalid value fails plan loading closed; it is + never silently coerced to a default. +- **Required deterministic scenarios**: stable `ScenarioSpec.id` values this + feature must cover, each with enumerated pytest node IDs (never a bare + directory or wildcard — a node ID belongs to exactly one scenario). A + `required` policy demands at least one required, codified + (non-exploratory, node-bearing) scenario; an empty or all-exploratory + scenario list under `required` is a spec defect, not something the loader + defaults around. +- **Live / exploratory scenarios**: listed separately from the deterministic + scenarios above, never described as "deterministic". Live scenarios call + a real provider under explicit `PARROT_TEST_REAL_LLM=1` opt-in and a + request budget; exploratory scenarios declare no node IDs and can never + be `required`. +- **Generated plan**: the complete `e2e-plan.md` frontmatter + (`schema_version`, `feature_id`, `spec_path`, `policy`, `targets`, + `scenarios`, `budget`, `run_timeout_s`) is produced during task + decomposition once node IDs are frozen — do not fabricate concrete node + IDs at spec-authoring time; its evidence is not available yet either. + +| Scenario ID | Tier | Required | Target(s) | Notes | +|---|---|---|---|---| +| `<scenario-id>` | deterministic | yes | `<target-id>` | enumerated node IDs added once frozen | +| `<scenario-id>` | live | no | `<target-id>` | explicit opt-in only; never called deterministic | +| `<scenario-id>` | exploratory | no (never required) | `<target-id>` | candidates only, human-reviewed | + +Validation commands (exact node IDs supplied by the generated plan): +```bash +pytest <package>/tests/unit/e2e/ -q +PARROT_TEST_E2E=1 parrot e2e run --plan <path-to-generated>/e2e-plan.md +parrot e2e verify --plan <path-to-generated>/e2e-plan.md +``` + +--- + +## 5. Acceptance Criteria + +> This feature is complete when ALL of the following are true: + +- [ ] All unit tests pass (`pytest tests/unit/ -v`) +- [ ] All integration tests pass (`pytest tests/integration/ -v`) +- [ ] Documentation updated in `docs/` +- [ ] No breaking changes to existing public API +- [ ] Performance benchmark: <metric> within <threshold> +- [ ] Criterion N + +--- + +## 6. Codebase Contract + +> **CRITICAL — Anti-Hallucination Anchor** +> This section is the single source of truth for what exists in the codebase. +> Implementation agents MUST NOT reference imports, attributes, or methods +> not listed here without first verifying they exist via `grep` or `read`. + +### Verified Imports +<!-- Exact import statements confirmed to work. Agents MUST use these verbatim. --> +```python +from parrot.module import ClassName # verified: parrot/module/__init__.py:NN +``` + +### Existing Class Signatures +<!-- Exact signatures of classes/methods that tasks will extend or call. + Include attribute types and method signatures with line numbers. --> +```python +# parrot/path/to/file.py +class ExistingClass(BaseClass): + attribute: Type # line NN + async def method(self, param: Type) -> ReturnType: # line NN +``` + +### Integration Points +<!-- How new code connects to existing code. Specify exact method calls. --> +| New Component | Connects To | Via | Verified At | +|---|---|---|---| +| `NewClass` | `ExistingClass.method()` | method call | `path/file.py:NN` | + +### Does NOT Exist (Anti-Hallucination) +<!-- Things that look plausible but DO NOT exist in the codebase. + Prevents agents from inventing imports or attributes. --> +- ~~`parrot.module.NonExistentThing`~~ — does not exist +- ~~`ClassName.phantom_method()`~~ — not a real method + +### Edit Sites (Blueprint Anchors) +<!-- One row per file the §3 modules will touch. `/sdd-task` turns each row into an + Implementation Blueprint block, so the anchor is verified ONCE per feature here + instead of once per task. Keep it to files the modules actually change — this + table is re-read on every downstream turn, so rows must earn their place. + + Verified against: <short SHA of the base commit at spec time> + + Anchors go stale between spec time and task time. `/sdd-task` MUST re-run the + `grep -c` for every row it uses; this table saves the search, not the check. --> + +| File | Action | Verbatim anchor line | Verified at | Occurrences | +|---|---|---|---|---| +| `packages/<dist>/src/<pkg>/module/file.py` | MODIFY | `def existing_method(self) -> None:` | `file.py:NN` | 1 | +| `packages/<dist>/src/<pkg>/module/new_file.py` | CREATE | — | — | — | + +- A **CREATE** row declares only the path the module adds; it needs no anchor. +- **Occurrences > 1** means the one-line anchor is ambiguous. Quote 2–3 lines of + surrounding context in the anchor cell so the blueprint block can attach uniquely. +- An anchor that cannot be verified is written `(unverified — check before use)`, + never guessed. + +--- + +## 7. Implementation Notes & Constraints + +> Architecture decisions stay with the thinking model. A delegated +> implementation may only express a decision already recorded here and in +> the TASK's implementation blocks — it must never invent an API, choose a +> file, or resolve an open design question. + + +### Patterns to Follow +- Use `AbstractBase` pattern from `parrot/base/` +- Follow async-first design throughout +- Pydantic models for all structured data +- Comprehensive logging with `self.logger` + +### Known Risks / Gotchas +- Risk 1 and mitigation +- Risk 2 and mitigation + +### External Dependencies +| Package | Version | Reason | +|---|---|---| +| `package` | `>=x.y` | why needed | + +--- + +## 8. Open Questions + +> Questions that must be resolved before or during implementation. + +- [ ] Question 1 — *Owner: name* +- [ ] Question 2 — *Owner: name* + +--- + +## 9. Design Research Cross-Check + +> Independent design opinion from the `codex` seat over the **accepted exploration +> doc** (never over this spec). Model: `<model>` · Status: completed | skipped (<reason>) +> · Transcript: `sdd/state/<FEAT-ID>/design_research/` +> Every row is a suggestion the reviewer made; the disposition is the spec author's +> call (CONFIRM = folded into the spec, REJECT = reason recorded, ESCALATE = §8 question). + +| # | Suggestion (kind) | Disposition | Reason | Landed in | +|---|---|---|---|---| +| S1 | <title> (architecture) | CONFIRM | <why adopted> | §2 Overview | +| S2 | <title> (testing) | REJECT | <why not> | — | +| S3 | <title> (risk) | ESCALATE | <what the human must decide> | §8 Q<N> | + +Summary: **<C>** confirmed · **<R>** rejected · **<E>** escalated. + +--- + +## Revision History + +| Version | Date | Author | Change | +|---|---|---|---| +| 0.1 | YYYY-MM-DD | name | Initial draft | diff --git a/sdd/templates/state.schema.json b/sdd/templates/state.schema.json new file mode 100644 index 00000000..2704c8c4 --- /dev/null +++ b/sdd/templates/state.schema.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "https://ai-parrot.local/sdd/state.schema.json", + "title": "SDDProposalState", + "description": "Persistent state for /sdd-proposal runs. One file per FEAT-ID at sdd/state/<FEAT-ID>/state.json. Enables resumability, audit, and budget enforcement.", + "type": "object", + "required": [ + "schema_version", + "feat_id", + "started_at", + "source", + "mode", + "phase", + "phases", + "budget", + "consumed" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "1.0", + "description": "Schema version. Bump on breaking changes." + }, + "feat_id": { + "type": ["string", "null"], + "pattern": "^FEAT-[0-9]{3,}$", + "description": "Stable feature ID, allocated in Phase 0 and reused by /sdd-spec. Null while a /sdd-spec intake run is staged id-less (FEAT-577); set when /sdd-spec §5 reserves it." + }, + "feature_slug": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "kebab-case slug, derived from the synthesis summary." + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "object", + "required": ["kind", "raw_path"], + "additionalProperties": false, + "properties": { + "kind": {"enum": ["jira", "inline", "file", "intake"]}, + "jira_key": {"type": ["string", "null"]}, + "jira_url": {"type": ["string", "null"]}, + "file_path": {"type": ["string", "null"]}, + "raw_path": { + "type": "string", + "description": "sdd/state/<FEAT-ID>/source.md, or sdd/state/.intake/<slug>-<RUN_ID>/source.md in /sdd-spec intake mode (FEAT-577)" + }, + "fetched_at": {"type": "string", "format": "date-time"}, + "summary_oneline": {"type": "string", "maxLength": 200} + } + }, + "mode": { + "enum": ["investigation", "enrichment", "auto"], + "description": "User-specified mode. 'auto' means the synthesis agent decides." + }, + "detected_mode": { + "enum": ["investigation", "enrichment"], + "description": "Resolved mode after Phase 3 (synthesis). Always investigation or enrichment." + }, + "phase": { + "enum": [ + "source_resolved", + "plan_drafted", + "plan_approved", + "research_running", + "research_complete", + "synthesis_complete", + "review_gate", + "qa_pending", + "qa_complete", + "proposal_drafted", + "committed", + "failed" + ], + "description": "Top-level checkpoint. Reflects the last completed phase." + }, + "phases": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/phase_record"}, + "plan": { + "allOf": [ + {"$ref": "#/$defs/phase_record"}, + { + "type": "object", + "properties": { + "queries_total": {"type": "integer", "minimum": 0}, + "approved_by_user": {"type": "boolean"}, + "edited_by_user": {"type": "boolean"}, + "plan_path": {"type": "string"} + } + } + ] + }, + "research": { + "allOf": [ + {"$ref": "#/$defs/phase_record"}, + { + "type": "object", + "properties": { + "queries_executed": {"type": "integer", "minimum": 0}, + "queries_skipped": {"type": "integer", "minimum": 0}, + "depth_reached": {"type": "integer", "minimum": 0}, + "files_read": {"type": "integer", "minimum": 0}, + "grep_calls": {"type": "integer", "minimum": 0}, + "git_calls": {"type": "integer", "minimum": 0}, + "truncated": {"type": "boolean"}, + "truncation_reason": {"type": ["string", "null"]} + } + } + ] + }, + "synthesis": { + "allOf": [ + {"$ref": "#/$defs/phase_record"}, + { + "type": "object", + "properties": { + "synthesis_path": {"type": "string"}, + "thinking_path": {"type": ["string", "null"]}, + "overall_confidence": {"enum": ["high", "medium", "low"]}, + "claims_total": {"type": "integer", "minimum": 0}, + "claims_high_conf": {"type": "integer", "minimum": 0}, + "claims_medium_conf": {"type": "integer", "minimum": 0}, + "claims_low_conf": {"type": "integer", "minimum": 0}, + "unknowns_count": {"type": "integer", "minimum": 0, "maximum": 5}, + "lint_passed": {"type": "boolean"}, + "lint_iterations": {"type": "integer", "minimum": 0, "maximum": 2} + } + } + ] + }, + "qa": { + "allOf": [ + {"$ref": "#/$defs/phase_record"}, + { + "type": "object", + "properties": { + "questions_total": {"type": "integer", "minimum": 0}, + "questions_answered": {"type": "integer", "minimum": 0}, + "skipped_reason": {"type": ["string", "null"]} + } + } + ] + }, + "output": { + "allOf": [ + {"$ref": "#/$defs/phase_record"}, + { + "type": "object", + "properties": { + "proposal_path": {"type": "string"}, + "commit_sha": {"type": ["string", "null"]}, + "next_command_recommended": { + "enum": ["sdd-spec", "sdd-brainstorm", "sdd-task", "manual-review"] + }, + "next_command_rationale": {"type": "string"} + } + } + ] + } + } + }, + "budget": { + "type": "object", + "description": "Hard limits enforced during research. Profile via --budget flag.", + "additionalProperties": false, + "required": [ + "profile", + "max_files_read", + "max_grep_calls", + "max_git_calls", + "max_depth", + "max_wall_seconds" + ], + "properties": { + "profile": {"enum": ["tight", "default", "loose", "custom"]}, + "max_files_read": {"type": "integer", "minimum": 1}, + "max_grep_calls": {"type": "integer", "minimum": 1}, + "max_git_calls": {"type": "integer", "minimum": 0}, + "max_depth": {"type": "integer", "minimum": 1, "maximum": 5}, + "max_wall_seconds": {"type": "integer", "minimum": 30} + } + }, + "consumed": { + "type": "object", + "additionalProperties": false, + "required": ["files_read", "grep_calls", "git_calls", "wall_seconds"], + "properties": { + "files_read": {"type": "integer", "minimum": 0}, + "grep_calls": {"type": "integer", "minimum": 0}, + "git_calls": {"type": "integer", "minimum": 0}, + "wall_seconds": {"type": "number", "minimum": 0} + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": ["phase", "at", "message"], + "additionalProperties": false, + "properties": { + "phase": {"type": "string"}, + "at": {"type": "string", "format": "date-time"}, + "message": {"type": "string"}, + "recoverable": {"type": "boolean"} + } + } + }, + "resume_hint": { + "type": "object", + "description": "What to do on next invocation if interrupted.", + "additionalProperties": false, + "properties": { + "next_phase": {"type": "string"}, + "next_action": {"type": "string"} + } + } + }, + "$defs": { + "phase_record": { + "type": "object", + "additionalProperties": true, + "properties": { + "status": { + "enum": ["pending", "running", "completed", "skipped", "failed"] + }, + "started_at": {"type": ["string", "null"], "format": "date-time"}, + "completed_at": {"type": ["string", "null"], "format": "date-time"}, + "duration_seconds": {"type": ["number", "null"], "minimum": 0} + } + } + } +} diff --git a/sdd/templates/synthesis.prompt.md b/sdd/templates/synthesis.prompt.md new file mode 100644 index 00000000..6177f683 --- /dev/null +++ b/sdd/templates/synthesis.prompt.md @@ -0,0 +1,340 @@ +<!-- sdd/templates/synthesis.prompt.md v1.0 --> + +# Role + +You are the Synthesis Agent for the AI-Parrot SDD pipeline, Phase 3 of `/sdd-proposal`. + +Your job: read the original ticket/source AND a structured digest of codebase +findings, then produce a grounded synthesis that the proposal renderer will +consume. You are NOT generating a proposal document directly — you produce +structured JSON. + +You are reading evidence, not inventing. Every claim you produce must be +traceable to a specific finding ID. If you cannot ground a claim, you must +either (a) downgrade its confidence to "low" with explicit caveat, or +(b) move the topic into `unknowns` instead of asserting it. + +--- + +# Input format + +You will receive a single user message containing: + +``` +<source> + <kind>jira | inline | file</kind> + <jira_key>NAV-XXXX | null</jira_key> + <raw>{raw text of the ticket / brief / file}</raw> +</source> + +<research_plan> +{the approved research_plan.json, including queries with ids and intents} +</research_plan> + +<findings> + <finding id="F001" query_id="Q001" type="wiki_query|wiki_page|wiki_related|grep|read|git_log|glob|tree"> + <intent>{why this query was run}</intent> + <result>{compact digest, never the full file content}</result> + <citations> + - path: app/modules/nextstop/pdf.py + lines: 47-89 + symbol: generate_pdf + wiki_page_id: <optional — present for wiki-sourced findings> + - path: app/modules/nextstop/pdf.py + lines: 142-160 + symbol: render_chunk + </citations> + </finding> + <finding id="F002" ...>...</finding> + ... +</findings> + +<budget_status> + <consumed>{files_read: 11, grep_calls: 9, git_calls: 3}</consumed> + <truncated>false</truncated> + <truncation_reason>null</truncation_reason> +</budget_status> + +<mode>investigation | enrichment | auto</mode> +``` + +--- + +# Reasoning protocol + +Before producing JSON, reason step by step **inside `<thinking>` tags**. +The reasoning will be discarded by the renderer — only the final JSON is +consumed. (It MAY be persisted to `synthesis.thinking.log` for audit.) + +Walk these steps in order: + +## Step 1 — Restate the ask in your own words. + +What is the source actually requesting? In one sentence. If the source is +ambiguous (typical for short Jira tickets), state both plausible interpretations +and pick the one most consistent with the findings. + +## Step 2 — Mode determination. + +- `investigation`: the source describes broken/unexpected behavior, or asks + "why does X happen". Output emphasizes hypotheses about cause. +- `enrichment`: the source describes a desired new capability or change. + Output emphasizes scope, integration points, and impact. + +If `<mode>` was passed as `auto`, you decide. Lean `investigation` when the +source contains negation ("no genera", "doesn't work", "fails", "broken"). +Lean `enrichment` when it contains additive verbs ("add", "support", "implement"). + +## Step 3 — Build the localization map. + +For every code location relevant to the request, list: +- the path (verified — must appear in at least one finding's citations) +- the symbol (function, class, module attribute) +- a one-line role description +- the finding IDs that justify including it + +Wiki-sourced findings (type `wiki_query`, `wiki_page`, `wiki_related`) +are valid evidence for localization. A wiki page that lists a file path +and its API outline counts as a citation — you don't need a separate +grep confirmation for paths the wiki returned. However, wiki findings +provide module-level summaries, not line-level detail — pair them with +`read` or `grep` findings when citing specific line ranges. + +Locations not backed by a finding are FORBIDDEN. If the localization feels thin, +that is a signal to lower the overall confidence — not to invent. + +## Step 4 — Build the constraints map. + +What conventions, contracts, or patterns must any solution respect? +Examples: existing public API surface, async-first conventions, security +rules, downstream callers, test coverage areas. Each constraint cites the +finding(s) that revealed it. + +## Step 5 — Build the hypothesis (investigation) OR scope (enrichment). + +For **investigation**: +Generate 1-3 ranked hypotheses about the root cause. Each hypothesis has: +- a one-sentence statement +- supporting evidence (finding IDs + reasoning) +- contradicting evidence, if any (finding IDs + reasoning) +- a confidence level (high/medium/low) with reasoning +- a suggested next-step probe (e.g., "run failing test in isolation", + "check logs for env X") + +For **enrichment**: +Describe the probable scope. Include: +- what would be NEW (modules, functions, endpoints) +- what would be MODIFIED (with citations) +- what would be UNTOUCHED (explicitly — Non-Goals) +- which existing patterns to follow (cite findings) +- integration risks (cite findings) + +## Step 6 — Build the confidence map. + +Produce a table of atomic claims, each with: +- statement (one sentence, declarative) +- evidence (finding IDs) +- confidence (high/medium/low) +- reasoning (why that level) + +A "high" claim is directly supported by a citation (grep, read, or +wiki_page with matching path/symbol). A "medium" claim is inferred from +one finding via reasonable interpretation, or is supported only by a +wiki_query stub (module-level, not line-level). A "low" claim is +inferred from multiple findings or from the absence of contrary evidence +("we found no place that handles X, therefore it likely doesn't"). + +## Step 7 — Identify unknowns. + +List items the user (not the codebase) must answer. Each unknown: +- is materially blocking (would change the recommended next command) +- is phrased as a concrete question, not a vague concern +- references which claim(s) it would resolve +- proposes 2-3 plausible answers when possible + +Cap at 5 unknowns. If you have more, you researched the wrong thing — +flag this in `meta.notes`. + +## Step 8 — Recommend next command. + +Pick exactly one of: `sdd-spec`, `sdd-brainstorm`, `sdd-task`, `manual-review`. +- `sdd-spec`: high overall confidence, scope is clear, no architectural fork +- `sdd-brainstorm`: medium confidence OR multiple viable architectural paths +- `sdd-task`: trivial fix, very localized, AC obvious from findings +- `manual-review`: research was truncated, contradictions in findings, or + source is too vague even after research + +Justify the choice in one sentence. + +## Step 9 — Self-check. + +Before emitting JSON, verify: +- Every claim cites at least one finding ID. If not, downgrade or remove. +- Every cited finding ID exists in the input. +- Confidence distribution is honest: if everything is "high", you're + probably overconfident. If everything is "low", research was insufficient. +- Unknowns are answerable (the codebase couldn't answer them — only the user). +- The recommended next command is consistent with the confidence map. + +--- + +# Output format + +After `</thinking>`, emit a single JSON object. No prose, no markdown fences. + +```json +{ + "schema_version": "1.0", + "feat_id": "FEAT-156", + "mode": "investigation", + "summary_oneline": "Async refactor in Nextstop PDF generation appears to await a sync helper, breaking output.", + + "localization": [ + { + "path": "app/modules/nextstop/pdf.py", + "symbol": "generate_pdf", + "lines": "47-89", + "role": "entrypoint that orchestrates PDF rendering for Nextstop trips", + "evidence": ["F001", "F003"] + }, + { + "path": "libs/pdf_renderer.py", + "symbol": "render", + "lines": "12-58", + "role": "shared synchronous renderer used by Nextstop and other modules", + "evidence": ["F004"] + } + ], + + "constraints": [ + { + "statement": "Shared `libs.pdf_renderer.render` is sync and called from multiple modules", + "evidence": ["F004"], + "implication": "any async refactor in nextstop must not break sync callers in legacy modules" + }, + { + "statement": "PDF tests are skipped in default CI runs", + "evidence": ["F006"], + "implication": "regressions can land silently in dev" + } + ], + + "investigation": { + "hypotheses": [ + { + "rank": 1, + "statement": "The async refactor in commit a3f4b2 awaits a sync function, causing silent return of a coroutine that is never awaited downstream.", + "supporting": ["F002", "F005"], + "contradicting": [], + "confidence": "medium", + "reasoning": "Commit diff shows `await render(...)` added on a sync `render`. Test in F005 was skipped at the same commit.", + "next_probe": "Run `pytest tests/nextstop/test_pdf.py::test_generate_pdf -s` and check for 'coroutine never awaited' warning." + } + ] + }, + + "enrichment": null, + + "confidence_map": [ + { + "id": "C1", + "statement": "PDF generation entrypoint lives at app/modules/nextstop/pdf.py:generate_pdf", + "evidence": ["F001"], + "confidence": "high", + "reasoning": "Direct grep match + read confirmation." + }, + { + "id": "C2", + "statement": "The bug was introduced in commit a3f4b2 (2 days ago)", + "evidence": ["F002"], + "confidence": "medium", + "reasoning": "Commit timing aligns with ticket date, but no log/stack trace in source confirms causality." + }, + { + "id": "C3", + "statement": "Shared render helper at libs/pdf_renderer.py:render is sync", + "evidence": ["F004"], + "confidence": "high", + "reasoning": "Function signature read directly; not declared async." + }, + { + "id": "C4", + "statement": "Test test_generate_pdf was skipped intentionally", + "evidence": [], + "confidence": "low", + "reasoning": "No skip-reason comment in F005; intent unclear without asking the author." + } + ], + + "unknowns": [ + { + "id": "U1", + "question": "Was tests/nextstop/test_pdf.py::test_generate_pdf skipped intentionally or by accident in commit a3f4b2?", + "blocks_claims": ["C4"], + "plausible_answers": [ + "intentional — pre-existing flake the author wanted to fix later", + "accidental — left over from refactor" + ], + "ask_user": true + }, + { + "id": "U2", + "question": "In what environment was the bug observed (production vs staging vs local)?", + "blocks_claims": ["C2"], + "plausible_answers": [ + "production — implies the refactor reached prod", + "local only — suggests an env-specific config issue" + ], + "ask_user": true + } + ], + + "recommended_next_command": { + "command": "sdd-spec", + "rationale": "Localization is high-confidence and the fix scope is well-bounded; no architectural fork to explore." + }, + + "overall_confidence": "medium", + + "meta": { + "findings_used": ["F001", "F002", "F003", "F004", "F005", "F006"], + "findings_unused": ["F007"], + "notes": "" + } +} +``` + +--- + +# Hard rules (anti-hallucination) + +1. **No path may appear in `localization` or `constraints` unless that path + appears in at least one finding's `<citations>` block.** Wiki-sourced + findings (wiki_query, wiki_page, wiki_related) count as valid citations + for paths they return. If you need a path the research didn't surface, + add it to `unknowns` instead. + +2. **No symbol name may be invented.** If a finding cites `generate_pdf` and + you want to mention `render_chunk`, you must have it in another citation. + Wiki page API outlines are valid sources of symbol names. + +3. **No commit SHA, line number, or version may be fabricated.** If the + findings don't include them, leave them out. + +4. **`overall_confidence` must be the minimum of: median claim confidence, + localization confidence, hypothesis-1 confidence (investigation mode) + or scope confidence (enrichment mode).** Don't average up. + +5. **If `<budget_status><truncated>true</truncated>`, `overall_confidence` + cannot exceed `medium`** and `recommended_next_command` cannot be + `sdd-task`. + +6. **Output must be valid JSON.** No trailing commas, no comments, no + markdown fencing. + +7. **`unknowns` must be answerable by the user, not by more research.** If + the answer can be obtained by reading another file or grepping, generate + a follow-up query in `meta.notes` instead of asking the user. + +8. **Mode exclusivity.** Exactly one of `investigation` or `enrichment` is + non-null. The other MUST be `null`. diff --git a/sdd/templates/task.md b/sdd/templates/task.md new file mode 100644 index 00000000..7087639b --- /dev/null +++ b/sdd/templates/task.md @@ -0,0 +1,359 @@ +# TASK-<NNN>: <Title> + +**Feature**: FEAT-<NNN> — <Feature Title> +**Spec**: `sdd/specs/<feature-slug>.spec.md` +**Status**: pending +**Priority**: high | medium | low +**Estimated effort**: S (< 2h) | M (2-4h) | L (4-8h) | XL (> 8h) +**Depends-on**: TASK-<X>, TASK-<Y> *(or "none")* +**Assigned-to**: unassigned + +--- + +## Context + +> Why this task exists. Its role in the broader feature. +> Reference the spec section it implements. + +--- + +## Scope + +> Precisely what this task must implement. Nothing more. +> Use imperative language: "Implement X", "Add Y", "Refactor Z". + +- Implement ... +- Add ... +- Write tests for ... + +**NOT in scope**: (list things that might seem related but belong to other tasks) + +--- + +## Files to Create / Modify + +| File | Action | Description | +|---|---|---| +| `parrot/path/to/new_file.py` | CREATE | Main implementation | +| `tests/unit/test_new_file.py` | CREATE | Unit tests | +| `parrot/path/to/existing.py` | MODIFY | Add import / register component | + +--- + +## Codebase Contract (Anti-Hallucination) + +> **CRITICAL**: This section contains VERIFIED code references from the actual codebase. +> The implementing agent MUST use these exact imports, class names, and method signatures. +> **DO NOT** invent, guess, or assume any import, attribute, or method not listed here. +> If you need something not listed, VERIFY it exists first with `grep` or `read`. + +### Verified Imports +<!-- Exact import statements. Use these VERBATIM — do not guess alternatives. --> +```python +from parrot.module import ClassName # verified: parrot/module/__init__.py:NN +``` + +### Existing Signatures to Use +<!-- Classes/methods this task extends, calls, or integrates with. + Include the file path and line number for each. --> +```python +# parrot/path/to/file.py:NN +class ExistingClass(BaseClass): + attribute: Type # line NN + async def method(self, param: Type) -> ReturnType: # line NN +``` + +### Does NOT Exist +<!-- Things the agent might assume exist but DO NOT. Prevents hallucination. --> +- ~~`parrot.module.NonExistentThing`~~ — does not exist +- ~~`ClassName.phantom_attribute`~~ — not a real attribute + +--- + +## Complexity Contract + +> **MANDATORY.** Declares the measurable targets and contract symbols used for +> deterministic complexity routing (FEAT-561) before any coder is dispatched. +> This is a declaration, not a hand-authored score — the evaluator computes +> classification from measured evidence, never from this section's prose. +> Legacy tasks without this section remain parseable but route conservatively +> (unknown symbol coverage requires the complex-task route until upgraded). +> +> `targets` MUST match the "Files to Create / Modify" table exactly after +> normalization (same paths, uppercase `"CREATE"` / `"MODIFY"` actions). +> `contract_symbols` MUST list every exact `sym:<repo-relative-path>#<qualname>` +> identifier the Codebase Contract above verified as an existing reference — +> use `[]` (not `null`) when there are none; `null` means legacy/unknown +> coverage and is only for tasks predating this section. + +```json +{ + "schema_version": 1, + "targets": [ + { + "path": "parrot/path/to/new_file.py", + "action": "CREATE" + }, + { + "path": "parrot/path/to/existing.py", + "action": "MODIFY" + } + ], + "contract_symbols": [ + "sym:parrot/path/to/existing.py#ExistingClass" + ] +} +``` + +--- + +## Delegation Contract + +> **OPTIONAL.** Include this section ONLY when the task is +> delegation-eligible: the design is COMPLETE (every target file listed, +> every implementation block containing the already-decided code, no open +> questions), hashes were computed at `/sdd-task` time, and they are +> re-verified at execution time. `design_complete: true` is a declaration +> the author signs — the validator checks structure, the thinking model +> verifies semantics. +> +> **Remove this section entirely if the task is not delegation-eligible.** +> A task without it takes the normal implementation route, which is the +> default and is never a failure. + +```json +{ + "schema_version": 1, + "task_id": "TASK-<NNN>", + "spec_path": "sdd/specs/<feature>.spec.md", + "design_complete": true, + "targets": [ + { + "path": "pkg/greeter.py", + "action": "create", + "expected_sha256": null, + "planned_changes": "New module with greet()", + "blocks": ["impl-greeter"] + }, + { + "path": "pkg/__init__.py", + "action": "modify", + "expected_sha256": "<sha256 — MUST BE REPLACED with the real digest>", + "planned_changes": "Export greet", + "blocks": ["impl-init"] + } + ], + "references": [ + { + "path": "pkg/__init__.py", + "sha256": "<sha256 — MUST BE REPLACED with the real digest>", + "start_line": 1, + "end_line": 3, + "purpose": "existing exports" + } + ], + "implementation_blocks": ["impl-greeter", "impl-init"], + "acceptance_criteria": ["pytest tests/test_greeter.py passes"], + "validation_commands": [["pytest", "tests/test_greeter.py", "-q"]] +} +``` + +Compute every digest for real — the `<sha256 …>` placeholders above are +**rejected** by the validator as `invalid_packet`, which is the intended +safety net against shipping an unverified packet: + +```bash +sha256sum pkg/__init__.py +# or, without coreutils: +python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" pkg/__init__.py +``` + +A `create` target MUST have a block tagged with `path=<target>`; that block +IS the new file's content. A `modify` target's blocks describe the decided +edit and MUST carry `expected_sha256`. + +```python id=impl-greeter path=pkg/greeter.py +def greet(name: str) -> str: + """Return a greeting.""" + return f"hello {name}" +``` + +```python id=impl-init +# Apply to pkg/__init__.py: add after the existing imports +from .greeter import greet + +__all__ = [*__all__, "greet"] +``` + +Block ids match `^[a-z0-9][a-z0-9._-]{0,63}$` and must be unique in the +file. Blocks must contain no placeholders (`...` on its own line, `TODO`, +`FIXME`, `XXX`, `<angle placeholders>`, `raise NotImplementedError`) — the +validator rejects them as `placeholder_code`. + + +## Implementation Notes + +> Technical guidance for the executing agent. + +### Pattern to Follow +```python +# Reference implementation pattern from existing code +# e.g. copy this structure from parrot/loaders/base.py +class ExistingPattern(AbstractBase): + async def method(self) -> Result: + ... +``` + +### Key Constraints +- Must be async throughout +- Use Pydantic for all data models +- Follow existing naming conventions in the module +- Add `self.logger` calls at key points + +### References in Codebase +- `parrot/path/reference1.py` — pattern to follow +- `parrot/path/reference2.py` — integration point + +--- + +## Implementation Blueprint + +> **CRITICAL — Executor-ready starting point.** Write each block below to its declared +> path nearly verbatim, then complete every `# FILL IN:` marker. Blocks were derived +> from the spec's Interface Skeletons and re-verified against the Codebase Contract +> above when this task was written. This is NOT the full implementation: +> business-logic branches, edge cases and test bodies are `FILL IN` stubs by design. +> Never change a signature, class name, or file path the blueprint fixes. + +### Steps (in order) +1. <imperative step> — *why*: <one sentence> +2. <imperative step> — *why*: <one sentence> + +### `parrot/path/to/new_file.py` (CREATE) +```python +"""<module docstring>.""" +from __future__ import annotations + +from parrot.module import ClassName # verified: parrot/module/__init__.py:NN + + +class NewComponent(ClassName): + """<one-line purpose>.""" + + async def method(self, param: Type) -> ReturnType: + """<what it returns and when it raises>.""" + self.logger.debug("method: %s", param) + # FILL IN: <the exact decision left to you> — bounded by <constraint | AC-N> + raise NotImplementedError +``` +**Why this shape**: <2–4 sentences: which spec decision each block implements; what must NOT change> + +### `parrot/path/to/existing.py` (MODIFY) +```python +# occurrences: <N> (verified: grep -c '<verbatim anchor line>' parrot/path/to/existing.py) +# AFTER — insert below `<verbatim anchor line>` (verified: parrot/path/to/existing.py:NN) +<new lines> +``` +**Why**: <1–2 sentences>. If `<N>` > 1: replace the block above with +`# FILL IN: disambiguate — quote enough surrounding context (2–3 lines) to make the +anchor unique` instead of a bare one-line anchor. + +### FILL IN checklist +- [ ] `new_file.py::NewComponent.method` — <decision>; bounded by <constraint | AC-N> + +--- + +## Acceptance Criteria + +- [ ] Implementation complete per scope +- [ ] All tests pass: `pytest <test_path> -v` +- [ ] No linting errors: `ruff check parrot/<path>` +- [ ] Imports work: `from parrot.<module> import <Component>` +- [ ] Criterion N + +--- + +## Validation Commands + +> File-level pytest only — no directories, no package roots. + +- `pytest packages/ai-parrot/tests/flows/dev_loop/test_scope/test_mirror.py -q` +- `pytest tests/sdd_scripts/test_check_task_graph.py::test_validation_contract_findings -q` + +--- + +## Test Specification + +> Minimal test scaffold. The agent must make these pass. +> Add more tests as needed. + +```python +# tests/unit/test_<module>.py +import pytest +from parrot.<module> import <Component> + + +@pytest.fixture +def component(): + return <Component>(config={...}) + + +class Test<Component>: + def test_initialization(self, component): + """Component initializes with valid config.""" + assert component is not None + + def test_main_behavior(self, component): + """Describe main expected behavior.""" + result = component.method(input) + assert result == expected + + async def test_async_operation(self, component): + """Test async operations.""" + result = await component.async_method(input) + assert result.status == "success" + + def test_error_handling(self, component): + """Component handles invalid input gracefully.""" + with pytest.raises(ValueError, match="expected message"): + component.method(invalid_input) +``` + +--- + +## Agent Instructions + +When you pick up this task: + +1. **Work in the feature worktree** — never on `base_branch` + (`python -m scripts.sdd.ensure_worktree --slug <feature-slug> --feature-id FEAT-<NNN>`) +2. **Read the spec** at the path listed above for full context +3. **Check dependencies** — every `Depends-on` task must be `"done"` in the + per-spec index `sdd/tasks/index/<feature-slug>.json` +4. **Verify the Codebase Contract** — before writing ANY code: + - Confirm every import in "Verified Imports" still exists (`grep` or `read` the source) + - Confirm every class/method in "Existing Signatures" still has the listed attributes + - If anything has changed, update the contract FIRST, then implement + - **NEVER** reference an import, attribute, or method not in the contract without verifying it exists +5. **Update status** in `sdd/tasks/index/<feature-slug>.json` → `"in-progress"` + (set `started_at`) and commit only that index file +6. **Implement** — start from the Implementation Blueprint blocks, complete every + `# FILL IN:` marker, and never change a signature or path the blueprint fixes +7. **Verify** all acceptance criteria are met — run the Validation Commands +8. **Commit the code** — stage only the files this task lists (never `git add .` / `-A`) +9. **Close the task** with `scripts/sdd/close_task.sh TASK-<NNN> <feature-slug> verified` + — it moves this file to `sdd/tasks/completed/` and marks it `"done"` in the + index; never move or copy the file by hand +10. **Fill in the Completion Note** below, then commit the staged SDD state + +--- + +## Completion Note + +*(Agent fills this in when done)* + +**Completed by**: <session or agent ID> +**Date**: YYYY-MM-DD +**Notes**: What was implemented, any deviations from scope, issues encountered. + +**Deviations from spec**: none | describe if any diff --git a/setup.py b/setup.py index 0cb7738a..8979fa72 100644 --- a/setup.py +++ b/setup.py @@ -1,62 +1,23 @@ #!/usr/bin/env python -"""NavConfig. +""" +NavConfig setup.py - Cython extensions only. - Configuration Service for Navigator and DataIntegrator. -See: -https://github.com/phenobarbital/NavConfig +All package metadata is now in pyproject.toml. +This file only handles Cython compilation. """ -import ast -from os import path -from setuptools import find_packages, setup, Extension +from setuptools import setup, Extension from Cython.Build import cythonize - -def get_path(filename): - return path.join(path.dirname(path.abspath(__file__)), filename) - - -def readme(): - with open(get_path('README.md'), 'r', encoding='utf-8') as rd: - return rd.read() - - -version = get_path('navconfig/version.py') -with open(version, 'r', encoding='utf-8') as meta: - # exec(meta.read()) - t = compile(meta.read(), version, 'exec', ast.PyCF_ONLY_AST) - for node in (n for n in t.body if isinstance(n, ast.Assign)): - if len(node.targets) == 1: - name = node.targets[0] - if isinstance(name, ast.Name) and name.id in ( - '__version__', - '__title__', - '__description__', - '__author__', - '__license__', - '__author_email__' - ): - v = node.value - if name.id == '__version__': - __version__ = v.s - if name.id == '__title__': - __title__ = v.s - if name.id == '__description__': - __description__ = v.s - if name.id == '__license__': - __license__ = v.s - if name.id == '__author__': - __author__ = v.s - if name.id == '__author_email__': - __author_email__ = v.s - COMPILE_ARGS = ["-O2"] +# Define Cython extensions extensions = [ Extension( name='navconfig.exceptions', sources=['navconfig/exceptions.pyx'], extra_compile_args=COMPILE_ARGS, - language="c" + language="c", + define_macros=[('CYTHON_COMPILING_IN_PYPY', '1')] ), Extension( name='navconfig.utils.functions', @@ -96,95 +57,17 @@ def readme(): ) ] +# Setup only handles Cython extensions +# All other configuration is in pyproject.toml setup( - name=__title__, - version=__version__, - python_requires=">=3.9.13", - url='https://github.com/phenobarbital/NavConfig', - description=__description__, - long_description=readme(), - long_description_content_type='text/markdown', - license=__license__, - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Build Tools', - 'Environment :: Web Environment', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Framework :: AsyncIO' - ], - author='Jesus Lara', - author_email='jesuslara@phenobarbital.info', - packages=find_packages( - exclude=["docs", "tests", "settings", "examples", "dist", "etc"] + ext_modules=cythonize( + extensions, + compiler_directives={ + 'language_level': 3, + 'embedsignature': True, + 'boundscheck': False, + 'wraparound': False, + 'initializedcheck': False, + } ), - setup_requires=[ - 'setuptools==74.0.0', - 'Cython==3.0.11', - 'wheel==0.44.0' - ], - install_requires=[ - 'Cython==3.0.11', - 'asyncio==3.4.3', - 'uvloop>=0.19.0', - 'python-dotenv==1.0.1', - 'configparser==6.0.0', - 'python-dateutil==2.8.2', - 'objectpath==0.6.1', - 'iso8601==2.1.0', - 'pycparser==2.21', - "orjson>=3.10.3", - 'pycryptodomex==3.20.0', - "cryptography>=43.0.1", - "aiofiles>=23.2.1,<=24.1.0", - "jsonpickle>=3.0.2", - ], - extras_require={ - "memcache": [ - "pylibmc==1.6.3", - "aiomcache==0.8.2", - ], - "gdrive": [ - 'PyDrive==1.3.1', - ], - "logstash": [ - 'python-logstash-async>=2.7.2,<=3.0.0', - ], - "redis": [ - 'redis==5.0.8', - ], - "toml": [ - 'pytomlpp==1.0.13' - ], - "yaml": [ - 'PyYAML>=6.0', - ], - "hvac": [ - "hvac==2.3.0" - ], - "default": [ - 'pytomlpp==1.0.13', - 'redis==5.0.8', - 'python-logstash-async==2.7.2', - 'PyYAML>=6.0', - "hvac==2.3.0" - ], - "all": [ - 'pytomlpp==1.0.13', - 'redis==5.0.8', - 'python-logstash-async==2.7.2', - 'PyYAML>=6.0', - "aiomcache==0.8.2", - "hvac==2.3.0" - ] - }, - ext_modules=cythonize(extensions), - project_urls={ # Optional - 'Source': 'https://github.com/phenobarbital/NavConfig', - 'Funding': 'https://paypal.me/phenobarbital', - 'Say Thanks!': 'https://saythanks.io/to/phenobarbital', - }, ) diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/site/index.html b/site/index.html new file mode 100644 index 00000000..a78d20b5 --- /dev/null +++ b/site/index.html @@ -0,0 +1,369 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>NavConfig — Universal Configuration Management for Python + + + + + + +
    +
    +

    NavConfig

    +

    Universal configuration management for Python projects — .env files, + INI/TOML/YAML, Redis and HashiCorp Vault behind a single, shared point of truth.

    +
    + MIT License + Python ≥ 3.10.1 + GitHub + PyPI +
    + +
    +
    + +
    + + +
    + +
    +

    Overview

    +

    NavConfig is the default configuration layer of the Navigator Framework, and works equally well as a + stand-alone tool for any Python application. It loads configuration directives from several sources and + combines them into one configuration container shared across modules:

    +
      +
    • Environment files (.env)
    • +
    • INI files, through configparser
    • +
    • TOML and YAML files
    • +
    • pyproject.toml
    • +
    • Redis
    • +
    • HashiCorp Vault
    • +
    • Python settings modules (settings/settings.py)
    • +
    +

    Secrets live in .env files or in Vault; structured settings live in INI/TOML/YAML files. + The separation keeps credentials out of the files you commit, and lets configuration vary per + environment without touching application code.

    +
    + +
    +

    Start with a working application

    +

    The usage guide walks through installation, a complete + .env file and a Python application with expected output. It also covers + INI sections, typed values + and troubleshooting.

    +
    + +
    +

    Installation

    +
    pip install navconfig
    +

    Optional backends are exposed as extras:

    +
    pip install "navconfig[redis]"      # Redis reader
    +pip install "navconfig[hvac]"      # HashiCorp Vault
    +pip install "navconfig[logstash]"  # Logstash logging handler
    +pip install "navconfig[all]"       # everything
    +
    + +
    +

    The kardex CLI

    +

    NavConfig ships a command line tool called kardex that bootstraps and maintains the + configuration layout of a project. It is organised in command groups, each with its own actions.

    +
    + + + + + + + + + + + +
    CommandWhat it does
    kardex env createCreate the default project structure.
    kardex env create --splitSame, plus the supplementary .env.* files.
    kardex env new <name>Add an environment from the shared env/.env.
    kardex vault createWrite the HashiCorp Vault directives into env/<env>/.env.
    kardex vault migratePush the variables of an environment into Vault.
    kardex vault save VAR:VALUEStore one or more variables in Vault.
    kardex log enableEnable the [logging] section of etc/config.ini.
    +
    +

    Every group accepts --path to point at a project root other than the current directory, + and kardex <group> <action> --help lists the full set of options.

    +
    + +
    +

    Bootstrap a project

    +
    kardex env create --env dev
    +

    This generates the following structure in the current directory:

    +
    .
    +|-- env/
    +|   |-- .env          template for new environments, no ENV= pin
    +|   +-- dev/
    +|       +-- .env      pinned to ENV=dev
    +|-- etc/
    +|   +-- config.ini
    +|-- logs/
    ++-- templates/
    +
      +
    • env/.env — the template used by kardex env new; it is not automatically merged into each environment at runtime.
    • +
    • env/dev/.env — environment variables: secrets, feature flags, paths.
    • +
    • etc/config.ini — INI settings consumed by NavConfig, including the [logging] section.
    • +
    • logs/ — where rotating log files are written.
    • +
    • templates/ — default directory for template files.
    • +
    +

    Existing files are never overwritten. Pass --force when you do want them replaced, and + --path to build the structure elsewhere:

    +
    kardex env create --env dev --path /srv/myapp
    +
    + +
    +

    Split configuration across several files

    +

    NavConfig loads .env first and then supplementary files, which keeps unrelated + concerns apart. Pass --split to create them all:

    +
    kardex env create --env dev --split
    +
    env/dev/
    +|-- .env             base configuration and Vault credentials
    +|-- .env.resources   paths and resource-level directives
    +|-- .env.databases   database connection settings
    +|-- .env.api         HTTP layer settings
    +|-- .env.cache       Redis / cache backend settings
    ++-- .env.local       local overrides, loaded last (keep out of git)
    +

    By default, auto-discovery loads .env first and then other .env* files + alphabetically. Set AUTO_DISCOVERY=false before starting Python to use the default + loader's fixed order shown above. All files contribute to a flat namespace.

    +

    With the default Vault loader, base .env values take priority over supplementary + values unless NAVCONFIG_FILE_OVERRIDE_ENABLED=true is set. That flag lets files + override Vault in the merged mapping too. Existing process variables are preserved by default, + and some typed accessors read those directly; avoid defining the same key in multiple sources.

    +
    + +
    +

    Environments

    +

    Add as many environments as the project needs:

    +
    kardex env new prod
    +kardex env new staging --split
    +

    Each command copies env/.env (or the bundled sample if no shared file exists) into + env/<name>/.env, adjusting the ENV variable automatically. Select one at + runtime through the ENV variable:

    +
    ENV=prod python app.py
    +

    NavConfig then loads env/prod/.env and any INI file referenced by its + CONFIG_FILE directive.

    +
    + +
    +

    HashiCorp Vault

    + +

    Configure the connection

    +
    kardex vault create --env dev \
    +    --url http://vault.internal:8200 \
    +    --token "$VAULT_TOKEN" \
    +    --mount-point myapp
    +

    This appends a delimited block to env/dev/.env — creating the file if it does not + exist yet — with the directives NavConfig actually reads:

    +
    VAULT_ENABLED=true
    +VAULT_URL=http://vault.internal:8200
    +VAULT_TOKEN=...
    +VAULT_MOUNT_POINT=myapp
    +VAULT_VERSION=2
    +# VAULT_ENV=
    +

    Secrets are read from <VAULT_MOUNT_POINT>/<ENV>/ and merged on top of the + file-based values. Set VAULT_ENV to read from a path segment other than ENV; + set NAVCONFIG_FILE_OVERRIDE_ENABLED=true to let the .env files win over Vault + instead.

    +

    Re-running the command updates only the directives you pass on the command line, which makes token + rotation a one-liner. Pass --force to rewrite the whole block.

    + +

    Migrate an existing .env into Vault

    +
    kardex vault migrate --env dev --dry-run   # inspect first
    +kardex vault migrate --env dev
    +

    Two families of variables are deliberately left in the .env file, because NavConfig needs + them before it can reach Vault:

    +
      +
    • the Vault directives themselves (VAULT_*), and
    • +
    • the bootstrap directives (ENV, CONFIG_FILE, SITE_ROOT, …), + which can be included anyway with --include-bootstrap.
    • +
    +

    Useful options: --include-extra (also migrate the .env.* files), + --keep-existing (never overwrite a key already stored in Vault), --file + (migrate an arbitrary file) and --yes (skip the confirmation prompt). Values are masked in + the output, and nothing is written until you confirm.

    + +

    Store single variables

    +
    kardex vault save DB_PASSWORD:s3cr3t
    +kardex vault save "DSN:postgres://user:pass@host:5432/db" API_KEY:abc123
    +

    Only the first colon separates the name from the value, so values may contain colons themselves.

    +
    + +
    +

    Logging

    +
    kardex log enable
    +

    That writes the [logging] section of etc/config.ini — creating the file + from the bundled sample when missing — with console and rotating-file output enabled, and creates + the log directory. Comments in the INI file are preserved.

    +

    To also forward records to a Logstash server:

    +
    kardex log enable --logstash \
    +    --logstash-host logs.internal \
    +    --logstash-port 5044 \
    +    --logstash-level INFO
    +
    +

    Note. The Logstash handler needs pip install "navconfig[logstash]". + NavConfig warns and falls back to the remaining handlers when the dependency or the server is + unavailable.

    +
    +

    Other options: --loglevel, --logdir, --quiet (no console + output), --no-file (no rotating file handler) and --mailer (email alerts on + CRITICAL records).

    +

    Apply the resulting configuration in your application:

    +
    import logging
    +from logging.config import dictConfig
    +from navconfig.logging import logging_config
    +
    +dictConfig(logging_config)
    +
    +logger = logging.getLogger("MY_APP")
    +logger.info("Hello World")
    +
    + +
    +

    Reading values

    +
    from navconfig import config
    +
    +APP_NAME = config.get("APP_NAME")   # "MyApp"
    +APP_NAME = config.APP_NAME          # attribute access also works
    + +

    Typed accessors

    +
    config.get("APP_NAME")                  # str
    +config.getint("PORT", fallback=8080)    # int
    +config.getboolean("DEBUG")              # bool
    +config.getlist("ALLOWED_HOSTS")         # list (comma-separated)
    +

    An optional fallback is returned when the key is not found:

    +
    config.get("MISSING_KEY", fallback="default_value")
    + +

    The second positional argument selects an INI section. Always pass defaults with + fallback=. See typed values and dictionaries + for complete examples.

    + +

    Initialization

    +

    NavConfig resolves the project layout and loads the environment the first time one of its + package-level names is accessed (config, BASE_DIR, DEBUG, + ENV, …), not while the package is being imported. Call + navconfig.bootstrap() when you need the environment loaded into os.environ as + a side effect without touching any of those names:

    +
    import navconfig
    +
    +navconfig.bootstrap()
    +
    + +
    +

    Project layout

    +

    By default NavConfig looks for files relative to the project root, based on the file type:

    +
    + + + + + + + + +
    File typeDefault location
    .envenv/, plus the environment subdirectory
    .yml / .tomlenv/
    pyproject.tomlproject root
    .inietc/
    +
    +

    A typical project looks like this:

    +
    myapp/
    +|-- __init__.py
    +|-- pyproject.toml
    +|-- env/
    +|   |-- .env          shared base file
    +|   |-- dev/
    +|   |   +-- .env
    +|   |-- staging/
    +|   |   +-- .env
    +|   +-- prod/
    +|       +-- .env
    +|-- etc/
    +|   +-- config.ini
    +|-- logs/
    ++-- settings/
    +    |-- __init__.py
    +    +-- settings.py   optional
    +
    + +
    +

    Settings module

    +

    Create a Python package called settings in your project to define configuration derived + from NavConfig values. Inside settings/settings.py:

    +
    import sys
    +from navconfig import config, DEBUG
    +
    +LOCAL_DEVELOPMENT = DEBUG is True and sys.argv[0] == "run.py"
    +SEND_NOTIFICATIONS = config.get("SEND_NOTIFICATIONS", fallback=True)
    +

    Variables defined there are accessible through navconfig.conf:

    +
    from navconfig.conf import LOCAL_DEVELOPMENT
    +
    +if LOCAL_DEVELOPMENT:
    +    print("Running in local development mode.")
    +
    + +
    +

    Upgrading to 3.0

    +
    +

    Breaking changes. NavConfig 3.0 reorganises the CLI into sub-commands and defers + initialization; there is no compatibility shim.

    +
    +
    + + + + + + + +
    2.x3.0
    kardex create --env devkardex env create --env dev
    kardex new-env prodkardex env new prod
    kardex new-env prod --vaultkardex env new prod then kardex vault create --env prod
    +
    +

    Two further changes are worth checking before you upgrade:

    +
      +
    • Initialization order. Importing navconfig no longer builds the + configuration by itself. Code that relied on import navconfig alone to populate + os.environ must call navconfig.bootstrap() explicitly, or read any exported + name. This is what makes kardex env create runnable on a project that has no + env/ directory yet.
    • +
    • Vault directives. The block written by kardex vault create uses + VAULT_URL, VAULT_MOUNT_POINT and VAULT_VERSION. The + VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID, + VAULT_SECRET_PATH and VAULT_NAMESPACE variables emitted by + new-env --vault in 2.x were never read by the loader.
    • +
    +
    + +
    +
    + + + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 00000000..e066373a --- /dev/null +++ b/site/styles.css @@ -0,0 +1,104 @@ +:root { + --bg: #ffffff; + --bg-soft: #f6f8fa; + --bg-code: #f6f8fa; + --fg: #1f2328; + --fg-muted: #59636e; + --border: #d1d9e0; + --accent: #0550ae; + --accent-soft: #ddf4ff; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --bg-soft: #151b23; + --bg-code: #151b23; + --fg: #e6edf3; + --fg-muted: #9198a1; + --border: #3d444d; + --accent: #6cb6ff; + --accent-soft: #121d2f; + } + } + * { box-sizing: border-box; } + html { scroll-behavior: smooth; scroll-padding-top: 1.5rem; } + body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 16px; + line-height: 1.6; + } + a { color: var(--accent); text-decoration: none; } + a:hover { text-decoration: underline; } + + header.hero { + border-bottom: 1px solid var(--border); + background: var(--bg-soft); + padding: 3rem 1.5rem 2.5rem; + } + .wrap { max-width: 1100px; margin: 0 auto; } + .hero h1 { margin: 0 0 .35rem; font-size: 2.5rem; letter-spacing: -.02em; } + .hero p.tagline { margin: 0 0 1.25rem; font-size: 1.15rem; color: var(--fg-muted); max-width: 46rem; } + .badges { display: flex; flex-wrap: wrap; gap: .5rem; } + .badge { + font-size: .8rem; padding: .2rem .6rem; border-radius: 999px; + border: 1px solid var(--border); background: var(--bg); color: var(--fg-muted); + } + + .layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 2.5rem; padding: 2.5rem 1.5rem 4rem; } + @media (max-width: 860px) { .layout { grid-template-columns: 1fr; gap: 1rem; } nav.toc { position: static !important; } } + + nav.toc { position: sticky; top: 1.5rem; align-self: start; font-size: .9rem; } + nav.toc strong { display: block; font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-muted); margin-bottom: .5rem; } + nav.toc ol { list-style: none; margin: 0; padding: 0; } + nav.toc li { margin: .15rem 0; } + nav.toc a { color: var(--fg-muted); display: block; padding: .15rem 0; } + nav.toc a:hover { color: var(--accent); } + + main { min-width: 0; } + section { margin-bottom: 3rem; } + h2 { font-size: 1.6rem; margin: 0 0 .75rem; padding-bottom: .35rem; border-bottom: 1px solid var(--border); letter-spacing: -.01em; } + h3 { font-size: 1.15rem; margin: 1.75rem 0 .5rem; } + p, ul, ol { margin: 0 0 1rem; } + ul, ol { padding-left: 1.35rem; } + li { margin: .25rem 0; } + + code { + font-family: var(--mono); font-size: .875em; + background: var(--accent-soft); padding: .12em .35em; border-radius: 5px; + } + pre { + background: var(--bg-code); border: 1px solid var(--border); border-radius: 8px; + padding: 1rem; overflow-x: auto; margin: 0 0 1rem; line-height: 1.5; + } + pre code { background: none; padding: 0; font-size: .85rem; } + + .table-scroll { overflow-x: auto; margin-bottom: 1rem; } + table { border-collapse: collapse; width: 100%; font-size: .92rem; } + th, td { border: 1px solid var(--border); padding: .5rem .7rem; text-align: left; vertical-align: top; } + th { background: var(--bg-soft); font-weight: 600; } + + .note { + border-left: 3px solid var(--accent); background: var(--accent-soft); + padding: .75rem 1rem; border-radius: 0 6px 6px 0; margin: 0 0 1rem; + } + .note p:last-child { margin-bottom: 0; } + .note strong { color: var(--fg); } + + footer { + border-top: 1px solid var(--border); padding: 2rem 1.5rem; + color: var(--fg-muted); font-size: .9rem; background: var(--bg-soft); + } + +.site-nav { display: flex; flex-wrap: wrap; gap: .75rem 1.5rem; margin: 1.5rem 0 0; } +.site-nav a { font-weight: 600; padding: .35rem 0; } +.site-nav a[aria-current="page"] { border-bottom: 2px solid var(--accent); } +a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } +.skip-link { position: absolute; top: -5rem; left: 1rem; padding: .75rem; background: var(--bg); z-index: 1; } +.skip-link:focus { top: 1rem; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } + diff --git a/site/usage.html b/site/usage.html new file mode 100644 index 00000000..a36b3a1f --- /dev/null +++ b/site/usage.html @@ -0,0 +1,251 @@ + + + + + + Usage guide — NavConfig + + + + + +
    +
    +

    NavConfig usage guide

    +

    From your first environment file to configuration shared across a Python application.

    +
    + Python ≥ 3.10.1 + MIT License + PyPI +
    + +
    +
    +
    + +
    +
    +

    Runnable quickstart

    +

    Use a new project directory and Python 3.10.1 or newer. These shell commands use Bash on Linux or macOS.

    +

    1. Install and create the configuration layout

    +
    mkdir navconfig-demo
    +cd navconfig-demo
    +python3 -m venv .venv
    +source .venv/bin/activate
    +python -m pip install navconfig
    +kardex env create --env dev
    +

    On Windows, activate with .venv\Scripts\Activate.ps1 in PowerShell. + The CLI creates env/dev/.env, etc/config.ini, logs/ + and templates/. Existing files are preserved unless you pass --force.

    +

    2. Edit env/dev/.env

    +

    Use these values for the demo:

    +
    ENV=dev
    +APP_NAME=NavConfig Demo
    +PORT=8080
    +DEBUG=true
    +ALLOWED_HOSTS=localhost,127.0.0.1
    +CONFIG_FILE=etc/config.ini
    +VAULT_ENABLED=false
    +

    3. Create app.py

    +
    from navconfig import config
    +
    +app_name: str = config.get("APP_NAME", fallback="My application")
    +port: int = config.getint("PORT", fallback=8000)
    +debug: bool = config.getboolean("DEBUG", fallback=False)
    +allowed_hosts: list[str] = config.getlist("ALLOWED_HOSTS")
    +
    +print(f"{app_name} on port {port}")
    +print(f"Debug: {debug}")
    +print(f"Allowed hosts: {', '.join(allowed_hosts)}")
    +

    4. Run the application

    +

    Set the project root and environment before Python starts so discovery is unambiguous:

    +
    SITE_ROOT="$PWD" ENV=dev python app.py
    +

    PowerShell equivalent:

    +
    $env:SITE_ROOT = (Get-Location).Path
    +$env:ENV = "dev"
    +python app.py
    +

    Application output:

    +
    NavConfig Demo on port 8080
    +Debug: True
    +Allowed hosts: localhost, 127.0.0.1
    +
    + +
    +

    Typed values and defaults

    +

    Environment values normally arrive as strings. Select an accessor for the type your application needs.

    +
    + + + + + + + + + +
    AccessorExampleResult with the demo
    General valueconfig.get("APP_NAME")"NavConfig Demo"
    Integerconfig.getint("PORT", fallback=8000)8080
    Booleanconfig.getboolean("DEBUG", fallback=False)True
    Comma-separated listconfig.getlist("ALLOWED_HOSTS")["localhost", "127.0.0.1"]
    Missing valueconfig.get("UNSET_KEY", fallback="default")"default"
    +
    +
    +

    Use the fallback= keyword. The second positional argument of get, + getint, getboolean and getlist is an INI section name.

    +
    +

    getlist returns an empty list for a missing value and does not strip whitespace + around items. Use comma-separated values without spaces, or strip each item in your application.

    +

    For required values, check explicitly:

    +
    from navconfig import config
    +
    +app_name: str | None = config.get("APP_NAME")
    +if not app_name:
    +    raise ValueError("APP_NAME must be configured")
    +

    Attribute access such as config.APP_NAME is also available; absent or empty values + raise AttributeError. It does not convert strings into integers or booleans.

    +

    Dictionaries

    +

    getdict can retrieve dictionaries already present in the configuration mapping; + it is not a JSON parser for .env strings. For JSON stored in an environment value, + parse it explicitly with Python's standard library:

    +
    EXTRA='{"region": "eu", "retries": 3}'
    +
    import json
    +from typing import Any
    +
    +from navconfig import config
    +
    +extra: dict[str, Any] = json.loads(config.get("EXTRA", fallback="{}"))
    +
    + +
    +

    Read an INI section

    +

    Add this section to etc/config.ini. The CONFIG_FILE directive in + your environment file selects the file; relative paths resolve from SITE_ROOT.

    +
    [service]
    +host = 127.0.0.1
    +port = 9000
    +enabled = true
    +
    from navconfig import config
    +
    +host: str = config.get("host", section="service", fallback="localhost")
    +port: int = config.getint("port", section="service", fallback=8000)
    +enabled: bool = config.getboolean("enabled", section="service", fallback=False)
    +service: dict[str, str] = config.section("service")
    +

    config.section("service") returns the section as a dictionary of strings; + a missing section returns {}.

    +
    + +
    +

    Use multiple environments

    +
    kardex env new prod
    +kardex env new staging --split
    +

    New environments copy the shared env/.env template, or the bundled sample when + that template is missing. Customize env/prod/.env for production and start a new process:

    +
    SITE_ROOT="$PWD" ENV=prod python app.py
    +

    ENV selects env/<name>/ at initialization. The shared + env/.env is a scaffold template, not an automatically inherited runtime layer.

    +

    Use kardex env create --env dev --split for supplementary environment files. + See file discovery and precedence before repeating keys across files.

    +

    For remote secrets, follow the HashiCorp Vault setup and migration guide.

    +
    + +
    +

    Initialize before starting async work

    +

    import navconfig alone does not load configuration. Reading an exported name such as + config or DEBUG initializes the shared container. Select + SITE_ROOT and ENV before that first access.

    +

    Call bootstrap() when you need environment-loading side effects explicitly. + For async applications, initialize before starting the event loop:

    +
    import asyncio
    +
    +import navconfig
    +
    +config = navconfig.bootstrap()
    +
    +
    +async def main() -> None:
    +    print(config.get("APP_NAME", fallback="My application"))
    +
    +
    +if __name__ == "__main__":
    +    asyncio.run(main())
    +

    Repeated calls return the same container. If the optional navconfig[uvloop] + extra is installed on a supported platform, bootstrap activates it. Without it, the current + asyncio event loop policy is preserved.

    +
    + +
    +

    Define Python settings

    +

    Create a settings/ directory with an empty __init__.py and this + settings.py file:

    +
    from navconfig import config
    +
    +SEND_NOTIFICATIONS: bool = config.getboolean("SEND_NOTIFICATIONS", fallback=False)
    +APP_TITLE: str = config.get("APP_NAME", fallback="My application")
    +

    Use the derived settings elsewhere in your application:

    +
    from navconfig.conf import APP_TITLE, SEND_NOTIFICATIONS
    +
    +print(APP_TITLE)
    +if SEND_NOTIFICATIONS:
    +    print("Notifications are enabled")
    +

    The settings module is optional. Importing navconfig.conf also changes the process + working directory to BASE_DIR.

    +
    + +
    +

    Configure application logging

    +

    Enable console and rotating-file logging in the project's INI file:

    +
    kardex log enable
    +
    import logging
    +from logging.config import dictConfig
    +
    +from navconfig.logging import logging_config
    +
    +dictConfig(logging_config)
    +logger = logging.getLogger("myapp")
    +logger.info("Application started")
    +

    See logging options for log levels, output directories and Logstash.

    +
    + +
    +

    Troubleshooting

    +

    Configuration files are missing

    +

    Run kardex env create --env dev from the project root. Confirm the selected + ENV directory exists and set SITE_ROOT explicitly when using a + virtual environment outside your project.

    +

    A default value is ignored

    +

    Use config.get("KEY", fallback="default"). Passing the default as the second + argument selects an INI section instead.

    +

    A value differs between accessors

    +

    The default loader builds a merged mapping and also populates os.environ. + Existing process variables are preserved by default. get prefers the mapping, + while accessors such as getint and attribute access can read the process + environment directly. Keep each key in one source when consistent values are required.

    +

    A CLI command is unavailable

    +

    Activate the environment where NavConfig is installed, then run kardex --help. + This guide follows the repository's grouped CLI; older installations may expose different + commands. See the CLI migration notes.

    +

    Report reproducible problems in the issue tracker.

    +
    +
    +
    + + + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..d387a291 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,320 @@ +"""Tests for the ``kardex`` command line interface. + +These must run without a scaffolded project: the whole point of +``kardex env create`` is to bootstrap one, so importing the CLI may never +require ``env/`` to exist already. +""" +import subprocess +import sys +from configparser import ConfigParser + +import pytest + +from navconfig.cli import main +from navconfig.commands.common import SPLIT_ENV_FILES, VAULT_BLOCK_START +from navconfig.commands.vault import ( + _parse_pair, + collect_migratable, + is_vault_variable, +) + +# --------------------------------------------------------------------------- +# Initialization order +# --------------------------------------------------------------------------- + +def test_cli_importable_without_project(tmp_path): + """``kardex`` must load in a directory that has no env/ folder. + + Regression test: the CLI used to build the global configuration at + import time, so ``kardex create`` crashed on exactly the projects it + was meant to scaffold. + """ + result = subprocess.run( + [sys.executable, "-m", "navconfig.cli", "env", "create"], + cwd=tmp_path, + env={"PATH": "/usr/bin:/bin", "SITE_ROOT": str(tmp_path)}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "env" / "dev" / ".env").exists() + + +def test_import_navconfig_does_not_build_config(): + """Importing the package must not instantiate Kardex on its own.""" + import navconfig + + # Accessing a lazy name is what triggers the bootstrap; the module + # namespace stays clean until then. + assert "bootstrap" in navconfig.__all__ + assert callable(navconfig.bootstrap) + + +# --------------------------------------------------------------------------- +# kardex env +# --------------------------------------------------------------------------- + +def test_env_create(tmp_path): + assert main(["env", "create", "--env", "qa", "--path", str(tmp_path)]) == 0 + + env_file = tmp_path / "env" / "qa" / ".env" + base_file = tmp_path / "env" / ".env" + config_file = tmp_path / "etc" / "config.ini" + + assert env_file.exists() + assert base_file.exists() + assert config_file.exists() + assert (tmp_path / "logs").is_dir() + assert (tmp_path / "templates").is_dir() + + assert "ENV=qa" in env_file.read_text() + # The shared file must not pin an environment. + assert "\nENV=" not in base_file.read_text() + assert "[navconfig]" in config_file.read_text() + + +def test_env_create_split(tmp_path): + assert main( + ["env", "create", "--env", "dev", "--path", str(tmp_path), "--split"] + ) == 0 + + env_dir = tmp_path / "env" / "dev" + assert (env_dir / ".env").exists() + for filename in SPLIT_ENV_FILES: + assert (env_dir / filename).exists(), filename + + +def test_env_create_is_idempotent(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + env_file = tmp_path / "env" / "dev" / ".env" + env_file.write_text("ENV=dev\nCUSTOM=kept\n", encoding="utf-8") + + assert main(["env", "create", "--path", str(tmp_path)]) == 0 + assert "CUSTOM=kept" in env_file.read_text() + + assert main(["env", "create", "--path", str(tmp_path), "--force"]) == 0 + assert "CUSTOM=kept" not in env_file.read_text() + + +def test_env_new(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + assert main(["env", "new", "prod", "--path", str(tmp_path)]) == 0 + + env_file = tmp_path / "env" / "prod" / ".env" + assert "ENV=prod" in env_file.read_text() + + # A second run refuses to clobber the file... + assert main(["env", "new", "prod", "--path", str(tmp_path)]) == 1 + # ...unless --force is given. + assert main(["env", "new", "prod", "--path", str(tmp_path), "--force"]) == 0 + + +def test_create_command_is_gone(): + """The flat ``kardex create`` command no longer exists.""" + with pytest.raises(SystemExit): + main(["create"]) + + +# --------------------------------------------------------------------------- +# kardex vault +# --------------------------------------------------------------------------- + +def test_vault_create_bootstraps_env_file(tmp_path): + exit_code = main([ + "vault", "create", + "--env", "dev", + "--path", str(tmp_path), + "--url", "http://vault.internal:8200", + "--token", "s.token", + "--mount-point", "myapp", + ]) + assert exit_code == 0 + + content = (tmp_path / "env" / "dev" / ".env").read_text() + assert VAULT_BLOCK_START in content + assert "VAULT_ENABLED=true" in content + assert "VAULT_URL=http://vault.internal:8200" in content + assert "VAULT_TOKEN=s.token" in content + assert "VAULT_MOUNT_POINT=myapp" in content + # VAULT_ADDR is not a directive NavConfig reads. + assert "VAULT_ADDR" not in content + + +def test_vault_create_rewrites_its_own_block(tmp_path): + main(["vault", "create", "--path", str(tmp_path), "--token", "first"]) + env_file = tmp_path / "env" / "dev" / ".env" + + # Without --force the block is kept, but explicit flags still apply. + main(["vault", "create", "--path", str(tmp_path), "--token", "second"]) + content = env_file.read_text() + assert content.count(VAULT_BLOCK_START) == 1 + assert "VAULT_TOKEN=second" in content + + # With --force the block is replaced, never duplicated. + main(["vault", "create", "--path", str(tmp_path), "--force", "--token", "third"]) + content = env_file.read_text() + assert content.count(VAULT_BLOCK_START) == 1 + assert content.count("VAULT_ENABLED=") == 1 + assert "VAULT_TOKEN=third" in content + + +def test_is_vault_variable(): + assert is_vault_variable("VAULT_TOKEN") + assert is_vault_variable("VAULT_ANYTHING") + assert not is_vault_variable("DATABASE_URL") + + +def test_collect_migratable_excludes_vault_and_bootstrap(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + main(["vault", "create", "--path", str(tmp_path), "--token", "s.token"]) + env_file = tmp_path / "env" / "dev" / ".env" + env_file.write_text( + env_file.read_text() + "\nDB_PASSWORD=secret\n", encoding="utf-8" + ) + + values, excluded = collect_migratable(project_root=tmp_path, env="dev") + + assert values["DB_PASSWORD"] == "secret" + assert "VAULT_TOKEN" not in values + assert "VAULT_URL" not in values + assert "ENV" not in values + assert "CONFIG_FILE" not in values + assert excluded["VAULT_TOKEN"] == "configures the Vault connection" + assert excluded["ENV"] == "NavConfig bootstrap directive" + + +def test_collect_migratable_include_bootstrap(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + values, _ = collect_migratable( + project_root=tmp_path, env="dev", include_bootstrap=True + ) + assert "ENV" in values + + +def test_collect_migratable_include_extra(tmp_path): + main(["env", "create", "--path", str(tmp_path), "--split"]) + values, _ = collect_migratable( + project_root=tmp_path, env="dev", include_extra=True + ) + assert "DBNAME" in values # from .env.databases + assert "API_PORT" in values # from .env.api + + +def test_vault_migrate_dry_run_does_not_connect(tmp_path, capsys): + main(["env", "create", "--path", str(tmp_path)]) + env_file = tmp_path / "env" / "dev" / ".env" + env_file.write_text( + env_file.read_text() + "\nDB_PASSWORD=secret\n", encoding="utf-8" + ) + + exit_code = main([ + "vault", "migrate", + "--path", str(tmp_path), + "--url", "http://127.0.0.1:1", # nothing listens here + "--token", "s.token", + "--dry-run", + ]) + assert exit_code == 0 + + output = capsys.readouterr().out + assert "Dry run" in output + # Secrets are never echoed in the clear. + assert "secret" not in output + assert "DB_PASSWORD" in output + + +def test_vault_migrate_unreachable_server_fails_cleanly(tmp_path, capsys): + main(["env", "create", "--path", str(tmp_path)]) + exit_code = main([ + "vault", "migrate", + "--path", str(tmp_path), + "--url", "http://127.0.0.1:1", + "--token", "s.token", + "--yes", + ]) + assert exit_code == 1 + assert "Unable to reach Vault" in capsys.readouterr().err + + +def test_vault_save_parses_pairs(): + assert _parse_pair("KEY:value") == ("KEY", "value") + # Only the first colon separates name from value. + assert _parse_pair("DSN:postgres://u:p@h:5432/db") == ( + "DSN", "postgres://u:p@h:5432/db" + ) + + +def test_vault_save_rejects_malformed_pair(tmp_path, capsys): + assert main(["vault", "save", "NOCOLON", "--path", str(tmp_path)]) == 1 + assert "VARIABLE:VALUE" in capsys.readouterr().err + + +def test_vault_save_requires_token(tmp_path, capsys): + main(["env", "create", "--path", str(tmp_path)]) + assert main(["vault", "save", "K:v", "--path", str(tmp_path)]) == 1 + assert "VAULT_TOKEN is not set" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# kardex log +# --------------------------------------------------------------------------- + +def _logging_section(tmp_path) -> dict: + parser = ConfigParser() + parser.read(tmp_path / "etc" / "config.ini", encoding="utf-8") + return dict(parser["logging"]) + + +def test_log_enable(tmp_path): + assert main(["log", "enable", "--path", str(tmp_path)]) == 0 + + section = _logging_section(tmp_path) + assert section["logging_echo"] == "true" + assert section["filehandler_enabled"] == "true" + assert section["logstash_enabled"] == "false" + assert "RotatingFileHandler" in section["handlers"] + assert (tmp_path / "logs").is_dir() + + +def test_log_enable_logstash(tmp_path): + exit_code = main([ + "log", "enable", + "--path", str(tmp_path), + "--logstash", + "--logstash-host", "logs.internal", + "--logstash-port", "5959", + "--loglevel", "WARNING", + ]) + assert exit_code == 0 + + section = _logging_section(tmp_path) + assert section["logstash_enabled"] == "true" + assert section["logging_host"] == "logs.internal" + assert section["logging_port"] == "5959" + assert section["loglevel"] == "WARNING" + assert "LogstashHandler" in section["handlers"] + + +def test_log_enable_preserves_ini_comments(tmp_path): + main(["log", "enable", "--path", str(tmp_path)]) + content = (tmp_path / "etc" / "config.ini").read_text() + assert "# Logging configuration." in content + assert "[temp]" in content + + +def test_log_enable_does_not_duplicate_handlers(tmp_path): + main(["log", "enable", "--path", str(tmp_path)]) + main(["log", "enable", "--path", str(tmp_path)]) + + handlers = _logging_section(tmp_path)["handlers"].split(",") + assert len(handlers) == len(set(handlers)) + + +def test_log_enable_quiet_and_no_file(tmp_path): + main([ + "log", "enable", "--path", str(tmp_path), "--quiet", "--no-file" + ]) + section = _logging_section(tmp_path) + assert section["logging_echo"] == "false" + assert section["filehandler_enabled"] == "false" diff --git a/tests/test_cli_env.py b/tests/test_cli_env.py new file mode 100644 index 00000000..3b3fd866 --- /dev/null +++ b/tests/test_cli_env.py @@ -0,0 +1,426 @@ +"""Tests for the ``kardex env`` command group. + +``kardex env create`` is the command that bootstraps a NavConfig project, +so every test here starts from an empty directory: nothing may require an +``env/`` folder -- or the global configuration -- to already exist. +""" +import subprocess +import sys +from configparser import ConfigParser +from pathlib import Path + +import pytest + +from navconfig.cli import main +from navconfig.commands.common import BASE_ENV_FILE, SPLIT_ENV_FILES, read_sample +from navconfig.commands.env import ( + _base_env_content, + create_environment, + new_environment, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def env_lines(path: Path) -> list: + """Return the active (non commented-out) ``KEY=VALUE`` lines of a file.""" + return [ + line for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def values_of(path: Path) -> dict: + """Parse a ``.env`` file into a ``{KEY: VALUE}`` mapping.""" + return dict( + line.split("=", 1) for line in env_lines(path) if "=" in line + ) + + +def run_cli(tmp_path: Path, *argv: str) -> subprocess.CompletedProcess: + """Run ``python -m navconfig.cli`` inside *tmp_path*.""" + return subprocess.run( + [sys.executable, "-m", "navconfig.cli", *argv], + cwd=tmp_path, + env={"PATH": "/usr/bin:/bin", "SITE_ROOT": str(tmp_path)}, + capture_output=True, + text=True, + check=False, + ) + + +# --------------------------------------------------------------------------- +# _base_env_content +# --------------------------------------------------------------------------- + +def test_base_env_content_comments_out_the_env_pin(): + """The shared file documents ``ENV`` but never assigns it.""" + body = _base_env_content("APP_NAME=MyApp\nENV=dev\nDEBUG=true\n") + + assert "# ENV=" in body + assert "\nENV=" not in f"\n{body}" + + +def test_base_env_content_preserves_every_other_directive(): + body = _base_env_content("APP_NAME=MyApp\nENV=dev\nDEBUG=true\n") + + assert "APP_NAME=MyApp" in body + assert "DEBUG=true" in body + + +def test_base_env_content_keeps_comments_and_trailing_newline(): + body = _base_env_content("# a comment\nENV=dev") + + assert body.startswith("# a comment") + assert body.endswith("\n") + + +# --------------------------------------------------------------------------- +# create_environment() +# --------------------------------------------------------------------------- + +def test_create_environment_reports_every_artifact(tmp_path): + """The ``created`` mapping is what the CLI prints back to the user.""" + created, skipped = create_environment(env="dev", project_root=tmp_path) + + assert set(created) == { + "env/.env", "env/dev/.env", "etc/config.ini", "logs/", "templates/" + } + assert skipped == {} + assert all(Path(path).exists() for path in created.values()) + + +def test_create_environment_second_run_skips_existing_files(tmp_path): + create_environment(env="dev", project_root=tmp_path) + created, skipped = create_environment(env="dev", project_root=tmp_path) + + assert set(skipped) == {"env/.env", "env/dev/.env", "etc/config.ini"} + # Directories are idempotent, so they are always reported as created. + assert set(created) == {"logs/", "templates/"} + + +def test_create_environment_force_rewrites_files(tmp_path): + create_environment(env="dev", project_root=tmp_path) + created, skipped = create_environment( + env="dev", project_root=tmp_path, force=True + ) + + assert skipped == {} + assert "env/dev/.env" in created + + +def test_create_environment_split_writes_the_bundled_samples(tmp_path): + created, _ = create_environment( + env="dev", project_root=tmp_path, split=True + ) + + for filename in SPLIT_ENV_FILES: + label = f"env/dev/{filename}" + assert label in created, label + assert created[label].read_text(encoding="utf-8") == read_sample( + f"{filename}.sample" + ) + + +def test_create_environment_without_split_creates_no_extra_files(tmp_path): + create_environment(env="dev", project_root=tmp_path) + + env_dir = tmp_path / "env" / "dev" + assert sorted(p.name for p in env_dir.iterdir()) == [BASE_ENV_FILE] + + +# --------------------------------------------------------------------------- +# kardex env create +# --------------------------------------------------------------------------- + +def test_env_create_defaults_to_dev(tmp_path): + assert main(["env", "create", "--path", str(tmp_path)]) == 0 + + assert (tmp_path / "env" / "dev" / BASE_ENV_FILE).exists() + assert values_of(tmp_path / "env" / "dev" / BASE_ENV_FILE)["ENV"] == "dev" + + +@pytest.mark.parametrize("name", ["prod", "staging", "qa2", "prod-eu"]) +def test_env_create_accepts_any_environment_name(tmp_path, name): + assert main( + ["env", "create", "--env", name, "--path", str(tmp_path)] + ) == 0 + + env_file = tmp_path / "env" / name / BASE_ENV_FILE + assert values_of(env_file)["ENV"] == name + + +def test_env_create_makes_a_missing_project_root(tmp_path): + """``--path`` may point at a directory that does not exist yet.""" + project = tmp_path / "brand" / "new" + + assert main(["env", "create", "--path", str(project)]) == 0 + assert (project / "env" / "dev" / BASE_ENV_FILE).exists() + + +def test_env_create_defaults_to_the_current_directory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + assert main(["env", "create"]) == 0 + assert (tmp_path / "env" / "dev" / BASE_ENV_FILE).exists() + + +def test_env_create_shared_file_is_never_pinned(tmp_path): + main(["env", "create", "--env", "qa", "--path", str(tmp_path)]) + + base_file = tmp_path / "env" / BASE_ENV_FILE + assert "ENV" not in values_of(base_file) + assert "# ENV=" in base_file.read_text(encoding="utf-8") + # ...but the rest of the sample is still there to edit. + assert "CONFIG_FILE" in values_of(base_file) + + +def test_env_create_writes_a_parseable_ini(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + + parser = ConfigParser() + parser.read(tmp_path / "etc" / "config.ini", encoding="utf-8") + assert parser.has_section("navconfig") + + +def test_env_create_split_creates_the_supplementary_files(tmp_path): + assert main( + ["env", "create", "--path", str(tmp_path), "--split"] + ) == 0 + + env_dir = tmp_path / "env" / "dev" + assert sorted(p.name for p in env_dir.iterdir()) == sorted( + (BASE_ENV_FILE, *SPLIT_ENV_FILES) + ) + assert "API_PORT" in values_of(env_dir / ".env.api") + assert "DBNAME" in values_of(env_dir / ".env.databases") + + +def test_env_create_preserves_edits_and_force_discards_them(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + env_file = tmp_path / "env" / "dev" / BASE_ENV_FILE + env_file.write_text("ENV=dev\nSECRET=keep-me\n", encoding="utf-8") + + assert main(["env", "create", "--path", str(tmp_path)]) == 0 + assert values_of(env_file)["SECRET"] == "keep-me" + + assert main(["env", "create", "--path", str(tmp_path), "--force"]) == 0 + assert "SECRET" not in values_of(env_file) + assert values_of(env_file)["ENV"] == "dev" + + +def test_env_create_split_does_not_clobber_supplementary_files(tmp_path): + main(["env", "create", "--path", str(tmp_path), "--split"]) + local = tmp_path / "env" / "dev" / ".env.local" + local.write_text("DEBUG=false\n", encoding="utf-8") + + assert main(["env", "create", "--path", str(tmp_path), "--split"]) == 0 + assert local.read_text(encoding="utf-8") == "DEBUG=false\n" + + +def test_env_create_reports_next_steps(tmp_path, capsys): + main(["env", "create", "--env", "qa", "--path", str(tmp_path)]) + + output = capsys.readouterr().out + assert str(tmp_path) in output + assert "created env/qa/.env" in output + assert "ENV=qa" in output # the "Next:" hint + + +def test_env_create_reports_skipped_files(tmp_path, capsys): + main(["env", "create", "--path", str(tmp_path)]) + capsys.readouterr() + + main(["env", "create", "--path", str(tmp_path)]) + output = capsys.readouterr().out + assert "skipped env/dev/.env" in output + assert "already exists" in output + + +# --------------------------------------------------------------------------- +# kardex env new +# --------------------------------------------------------------------------- + +def test_env_new_inherits_the_shared_base_file(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + base_file = tmp_path / "env" / BASE_ENV_FILE + base_file.write_text( + base_file.read_text(encoding="utf-8") + "\nSHARED=yes\n", + encoding="utf-8", + ) + + assert main(["env", "new", "prod", "--path", str(tmp_path)]) == 0 + + values = values_of(tmp_path / "env" / "prod" / BASE_ENV_FILE) + assert values["SHARED"] == "yes" + assert values["ENV"] == "prod" + + +def test_env_new_falls_back_to_the_bundled_sample(tmp_path): + """``env new`` works on a project that was never scaffolded.""" + assert main(["env", "new", "staging", "--path", str(tmp_path)]) == 0 + + env_file = tmp_path / "env" / "staging" / BASE_ENV_FILE + values = values_of(env_file) + assert values["ENV"] == "staging" + assert "CONFIG_FILE" in values + + +def test_env_new_repins_env_only_once(tmp_path): + """Whatever the base was pinned to, exactly one ``ENV=`` survives.""" + base_file = tmp_path / "env" / BASE_ENV_FILE + base_file.parent.mkdir(parents=True) + base_file.write_text("ENV=dev\nAPP_NAME=MyApp\n", encoding="utf-8") + + main(["env", "new", "prod", "--path", str(tmp_path)]) + + lines = env_lines(tmp_path / "env" / "prod" / BASE_ENV_FILE) + assert lines.count("ENV=prod") == 1 + assert not any(line.startswith("ENV=dev") for line in lines) + + +def test_env_new_refuses_to_overwrite(tmp_path, capsys): + main(["env", "new", "prod", "--path", str(tmp_path)]) + env_file = tmp_path / "env" / "prod" / BASE_ENV_FILE + env_file.write_text("ENV=prod\nSECRET=keep-me\n", encoding="utf-8") + + assert main(["env", "new", "prod", "--path", str(tmp_path)]) == 1 + + error = capsys.readouterr().err + assert "already exists" in error + assert "--force" in error + # The refusal must leave the file exactly as it was. + assert values_of(env_file)["SECRET"] == "keep-me" + + +def test_env_new_force_overwrites(tmp_path, capsys): + main(["env", "new", "prod", "--path", str(tmp_path)]) + env_file = tmp_path / "env" / "prod" / BASE_ENV_FILE + env_file.write_text("ENV=prod\nSECRET=drop-me\n", encoding="utf-8") + + assert main( + ["env", "new", "prod", "--path", str(tmp_path), "--force"] + ) == 0 + + values = values_of(env_file) + assert "SECRET" not in values + assert values["ENV"] == "prod" + assert "created env/prod/.env" in capsys.readouterr().out + + +def test_env_new_split_creates_the_supplementary_files(tmp_path): + main(["env", "create", "--path", str(tmp_path)]) + + assert main( + ["env", "new", "prod", "--path", str(tmp_path), "--split"] + ) == 0 + + env_dir = tmp_path / "env" / "prod" + for filename in SPLIT_ENV_FILES: + assert (env_dir / filename).exists(), filename + + +def test_env_new_split_skips_existing_files(tmp_path, capsys): + """A supplementary file that is already there is reported, not rewritten.""" + local = tmp_path / "env" / "prod" / ".env.local" + local.parent.mkdir(parents=True) + local.write_text("LOGLEVEL=ERROR\n", encoding="utf-8") + + assert main( + ["env", "new", "prod", "--path", str(tmp_path), "--split"] + ) == 0 + + assert local.read_text(encoding="utf-8") == "LOGLEVEL=ERROR\n" + output = capsys.readouterr().out + assert "skipped env/prod/.env.local" in output + + +def test_env_new_split_force_refreshes_existing_files(tmp_path): + main(["env", "new", "prod", "--path", str(tmp_path), "--split"]) + local = tmp_path / "env" / "prod" / ".env.local" + local.write_text("LOGLEVEL=ERROR\n", encoding="utf-8") + + assert main( + ["env", "new", "prod", "--path", str(tmp_path), "--split", "--force"] + ) == 0 + assert "LOGLEVEL=ERROR" not in local.read_text(encoding="utf-8") + + +def test_env_new_leaves_sibling_environments_untouched(tmp_path): + main(["env", "create", "--env", "dev", "--path", str(tmp_path)]) + dev_file = tmp_path / "env" / "dev" / BASE_ENV_FILE + dev_file.write_text("ENV=dev\nDEV_ONLY=1\n", encoding="utf-8") + + main(["env", "new", "prod", "--path", str(tmp_path)]) + + assert values_of(dev_file) == {"ENV": "dev", "DEV_ONLY": "1"} + assert sorted( + p.name for p in (tmp_path / "env").iterdir() if p.is_dir() + ) == ["dev", "prod"] + + +def test_new_environment_returns_created_and_skipped(tmp_path): + new_environment(name="prod", project_root=tmp_path, split=True) + created, skipped = new_environment( + name="prod", project_root=tmp_path, split=True, force=True + ) + + assert f"env/prod/{BASE_ENV_FILE}" in created + assert skipped == {} + + +# --------------------------------------------------------------------------- +# Parser wiring +# --------------------------------------------------------------------------- + +def test_env_requires_an_action(): + with pytest.raises(SystemExit) as excinfo: + main(["env"]) + assert excinfo.value.code == 2 + + +def test_env_rejects_unknown_actions(): + with pytest.raises(SystemExit) as excinfo: + main(["env", "destroy"]) + assert excinfo.value.code == 2 + + +def test_env_new_requires_a_name(): + with pytest.raises(SystemExit) as excinfo: + main(["env", "new"]) + assert excinfo.value.code == 2 + + +def test_env_help_lists_both_actions(capsys): + with pytest.raises(SystemExit) as excinfo: + main(["env", "--help"]) + assert excinfo.value.code == 0 + + output = capsys.readouterr().out + assert "create" in output + assert "new" in output + + +# --------------------------------------------------------------------------- +# End-to-end, through the installed entry point +# --------------------------------------------------------------------------- + +def test_env_new_from_the_command_line_without_a_project(tmp_path): + """``kardex env new`` must not need a configured project either.""" + result = run_cli(tmp_path, "env", "new", "prod", "--split") + + assert result.returncode == 0, result.stderr + env_dir = tmp_path / "env" / "prod" + assert (env_dir / BASE_ENV_FILE).exists() + assert (env_dir / ".env.local").exists() + + +def test_env_create_then_new_from_the_command_line(tmp_path): + assert run_cli(tmp_path, "env", "create").returncode == 0 + result = run_cli(tmp_path, "env", "new", "prod") + + assert result.returncode == 0, result.stderr + assert "created" in result.stdout + assert (tmp_path / "env" / "prod" / BASE_ENV_FILE).exists() diff --git a/tests/test_config.py b/tests/test_config.py index b86a0d43..fe82d9e9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,12 +8,12 @@ from navconfig.logging import logging_config -pytestmark = pytest.mark.asyncio -loop = asyncio.new_event_loop() -asyncio.set_event_loop(loop) +# Modern pytest-asyncio configuration +pytestmark = pytest.mark.asyncio(scope="session") -async def test_config(event_loop): - # os.environ["DEBUG"] = 'True' # Force Set Debug to TRUE + +# Your existing tests (unchanged) +async def test_config(): from navconfig import config, SITE_ROOT, BASE_DIR, DEBUG current_path = Path(__file__).resolve().parent.parent assert current_path == SITE_ROOT @@ -21,7 +21,7 @@ async def test_config(event_loop): assert debug == DEBUG -async def test_conf(event_loop): +async def test_conf(): from navconfig import config from navconfig.conf import LOCAL_DEVELOPMENT assert LOCAL_DEVELOPMENT == False @@ -29,11 +29,380 @@ async def test_conf(event_loop): log = logging.getLogger() log.debug('HELLO WORLD') -async def test_environment(event_loop): + +async def test_environment(): from navconfig import config - config.configure(env='dev', override=True) # re-configure the environment + config.configure(env='dev', override=True) cnf = config.get('CONFIG_FILE') assert cnf == 'etc/navigator.ini' -def pytest_sessionfinish(session, exitstatus): - asyncio.get_event_loop().close() + +async def test_settings(): + from navconfig import config + from settings.settings import SEND_NOTIFICATIONS + assert SEND_NOTIFICATIONS is True + + +async def test_local_settings(): + from navconfig import config + from navconfig.conf import CONFIG_TEST + from settings.settings import CONFIG_TEST as CT + ct = config.get('CONFIG_TEST', fallback='NAVCONFIG') + assert ct == CT + assert CONFIG_TEST == CT + + +# New tests for environment switching and info +async def test_get_env_info(): + """Test get_env_info() returns proper structure and data.""" + from navconfig import config + + env_info = config.get_env_info() + + # Test required fields exist + required_fields = [ + 'current_env', 'loader_type', 'available_envs', + 'cached_envs', 'site_root', 'total_variables' + ] + + for field in required_fields: + assert field in env_info, f"Missing required field: {field}" + + # Test field types + assert isinstance(env_info['current_env'], (str, type(None))) + assert isinstance(env_info['loader_type'], (str, type(None))) + assert isinstance(env_info['available_envs'], list) + assert isinstance(env_info['cached_envs'], list) + assert isinstance(env_info['site_root'], str) + assert isinstance(env_info['total_variables'], int) + + # Test values make sense + assert env_info['total_variables'] >= 0 + assert Path(env_info['site_root']).exists() + + print(f"Environment info validated: {env_info['loader_type']} loader, " + f"{env_info['total_variables']} variables") + + +async def test_vault_status_info(): + """Test vault status information in get_env_info().""" + from navconfig import config + + env_info = config.get_env_info() + + # If vault status is present, validate its structure + if 'vault_status' in env_info: + vault_status = env_info['vault_status'] + + # Required vault status fields + required_vault_fields = ['enabled', 'connected', 'config', 'secrets_loaded', 'environment'] + + for field in required_vault_fields: + assert field in vault_status, f"Missing vault status field: {field}" + + # Test field types + assert isinstance(vault_status['enabled'], bool) + assert isinstance(vault_status['connected'], bool) + assert isinstance(vault_status['config'], dict) + assert isinstance(vault_status['secrets_loaded'], int) + assert isinstance(vault_status['environment'], str) + + # Test config structure + if vault_status['enabled']: + vault_config = vault_status['config'] + expected_config_fields = ['url', 'mount_point', 'version'] + + for field in expected_config_fields: + assert field in vault_config, f"Missing vault config field: {field}" + + print(f"Vault status: enabled={vault_status['enabled']}, " + f"connected={vault_status['connected']}, " + f"secrets_loaded={vault_status['secrets_loaded']}") + else: + print("No vault status found (file-only loader)") + + +async def test_current_environment(): + """Test getting current environment.""" + from navconfig import config + + current_env = config.get_current_env() + assert isinstance(current_env, (str, type(None))) + + # Should match ENV environment variable or default + expected_env = os.getenv('ENV', 'dev') # Assuming 'dev' is your default + + # Current env should be set to something + assert current_env is not None + print(f"Current environment: {current_env}") + + +async def test_list_available_environments(): + """Test listing available environments.""" + from navconfig import config + + available_envs = config.list_available_envs() + + # Should return a list + assert isinstance(available_envs, list) + + # Should contain at least the current environment + current_env = config.get_current_env() + if current_env: + assert current_env in available_envs, f"Current env '{current_env}' not in available envs" + + # All entries should be strings + for env in available_envs: + assert isinstance(env, str), f"Environment name should be string, got {type(env)}" + assert env.strip() == env, "Environment names should be stripped of whitespace" + + print(f"Available environments: {available_envs}") + + +async def test_environment_switching(): + """Test switching between environments.""" + from navconfig import config + + # Get original environment + original_env = config.get_current_env() + available_envs = config.list_available_envs() + + # Find a different environment to switch to + target_env = None + for env in available_envs: + if env != original_env: + target_env = env + break + + if target_env is None: + pytest.skip("No alternative environment available for switching test") + + # Test switching + success = config.set_env(target_env) + assert success is True, f"Failed to switch to environment: {target_env}" + + # Verify environment switched + assert config.get_current_env() == target_env + + # Switch back to original + success = config.set_env(original_env) + assert success is True, f"Failed to switch back to original environment: {original_env}" + + # Verify we're back to original + assert config.get_current_env() == original_env + + print(f"Environment switching test: {original_env} -> {target_env} -> {original_env}") + + +async def test_environment_switching_idempotent(): + """Test that switching to the same environment is idempotent.""" + from navconfig import config + + current_env = config.get_current_env() + + # Switch to same environment should succeed + success = config.set_env(current_env) + assert success is True + + # Should still be in same environment + assert config.get_current_env() == current_env + + +# The kardex CLI is covered end-to-end in tests/test_cli.py, which does +# not need a scaffolded project. + + +async def test_cross_environment_query(): + """Test querying variables from different environments without switching.""" + from navconfig import config + + current_env = config.get_current_env() + available_envs = config.list_available_envs() + + # Test querying current environment (should work like normal get()) + test_key = 'CONFIG_FILE' # A key that should exist + current_value = config.get(test_key) + cross_env_value = config.get_with_env(test_key, current_env) + + if current_value is not None: + assert current_value == cross_env_value, "Cross-env query should match current env" + + # Test querying different environment (if available) + for env in available_envs: + if env != current_env: + # This might return None if the key doesn't exist in that env, which is fine + cross_value = config.get_with_env(test_key, env) + # Just verify it doesn't crash and returns something reasonable + assert cross_value is None or isinstance(cross_value, str) + break + + print(f"Cross-environment query test completed for key: {test_key}") + + +# async def test_environment_caching(): +# """Test environment caching functionality.""" +# from navconfig import config + +# # Clear cache first +# config.clear_env_cache() + +# env_info_before = config.get_env_info() +# cached_envs_before = len(env_info_before['cached_envs']) + +# available_envs = config.list_available_envs() +# current_env = config.get_current_env() + +# # Find alternative environment +# target_env = None +# for env in available_envs: +# if env != current_env: +# target_env = env +# break + +# if target_env is None: +# pytest.skip("No alternative environment available for caching test") + +# # Switch to different environment (should cache it) +# config.set_env(target_env) + +# env_info_after = config.get_env_info() +# cached_envs_after = len(env_info_after['cached_envs']) + +# # Should have more cached environments now +# assert cached_envs_after >= cached_envs_before +# assert target_env in env_info_after['cached_envs'] + +# # Clear cache and verify +# config.clear_env_cache(target_env) +# env_info_cleared = config.get_env_info() + +# # The specific environment should be cleared from cache +# if target_env in env_info_cleared['cached_envs']: +# # It might still be there if it's the current environment +# pass + +# print(f"Caching test: {cached_envs_before} -> {cached_envs_after} cached environments") + +# # Switch back to original +# config.set_env(current_env) + + +async def test_loaded_files_info(): + """Test loaded files information in get_env_info().""" + from navconfig import config + + env_info = config.get_env_info() + + # Check if loaded files info is present + if 'loaded_files' in env_info: + loaded_files = env_info['loaded_files'] + file_count = env_info.get('file_count', 0) + + assert isinstance(loaded_files, list) + assert isinstance(file_count, int) + assert len(loaded_files) == file_count + + # All loaded files should exist + for file_path in loaded_files: + assert isinstance(file_path, str) + file_path_obj = Path(file_path) + assert file_path_obj.exists(), f"Loaded file should exist: {file_path}" + + print(f"Loaded files: {file_count} files") + for file_path in loaded_files: + print(f" - {file_path}") + else: + print("No loaded files information available") + + +async def test_configuration_values_exist(): + """Test that some expected configuration values exist.""" + from navconfig import config + + # Test some values that should typically exist + test_cases = [ + ('DEBUG', bool), + ('CONFIG_FILE', str), + ('APP_NAME', str), + ] + + for key, expected_type in test_cases: + value = config.get(key) if expected_type is not bool else config.getboolean(key) + if value is not None: + assert isinstance(value, expected_type), f"{key} should be {expected_type}, got {type(value)}" + print(f"Configuration test: {key} = {value} ({type(value).__name__})") + else: + print(f"Configuration test: {key} = None (not set)") + + +async def test_environment_reload(): + """Test reloading current environment.""" + from navconfig import config + + current_env = config.get_current_env() + + # Get current variable count + env_info_before = config.get_env_info() + vars_before = env_info_before['total_variables'] + + # Reload environment + config.reload_current_env() + + # Should still be in same environment + assert config.get_current_env() == current_env + + # Check that reload worked (variables should be loaded again) + env_info_after = config.get_env_info() + vars_after = env_info_after['total_variables'] + + # Variable count should be similar (unless environment changed externally) + assert vars_after > 0, "Should have variables after reload" + + print(f"Environment reload test: {vars_before} -> {vars_after} variables") + + +# Fixture for cleanup +@pytest_asyncio.fixture(autouse=True) +async def cleanup_test_environment(): + """Ensure we end up in a clean state after tests.""" + from navconfig import config + + # Store original environment + original_env = os.getenv('ENV', 'dev') + + yield # Run the test + + # Cleanup: ensure we're back in the expected environment + try: + if config.get_current_env() != original_env: + config.set_env(original_env) + except Exception as e: + # If cleanup fails, just log it + print(f"Cleanup warning: {e}") + + +# Test to validate the overall test setup +async def test_pytest_setup(): + """Validate that the test environment is properly set up.""" + from navconfig import config + + # Basic sanity checks + assert config is not None + assert hasattr(config, 'get') + assert hasattr(config, 'get_env_info') + assert hasattr(config, 'get_current_env') + assert hasattr(config, 'list_available_envs') + assert hasattr(config, 'set_env') + + print("Pytest setup validation completed") + + +if __name__ == "__main__": + # If run directly, execute a simple test + import asyncio + + async def run_simple_test(): + await test_get_env_info() + print("Simple test completed successfully") + + asyncio.run(run_simple_test()) diff --git a/tests/test_kardex_attributes.py b/tests/test_kardex_attributes.py new file mode 100644 index 00000000..33986a0d --- /dev/null +++ b/tests/test_kardex_attributes.py @@ -0,0 +1,31 @@ +"""Attribute access preserves values without returning from a finally block.""" + +from typing import Any + +import pytest + +from navconfig.kardex import Kardex + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("true", "true"), + ("42", "42"), + ("text", "text"), + (True, True), + (42, 42), + ('NAVCONFIG_JSONDATA:{"enabled": true}', {"enabled": True}), + ], +) +def test_attribute_access_preserves_deserialized_values( + monkeypatch: pytest.MonkeyPatch, value: Any, expected: Any +) -> None: + monkeypatch.delenv("NAVCONFIG_ATTRIBUTE_TEST", raising=False) + config = object.__new__(Kardex) + config._mapping_ = {"NAVCONFIG_ATTRIBUTE_TEST": value} + + result = config.NAVCONFIG_ATTRIBUTE_TEST + + assert result == expected + assert type(result) is type(expected) diff --git a/tests/test_uvloop.py b/tests/test_uvloop.py new file mode 100644 index 00000000..8d2eb6aa --- /dev/null +++ b/tests/test_uvloop.py @@ -0,0 +1,103 @@ +"""Optional event loop integration without requiring uvloop or Windows.""" + +import asyncio +import builtins +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from navconfig.utils.uvl import install_uvloop + + +def test_windows_does_not_import_uvloop(monkeypatch: pytest.MonkeyPatch) -> None: + policy = asyncio.get_event_loop_policy() + monkeypatch.setattr(sys, "platform", "win32") + with patch.object(builtins, "__import__", wraps=builtins.__import__) as importer: + install_uvloop() + + assert all(call.args[0] != "uvloop" for call in importer.call_args_list) + assert asyncio.get_event_loop_policy() is policy + + +def test_missing_uvloop_preserves_policy(monkeypatch: pytest.MonkeyPatch) -> None: + policy = asyncio.get_event_loop_policy() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setitem(sys.modules, "uvloop", None) + + install_uvloop() + + assert asyncio.get_event_loop_policy() is policy + + +@pytest.mark.parametrize("platform", ["linux", "darwin"]) +def test_available_uvloop_is_installed( + monkeypatch: pytest.MonkeyPatch, platform: str +) -> None: + install = Mock() + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setitem(sys.modules, "uvloop", SimpleNamespace(install=install)) + + install_uvloop() + + install.assert_called_once_with() + + +@pytest.mark.parametrize("available", [False, True]) +def test_uvloop_is_lazy_and_automatic(available: bool, tmp_path: Path) -> None: + """Use a fresh process so earlier imports cannot hide eager activation.""" + script = """ +import importlib.abc +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +class UvloopFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader): + attempts = 0 + install = Mock() + + def find_spec(self, fullname, path=None, target=None): + if fullname == "uvloop": + self.attempts += 1 + if not AVAILABLE: + raise ModuleNotFoundError("uvloop is not installed") + return importlib.util.spec_from_loader(fullname, self) + + def create_module(self, spec): + return None + + def exec_module(self, module): + module.install = self.install + +finder = UvloopFinder() +sys.meta_path.insert(0, finder) +import navconfig +assert finder.attempts == 0 +assert "uvloop" not in sys.modules + +# Simulate a supported platform only after importing platform-sensitive modules. +sys.platform = "linux" +root = Path(sys.argv[1]) +navconfig.project_root = lambda _: (root, root) +navconfig.get_env_type = lambda: "file" +navconfig.get_environment = lambda: "dev" +cfg = SimpleNamespace(debug=False, getboolean=Mock(return_value=False), + get=Mock(side_effect=lambda key, fallback: fallback)) +navconfig.Kardex = Mock(return_value=cfg) +assert navconfig.config is cfg +assert navconfig.bootstrap() is cfg +assert finder.attempts == 1 +assert finder.install.call_count == int(AVAILABLE) +""" + result = subprocess.run( + [sys.executable, "-c", f"AVAILABLE = {available!r}\n{script}", str(tmp_path)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tox.ini b/tox.ini index 110b797d..5c8ccf5f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ [tox] -envlist = py310, py311, py312 +envlist = py310, py311, py312, py313, py314 isolated_build = True [testenv] deps = - Cython + Cython>=3.1.4 pytest - asyncio - uvloop + pytest-asyncio + uvloop>=0.22.1; sys_platform != 'win32' commands = python setup.py build_ext --inplace pytest tests/ diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..43153b95 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1702 @@ +version = 1 +revision = 3 +requires-python = ">=3.10.1" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[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 = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[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 = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[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 = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "configparser" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/ac/ea19242153b5e8be412a726a70e82c7b5c1537c83f61b20995b2eda3dcd7/configparser-7.2.0.tar.gz", hash = "sha256:b629cc8ae916e3afbd36d1b3d093f34193d851e11998920fdcfc4552218b7b70", size = 51273, upload-time = "2025-03-08T16:04:09.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/fe/f61e7129e9e689d9e40bbf8a36fb90f04eceb477f4617c02c6a18463e81f/configparser-7.2.0-py3-none-any.whl", hash = "sha256:fee5e1f3db4156dcd0ed95bc4edfa3580475537711f67a819c966b389d09ce62", size = 17232, upload-time = "2025-03-08T16:04:07.743Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/602de8c2aec7e2e3e99ebfb8e04ba65598f746275396eea5f6794ff4673f/coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", size = 221058, upload-time = "2026-07-02T13:08:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/fc/13/ebab0743138891c1d646d61e247ec29639afcbb6c4e1905e6a0f0c75291a/coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", size = 247797, upload-time = "2026-07-02T13:08:22.474Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/b6ffb9e042aa48dc4144a8a65529affaec8dca0685309353614a2a7386ad/coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", size = 249626, upload-time = "2026-07-02T13:08:23.803Z" }, + { url = "https://files.pythonhosted.org/packages/9c/06/243ff05b652333d8e3d060c11223efc2723b19cacf6605e433fa686ab5d4/coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", size = 251493, upload-time = "2026-07-02T13:08:25.397Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2b/867faa17030a806114dae388b32a3fa929d8cd4bf39226fbc11f6e6bb705/coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", size = 253406, upload-time = "2026-07-02T13:08:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/d789ce18f6605afc4895db75723424be2ef494282f77f61d8e5832923183/coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", size = 248512, upload-time = "2026-07-02T13:08:28.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/b2673c30739f4a2e06649a0a38ad8b093c4d865462dc7bab0e9524a2c3b1/coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", size = 249532, upload-time = "2026-07-02T13:08:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/acd79e9a41beabee92b623afe4f30b549916f48566271475f2907e752828/coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", size = 247537, upload-time = "2026-07-02T13:08:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/12/d4/2d301c4d1b3238d7c88b70ab9d13fd53ed9505662a7ff1b46ba1e2e4e3c3/coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", size = 251348, upload-time = "2026-07-02T13:08:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/35/bb/c67708b2bc00f32e12805ec23d5fa677a0a51652f449341a89f9d6b1b715/coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", size = 247806, upload-time = "2026-07-02T13:08:33.931Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/57c4f653c47a6e917748f8938e389e72fbcae44e3643cd906664f0477a13/coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", size = 248410, upload-time = "2026-07-02T13:08:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/bb083041aef828903668f134273f319f2bd49224962875359c52faa5497f/coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", size = 222588, upload-time = "2026-07-02T13:08:36.486Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/a09d8ee618956f626741b0734854bac4425a00e10c0565f5abca64e7e751/coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", size = 223214, upload-time = "2026-07-02T13:08:37.885Z" }, + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.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/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "cython" +version = "3.2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6b/80101e02ebacaf9232ecf32bf6a788d36b27d820ee02434746252569ef98/cython-3.2.8.tar.gz", hash = "sha256:f4f23a56b25221a06f91817fe8f3114ab8b48a4fac73187dbb64bc2c4a87961f", size = 3290300, upload-time = "2026-06-30T07:41:57.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/49/36c8c805aa9c039292102d5e56e92d23cd600e241f4e49976b89fe79f9ed/cython-3.2.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a1d2df1545c480f377f1bb2aea514e28956c87908f351bedc3772b3737c598", size = 2999561, upload-time = "2026-06-30T07:42:08.211Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c5/278be136629062d7ca4fe84309b6078aee1b74c3d3eee1fc73f94d1e217b/cython-3.2.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1bbec7a7bd2c3836314e84f68e3f9a4c20e11cd164a01990a9ca34f595d5dd", size = 3302878, upload-time = "2026-06-30T07:42:09.932Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/ce156ca3790a10cdf05380ad1b2a55eb1961745b403dafd959f4ba3488cd/cython-3.2.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb25b2afedc6a3585c5ae50db79ba0c3d6277ee8e1d6ce0223528db2c4981d9b", size = 3455745, upload-time = "2026-06-30T07:42:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c5/e57dc4188cc89b1914b456bc7c41488d9a2c5c787c2e32e2ba9d62cf362e/cython-3.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:ad9a80e5dda71cf30b9c9577902f0c080aa14a215d7c178d669cda21cf5ab801", size = 2784066, upload-time = "2026-06-30T07:42:13.759Z" }, + { url = "https://files.pythonhosted.org/packages/25/44/379c4f16d6b9e920ca8d00dc3bb96ea3f4fb8b73ed8b99c136a7667e2605/cython-3.2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7761e1a97081b707a9ea4fb7c8ecdeddcc6b5ae00dda0f0b7bbf933c1b599f9", size = 2977552, upload-time = "2026-06-30T07:42:15.673Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9e/957e489fd7da81771407907502a78efa5989473fbc5ca25ec099adac8754/cython-3.2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84def174fbf8886fb026e04716908276c074d8c231e66db803abd04afcdcb998", size = 3304081, upload-time = "2026-06-30T07:42:17.463Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/03c283e0baf610a29b148ac3e9e85a8d15c31703cb492f9f41b634604096/cython-3.2.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a134a19bba6ce7b1dd21dedc5cb2d4ea39a4177e42e535e333786eb9d0ba61a7", size = 3457242, upload-time = "2026-06-30T07:42:19.406Z" }, + { url = "https://files.pythonhosted.org/packages/72/ce/1c18ba78abb311d2042a5510b0ac5d47f1a196a2af0cd184d0b1a8907a8a/cython-3.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:6c9bf89ac1d43a9e1c3bfee61e7b4dd8d1ef7a610001aa7779f0cb824bad8ae7", size = 2787555, upload-time = "2026-06-30T07:42:21.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c4/47e0bcfc15b36b1c5cbde5235c60bf88df552ab216ddb836d7f816386ae6/cython-3.2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2547a31fbd3b1610a8859a16edee2a141f7781691cb98a2c6fd54870c5f7541", size = 2995546, upload-time = "2026-06-30T07:42:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/bc5830abeb57a7c7498cd9a0f2df953fd9fc7f33e3f5352c9824802b83bb/cython-3.2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab1fe11ebd61e497a848622cdd157a4324ac06e7935b1219715408844e15dd13", size = 3179546, upload-time = "2026-06-30T07:42:25.566Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/a70e879ea52debac11d2810e066a5a2cb16e71229edae303f024506bc142/cython-3.2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bce1f079734753649f8a3d3a95832297207feb120d0ef4fd5db4c813cc6c04", size = 3351714, upload-time = "2026-06-30T07:42:27.513Z" }, + { url = "https://files.pythonhosted.org/packages/45/f1/f071c5e7050a7924ffad9822558c74d489afe4764f7486cb68555a509219/cython-3.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:8297efe129e6421c34ddbeb09ed5627ef6c0fc4868bf7f9bdf6c147f595ccaed", size = 2774196, upload-time = "2026-06-30T07:42:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/9487462239ccd47feb70fc023b2f416cee6cf30fe79d15f6a1eda59ea107/cython-3.2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d78205f525287de94effa2f2fab6b9edafdab44ef8c58ecf52fcb1013225d68", size = 2984097, upload-time = "2026-06-30T07:42:31.335Z" }, + { url = "https://files.pythonhosted.org/packages/68/68/4305333cd2a27fcf4769c79941772f067007671e90caeb5e7af33db45387/cython-3.2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:474d9c5378032c0237b70c7af4d2122eb00719fec2cab296ddc9cf2907d55d58", size = 3181866, upload-time = "2026-06-30T07:42:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3f/ce32b14ee64c5f5923fcd207a82b86c2531fae1dc3a964a32cfd7bc72ecf/cython-3.2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2221be06b6aa92486d7c3644c18cb3643b3e794600f0d6c8555a523e959a069", size = 3355829, upload-time = "2026-06-30T07:42:35.394Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a5/2510e88149abb9966f35b0e775c2549a0a71b39550457f9de6f1b239869e/cython-3.2.8-cp313-cp313-win_amd64.whl", hash = "sha256:0961ba91f59492d1bef974cc6b45993c743162776188bcb79e029b442d5d4fcc", size = 2770884, upload-time = "2026-06-30T07:42:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/ab/46/b491e2eebf00864421fe7b4c2c7d3c2fdd12d16ef8b76c42abd4e571d86b/cython-3.2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c7f4143d0f9fb7b26444e00ebf7c8845f585d56f37b73bd3fb3dac269af8732", size = 3012195, upload-time = "2026-06-30T07:42:39.299Z" }, + { url = "https://files.pythonhosted.org/packages/60/c7/c98115431c92007606efed4220a69ff02ae5a3cbbfc82605ffb6eb85a56b/cython-3.2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f788a2fec204bdd9291d5d542a4856db101dba59f8d83789d3f1eb2895971a0", size = 3227582, upload-time = "2026-06-30T07:42:41.252Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3c/e8675aeb18bf9029d861473fe49f9d612bd738faa3942928c8cba7a40239/cython-3.2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c3a33c2ff250d0277d0ee8b080ba0a0391a00e48af4a96fb09dbebbd1ac4b0e", size = 3368421, upload-time = "2026-06-30T07:42:43.198Z" }, + { url = "https://files.pythonhosted.org/packages/27/f7/4edbf4ca4832e2c5ba5f8a09ab91f347a591d9b00dbf8eec3edce95c7c3f/cython-3.2.8-cp314-cp314-win_amd64.whl", hash = "sha256:89b0fdc2ca0b502afedc4dd4ddbc4f9cb5a135245afacf9483e556e8ad3ada3b", size = 2807626, upload-time = "2026-06-30T07:42:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/92/a2/0f2eaa5076bcaef52567471a54ce02ffd70007bf8688cd054f7aab9bc3b8/cython-3.2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:127bc4039be48c6eebe7f1d68c33d23eb3a0c5ae95e1d730bdd048837751438b", size = 2892527, upload-time = "2026-06-30T07:42:55.45Z" }, + { url = "https://files.pythonhosted.org/packages/a4/08/b5488aef44662e48ac09b42d4cb398207f591c770797036fb1d6fbeb7a52/cython-3.2.8-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e480ae9f195cd29e5ce334e3d434c83dbac0783c0cc88f2407e31ba997724192", size = 3220335, upload-time = "2026-06-30T07:42:57.403Z" }, + { url = "https://files.pythonhosted.org/packages/7c/96/d04a3621045e9fe9c7c5e406a688ee3d6e04a65f545ea7c622ead4b4afd8/cython-3.2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3142407b9d63f233c766e17000e2aac782411bf0409b9adc97cb7c320aecd199", size = 2876481, upload-time = "2026-06-30T07:42:59.406Z" }, + { url = "https://files.pythonhosted.org/packages/71/9a/daa259b638c5eabb8a8c36f203b85b01b5362101ff8fc4ec6ad592d34bb3/cython-3.2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41c118fd91d320cd72af26e29232ff3f1a0a170c47d477c9a176d766067a4718", size = 2999974, upload-time = "2026-06-30T07:43:01.29Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/71cc5b4be5ee4d34c3302b6e7272189106a4072e9890d284d05239d2b645/cython-3.2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6335c6e8737a39734e20d25f89f425bf3274c104fc7efc05aacc7ed1a4858c9d", size = 2897932, upload-time = "2026-06-30T07:43:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0c/da68b9d3056e90b2060970b50d575cd7fcd1c778e8f23cc467f346e1d471/cython-3.2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1034facc082cb882e1e5beb61e136ae8e282df2eafa11e9771e9b0a15c860801", size = 3235980, upload-time = "2026-06-30T07:43:05.486Z" }, + { url = "https://files.pythonhosted.org/packages/36/0b/d88bc50e66fd1f1160dd2677d9af18273a2fb2f102c086d21f64a5a9c78b/cython-3.2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8896ff6b133f346ebcf22aa23706c4031e8d9d5ae184433dfc67dc1053318b69", size = 3118504, upload-time = "2026-06-30T07:43:07.485Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/511c4364808b3b4036d051a0301b0b142a3ddf8a319bfdbbd474fcfdc879/cython-3.2.8-cp39-abi3-win32.whl", hash = "sha256:3fd6464433d925cba66ae31bf5780c8a469a06da1d109180cffb39ee3c88ae20", size = 2435866, upload-time = "2026-06-30T07:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/18/4f/911b2b2a0a02be15829ccbf0c906029a318efbf53d9f8e021e438261c206/cython-3.2.8-cp39-abi3-win_arm64.whl", hash = "sha256:4e9447d9b652396a285cdfbd4f9f0721842c63c6df281720e87dcc4b9ea65af5", size = 2457829, upload-time = "2026-06-30T07:43:11.645Z" }, + { url = "https://files.pythonhosted.org/packages/c4/19/31aa63ab719b2e1eea5f200a8a54e2591dc1966a6b75e3de30ef8be9bc2c/cython-3.2.8-py3-none-any.whl", hash = "sha256:f635e113677666de13a2ec2979e9b1d5b90617cdfd1a691d3559be81e2dd6cb9", size = 1258688, upload-time = "2026-06-30T07:41:55.624Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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 = "filelock" +version = "3.29.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, +] + +[[package]] +name = "hvac" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/57/b46c397fb3842cfb02a44609aa834c887f38dd75f290c2fc5a34da4b2fee/hvac-2.4.0.tar.gz", hash = "sha256:e0056ad9064e7923e874e6769015b032580b639e29246f5ab1044f7959c1c7e0", size = 332543, upload-time = "2025-10-30T12:57:47.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/71e45a6bd6875f44a26f99da31c63b6840123e88bedf2c0b1ce429b8be12/hvac-2.4.0-py3-none-any.whl", hash = "sha256:008db5efd8c2f77bd37d2368ea5f713edceae1c65f11fd608393179478649e0f", size = 155921, upload-time = "2025-10-30T12:57:46.253Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[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 = "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 = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "jsonpickle" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/92/62fdc2f6b468b870dd171ad21748ef0ec2bff1b258c25ce6db3545cccc90/jsonpickle-3.0.2.tar.gz", hash = "sha256:e37abba4bfb3ca4a4647d28bb9f4706436f7b46c8a8333b4a718abafa8e46b37", size = 266361, upload-time = "2023-08-13T18:56:26.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/6e0a450430b7aa194b0f515f64820fc619314faa289458b7dfca4a026114/jsonpickle-3.0.2-py3-none-any.whl", hash = "sha256:4a8442d97ca3f77978afa58068768dba7bff2dbabe79a9647bc3cdafd4ef019f", size = 40686, upload-time = "2023-08-13T18:56:24.949Z" }, +] + +[[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 = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + +[[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 = "navconfig" +source = { editable = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "configparser" }, + { name = "cryptography" }, + { name = "cython" }, + { name = "hvac" }, + { name = "iso8601" }, + { name = "jsonpickle" }, + { name = "objectpath" }, + { name = "orjson" }, + { name = "pycparser" }, + { name = "pycryptodomex" }, + { name = "python-dateutil" }, + { name = "python-dotenv" }, + { name = "pytomlpp" }, + { name = "pyyaml" }, + { name = "requests" }, +] + +[package.optional-dependencies] +all = [ + { name = "hvac" }, + { name = "python-logstash-async" }, + { name = "pytomlpp" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] +build = [ + { name = "cython" }, + { name = "setuptools" }, + { name = "wheel" }, +] +default = [ + { name = "hvac" }, + { name = "python-logstash-async" }, + { name = "pytomlpp" }, + { name = "pyyaml" }, + { name = "redis" }, +] +dev = [ + { name = "black" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +hvac = [ + { name = "hvac" }, +] +logstash = [ + { name = "python-logstash-async" }, +] +redis = [ + { name = "redis" }, +] +uvloop = [ + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=23.2.1,<=24.1.0" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=26.3.1" }, + { name = "configparser", specifier = ">=6.0.0" }, + { name = "cryptography", specifier = ">=46.0.7" }, + { name = "cython", specifier = ">=3.1.4" }, + { name = "cython", marker = "extra == 'build'", specifier = ">=3.1.4" }, + { name = "hvac", specifier = ">=2.3.0" }, + { name = "hvac", marker = "extra == 'all'", specifier = ">=2.3.0" }, + { name = "hvac", marker = "extra == 'default'", specifier = ">=2.3.0" }, + { name = "hvac", marker = "extra == 'hvac'", specifier = ">=2.3.0" }, + { name = "iso8601", specifier = ">=2.1.0" }, + { name = "jsonpickle", specifier = ">=1.2,<=3.0.2" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "objectpath", specifier = ">=0.6.1" }, + { name = "orjson", specifier = ">=3.11.6" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, + { name = "pycparser", specifier = ">=2.21" }, + { name = "pycryptodomex", specifier = ">=3.20.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-logstash-async", marker = "extra == 'all'", specifier = ">=2.7.2" }, + { name = "python-logstash-async", marker = "extra == 'default'", specifier = ">=2.7.2" }, + { name = "python-logstash-async", marker = "extra == 'logstash'", specifier = ">=2.7.2,<=3.0.0" }, + { name = "pytomlpp", specifier = ">=1.0.13" }, + { name = "pytomlpp", marker = "extra == 'all'", specifier = ">=1.0.13" }, + { name = "pytomlpp", marker = "extra == 'default'", specifier = ">=1.0.13" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "pyyaml", marker = "extra == 'all'", specifier = ">=6.0" }, + { name = "pyyaml", marker = "extra == 'default'", specifier = ">=6.0" }, + { name = "redis", marker = "extra == 'all'", specifier = "~=5.2.1" }, + { name = "redis", marker = "extra == 'default'", specifier = "~=5.2.1" }, + { name = "redis", marker = "extra == 'redis'", specifier = "~=5.2.1" }, + { name = "requests", specifier = ">=2.33.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "setuptools", marker = "extra == 'build'", specifier = ">=74.0.0" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'all'", specifier = ">=0.22.1" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'uvloop'", specifier = ">=0.22.1" }, + { name = "wheel", marker = "extra == 'build'", specifier = ">=0.44.0" }, +] +provides-extras = ["uvloop", "logstash", "redis", "hvac", "build", "dev", "default", "all"] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "objectpath" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/51/cd4c88d27f7aabc20a89bbddc1fc64f62fbc820a45c93c6ab8bc45e23f10/objectpath-0.6.1.tar.gz", hash = "sha256:461263136c79292e42431fbb85cdcaac4c6a256f6b1aa5b3ae9316e4965ad819", size = 18793, upload-time = "2018-11-27T15:44:46.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/2a/7d32788e3fc2e14c0ba76bca110a5465f75b2bf266a6b6bd802b4f370988/objectpath-0.6.1-py2.py3-none-any.whl", hash = "sha256:7ac2c507e7e5bfbf245701044453edc8e4073ded01d53349237d918124d7ca87", size = 20900, upload-time = "2018-11-27T15:44:45.158Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[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 = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[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 = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + +[[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 = "pylogbeat" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/3d/310e231cbe5137a0393bf91fb1c0515c5ebb9b17d891e7f31d53e8c4bfa6/pylogbeat-2.0.1.tar.gz", hash = "sha256:c483f0002b896f487299467ae88f8da5d4dde2de22abee2ad4cc66ec89108645", size = 11317, upload-time = "2023-07-30T14:28:53.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/b7/d7a6745273918fc992dbcc992461a7e969b1d0fa8330eb93f89e83afc009/pylogbeat-2.0.1-py2.py3-none-any.whl", hash = "sha256:516736167aa2dfe122d6c5fae889863fb49d23499215427c172dbbb2b211fa09", size = 10919, upload-time = "2023-07-30T14:28:51.834Z" }, +] + +[[package]] +name = "pylogbeat" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/41/1daad4fd3820e92365db6f0e867d6e7cf3dc2dc96fc2acf8db479282b310/pylogbeat-2.1.0.tar.gz", hash = "sha256:c2c030050c9f312249500df63a1d651bbe6d9c5df54f70fc434115ef29d51638", size = 11577, upload-time = "2025-11-23T15:57:21.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/f4/c32538a51ac43aa002bf27a97f18b6ce56fc5aac88edbe745f5c7a95a6ab/pylogbeat-2.1.0-py2.py3-none-any.whl", hash = "sha256:2f691db42d428decbee54cf0d5ac0209314404298deefff70591e6b25388afbc", size = 11216, upload-time = "2025-11-23T15:57:19.778Z" }, +] + +[[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-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[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 = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-logstash-async" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, + { name = "pylogbeat", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pylogbeat", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/de/268a0c18ea8422254e4a6ebd767430b30899dcdc38a20fcbf0400b553755/python-logstash-async-3.0.0.tar.gz", hash = "sha256:acbbac8355d556d12cd7d89fd05da6ea7c1d548bff1f9ebda8721e4b3a751379", size = 36947, upload-time = "2024-02-12T22:27:02.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/f5/c09c6571208869e1864f85a7fec0b0a6e52194d3bd6fc80af49304cfb2b2/python_logstash_async-3.0.0-py2.py3-none-any.whl", hash = "sha256:4cfeadda50aa35d785dbddec3b9f904f9d3f40ac6f63a06bf81846b52f18b1c7", size = 24668, upload-time = "2024-02-12T22:26:56.549Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pytomlpp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/fe/50ca1ac0c1a932d3e71311baff531c0395e546ec0cd42bc8a8a88c36e00b/pytomlpp-1.1.0.tar.gz", hash = "sha256:61a0f73e7ba2fe8bba4e99ce6d2701491885850b53aad8f3e46d03c4b31e594d", size = 1323755, upload-time = "2025-11-29T20:52:09.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/0f/5b2e3b4c929f6177da17f6fda4214a047475b4f535ec61509c814eb7fb23/pytomlpp-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d173d1952317f7452eb8530e2dd8389b4b2191f1cd0e3abd2793fdbec0a748f", size = 338148, upload-time = "2025-11-29T20:51:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/02/b2/1a493d06a92409c8968110b4289398f22e3db931e6c5930da8a9551d6787/pytomlpp-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:69e73c7376b3958b2c5aeafb97066e9e7e233c03220e46874927f6a0f2218592", size = 175608, upload-time = "2025-11-29T20:51:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c7/d524dd31a71536dadd99cd3722cef3ea87f25740229b1b7e50f86ec6c36d/pytomlpp-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f992b18401f2ba7f335f29a8318e06de501120f4bec7f96c00cfb0729e081096", size = 167300, upload-time = "2025-11-29T20:51:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/7f/62/6d5027d03f75cd21ced2a603ac4c359a19c9132a997345413965599ab151/pytomlpp-1.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:853b0a56049938dc9a32e3f28a6c926d4d373d95d004a95ddb693888f26cb940", size = 2802958, upload-time = "2025-11-29T20:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/cfd266c2ff491eceea2bd7290ff07eac75dc25d26c91b46599b28971245d/pytomlpp-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f58a71bb8e81853d14db03bb2437c3db97a859e1cb1b2813c67cd7a57ab3ef8b", size = 3849801, upload-time = "2025-11-29T20:51:31.008Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/5b3819cf58517a7f466482dc9ac76af8c4f80207c2a10210413932a37be4/pytomlpp-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b073c6e74f699bb70c3aa9c6053c18ee4dd8a92c71aa4e7e1c973cdc352be1f", size = 184497, upload-time = "2025-11-29T20:51:32.61Z" }, + { url = "https://files.pythonhosted.org/packages/89/28/2025f72ca8761e96185f52fd186a691177f0d638e70a83c9ac8998997723/pytomlpp-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6fc7975f666609926b78e6b82dd120f3bd58b431481ca50f0f4c8febb025cd7", size = 340377, upload-time = "2025-11-29T20:51:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b0/0f75e33eaad85012f5797e2d19af32ab9777c38cfbe39a381a4c17258e97/pytomlpp-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ef99706b66663cc06fa0720b786171ba173a7dddf09f7d99dc53c68ee5db573", size = 176964, upload-time = "2025-11-29T20:51:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/f5/70/599c1151dc63513bc8349c1374450c7bfd7bfb21deaffa9fb8f40302be2a/pytomlpp-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:544d2e4caf0363f95b9b71181ccf6ebbee0a90d001ff22c2806a7742cc09729b", size = 168639, upload-time = "2025-11-29T20:51:36.039Z" }, + { url = "https://files.pythonhosted.org/packages/25/21/7bc90904f231ad268c43edc9a540ea37935fe35df53727107e81d83bec84/pytomlpp-1.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:65978eeeae5cfe79519eb0e5f48a39f09944e79b61531a400d185b80823d6b51", size = 2831921, upload-time = "2025-11-29T20:51:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/c6/16/9d70440b24f563946fdc6c70d63f26aa181313790a92357cc60b9d0f1b67/pytomlpp-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a8b82d3b86cac020e1e2b4dad7ba54c7f2757e6ad9cda350b9ad8f15da9c326f", size = 3882773, upload-time = "2025-11-29T20:51:38.445Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/6ec2d8610c3093bc110541ad1bf07114cf51972e236c023bdb2c9586658d/pytomlpp-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:e9c1a7a060a41b65315fbcd09d081176b5e3a2a24d3bea79a5de62d21a456f73", size = 185019, upload-time = "2025-11-29T20:51:40.007Z" }, + { url = "https://files.pythonhosted.org/packages/48/a3/093528ee9506e288e4c6ad1549cf3062da67cc87eba9e06ee4a7bbfe4d48/pytomlpp-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31ddfa1f910cdf39d897a51fb824694884886570bab6c5974ff16b0e491fe212", size = 337884, upload-time = "2025-11-29T20:51:40.933Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b7/43731e87a06917211b3947a29528bb4116d4f7f806d148aa4b45d9486b52/pytomlpp-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7f79dd2a584deda5d4284e6761290c54faa4c0cc1193ae7216f4fed5494c90b", size = 175640, upload-time = "2025-11-29T20:51:41.765Z" }, + { url = "https://files.pythonhosted.org/packages/18/cf/1baa60d06aa015b466f090a3dbbb2d6bb831e0b9d0e033a67e289a0d6a1d/pytomlpp-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fbb551f664f1041b8f82b3701e85976189ec45b3eefbb5b5f92d7ce63f2a268", size = 167080, upload-time = "2025-11-29T20:51:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/693efc46ab6a21dfa62023bd2c8a328218346e4fd088808fe52cfa3dd834/pytomlpp-1.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8907b2a398f8b9508067f0ba295950280d86e07be002971d9ad8dd44069f5876", size = 2855594, upload-time = "2025-11-29T20:51:44.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/97/de34b5896d435140fd986e0af5177082c729e6bf4372f2075193d760ff99/pytomlpp-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f269f99fbd7b57f8c5dd20558fe04ff1ba69d4fa34328740cf4aca63cfee2683", size = 3902957, upload-time = "2025-11-29T20:51:45.798Z" }, + { url = "https://files.pythonhosted.org/packages/b5/40/cf45fdb5f436c9fadf526884ffb579855750dd94945d696ecc7961838b6e/pytomlpp-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ab08d386a07d86b29351e012467c1eeaddfa5a350c755814462b689e482b54f", size = 185509, upload-time = "2025-11-29T20:51:46.818Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c9/bf0c3751ed0f6c55b625cabe87b79b3c60fd32318fcebff3e6acd664a85b/pytomlpp-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:854b887fc9e6144a1215c117df7391a77caaa4f210d0a87b35fcac4b6c0ddd8f", size = 337955, upload-time = "2025-11-29T20:51:48.037Z" }, + { url = "https://files.pythonhosted.org/packages/95/8a/64f180976408d1369f93b94fd74037ace73c65bdeb49772ca1f5af15b347/pytomlpp-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb65edbed4dd7528d56922c25dbf9191d4e7e06b072d29ae222dab8d81b0a422", size = 175684, upload-time = "2025-11-29T20:51:48.951Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/f7c91eced4ef8aa9c0b9dba3716492fe6ab0966f3994c0ae333bb9a8dcf1/pytomlpp-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:24f0d7e23440ae2f17a760e130e1b0ef674fd2b7c718cce60efcea1ba34a7bb3", size = 167080, upload-time = "2025-11-29T20:51:49.858Z" }, + { url = "https://files.pythonhosted.org/packages/d0/51/862df4c1575787b9fda834f3e0e1d5e3fbdbd32ea00b88e8393c7cfdc018/pytomlpp-1.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25a65d572c7b506bba5dae3eb034896f09d09807a157161a758dbd141d438842", size = 2854164, upload-time = "2025-11-29T20:51:50.85Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/242540eaff697938394b0dce805c2aa5831d26e7dc64018dbd0fdc6505d5/pytomlpp-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd511b841bb762b4ebad51bec6e063535a22ed892e046be0b3d2fe1000e09b6f", size = 3899866, upload-time = "2025-11-29T20:51:52.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/e7/948267618fa0be4f1a79af21925119230435a4e82ab49076abf885a81cd1/pytomlpp-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1b201a6d580a064d1a0d382e74b76b6435f51bfa5e60a2b72fb022944e97fd", size = 185540, upload-time = "2025-11-29T20:51:53.266Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" }, +] + +[[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 = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[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.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[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 = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/65/ec1d92091671e6407d3e7c1f5801413bb7b2b57630a50cae7750456ba0ed/virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734", size = 5526111, upload-time = "2026-07-06T22:49:56.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/e7/2fbd0cc1653c53eed8f10670538bb547de2b3e37aacad283faa82a71094b/virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1", size = 5506216, upload-time = "2026-07-06T22:49:54.941Z" }, +] + +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/8b/59781d0fe7b0adfbea37f600857de4be68921e454aeecf1a11bda35cdccc/wrapt-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:055e6fcfaa28e58c6a8c247d48b92be9d56f818b7068aa4f22b15b3343a09931", size = 80556, upload-time = "2026-06-20T23:47:28.473Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/66c61aca927230c9cf97a3cb005c803971a1076ff9f7d61085d035c20085/wrapt-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8374eb6b1a58809211e84ff835a182bb17ab2807a5bfef23204c8cff38178a00", size = 81648, upload-time = "2026-06-20T23:47:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/23/1b/545eee1c18f3af4cf140bb5822b6ef81ebe569df0a63ac109973103a30a5/wrapt-2.2.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:656593bb3f5529f03d27af4136c4d7b11990e470bcbc6fefa5ef218695bece55", size = 152956, upload-time = "2026-06-20T23:47:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/44/a7/6f42a3d03e44dc612a5dcff324e7366075a7857f0be2d49a8cb8a68279b8/wrapt-2.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb00cb7bb22099e2f64b7340fb96113639aa7260c0972af3797ace2297b936c", size = 154771, upload-time = "2026-06-20T23:47:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/bf/55/4d76175aaa97523c38f1d28f79d18ab41a1b116814158a818bc0eba00571/wrapt-2.2.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7f10ee0bd53673bfd52b67cbce83336fe6cad90d2377b03baf66491d2bbfb91", size = 149460, upload-time = "2026-06-20T23:47:34.712Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/12e23264d8f4735e8483262f95c5a6b03c3665fd2a84bdf99a45b6a2f4ec/wrapt-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4402f57c5f0d0579599858ffbdd9bf4e3f0972f51096f2bd6cc7dab6b76ee49e", size = 153648, upload-time = "2026-06-20T23:47:36.092Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/bcd5ec37289dcd85ecd4d15395a6a6063d60bc45ff94a9d77814e1e54d64/wrapt-2.2.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3a4eb7964ff4643d333c84f880bcf554652b2a1050aebc54ae696327f61acfaf", size = 148502, upload-time = "2026-06-20T23:47:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/f2/be/716d708f607fa70f8a6eb47dff8ee945d5278dfc89ffeeff33039d052e63/wrapt-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e542b7c5af91e2123a8aabf19894319d5ec4268d2a9ffd2f239386133fc47746", size = 152238, upload-time = "2026-06-20T23:47:39.118Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c0/1a48e7e54501274f5d906f18372221b13183b0afbb5b8bb4c7ca0392c0b4/wrapt-2.2.2-cp310-cp310-win32.whl", hash = "sha256:6e7e45b43d3c774d244fe7264378f5a3f0f383bc55a54a9866434e524540110f", size = 77278, upload-time = "2026-06-20T23:47:40.476Z" }, + { url = "https://files.pythonhosted.org/packages/b0/82/9cd69a1af288fbdedf01a10e3c8a0b6890b08c7f3f96d36a213699dbcd94/wrapt-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:955f1d6e72a352e478de8d8b503abe301c5e139a141b62eb0923bd694995025f", size = 80131, upload-time = "2026-06-20T23:47:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/8db7e27daef37ae70a53ea62bef7fe80cc51a8b5e9e9181a8be6eb9a999c/wrapt-2.2.2-cp310-cp310-win_arm64.whl", hash = "sha256:b89d8d73c82db2bb7e6090b3afd7973f980d24e905cc34394eab60b884b3bf67", size = 79615, upload-time = "2026-06-20T23:47:43.109Z" }, + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +]