diff --git a/.atlas-ai/cdd/task-1.json b/.atlas-ai/cdd/task-1.json new file mode 100644 index 0000000..887b7e2 --- /dev/null +++ b/.atlas-ai/cdd/task-1.json @@ -0,0 +1,18 @@ +{ + "task": 1, + "given": "Atlas Pro needs portable license keys signed with Ed25519 and verified without private-key material in the repo", + "when": "the Python CLI, TypeScript worker signer, committed vectors, and documentation are exercised", + "then": [ + "license keys use ATLAS-. with canonical JSON payload bytes", + "payloads contain lid, sub, plan, iat, exp, and v fields with v equal to 1", + "Ed25519 signing and verification pass RFC 8032 vectors", + "valid, expired, tampered-payload, and signature-mismatch fixtures produce their expected verdicts", + "Python and TypeScript signers produce byte-identical keys for fixture payloads", + "private signing material is ignored and repository scanning fails on committed private-key material" + ], + "evidence_required": [ + "python3 -m pytest tests/license/ -q", + "python3 -m pytest tests/ -q", + "git diff --cached --name-only before commit includes forced .atlas-ai/cdd/task-1.json" + ] +} diff --git a/.atlas-ai/cdd/task-10.json b/.atlas-ai/cdd/task-10.json new file mode 100644 index 0000000..3c35750 --- /dev/null +++ b/.atlas-ai/cdd/task-10.json @@ -0,0 +1,19 @@ +{ + "task": 10, + "given": "The Worker exposes a public telemetry endpoint for minimal Atlas usage events", + "when": "POST /telemetry receives valid events, malformed payloads, storage failures, or KPI query fixtures", + "then": [ + "valid install, atlas_invoked, reach_execute, and ship_check_ok payloads return 204", + "schema violations return 400 and method violations return 405", + "valid events are inserted into D1 telemetry_events", + "valid events call Analytics Engine writeDataPoint with blobs [event, version, os] and indexes [install_id]", + "storage sink failures are logged without changing a valid request response", + "documented D1 KPI queries compute A1, A3, AC1, and AC2 from seeded rows" + ], + "evidence_required": [ + "workers/test/telemetry.test.ts red/green output", + "D1 migration 0003 applied locally", + "Worker typecheck and vitest output", + "blocked deploy/smoke logged because production deploy is intentionally skipped" + ] +} diff --git a/.atlas-ai/cdd/task-11.json b/.atlas-ai/cdd/task-11.json new file mode 100644 index 0000000..0807c0f --- /dev/null +++ b/.atlas-ai/cdd/task-11.json @@ -0,0 +1,15 @@ +{ + "task": 11, + "given": "A local Atlas install runs CLI and pipeline workflows", + "when": "telemetry is enabled and lifecycle events occur", + "then": [ + "the client generates a stable anonymous install_id under ~/.atlas-ai", + "telemetry POST payloads contain exactly install_id, event, version, and os", + "ATLAS_TELEMETRY=0 and telemetry:false config opt-outs prevent install_id creation and network calls", + "pipeline and ship-check hooks are best-effort and never alter return values" + ], + "evidence_required": [ + "python3 -m pytest tests/test_telemetry.py -q", + "python3 -m pytest tests/ -x -q" + ] +} diff --git a/.atlas-ai/cdd/task-12.json b/.atlas-ai/cdd/task-12.json new file mode 100644 index 0000000..e849396 --- /dev/null +++ b/.atlas-ai/cdd/task-12.json @@ -0,0 +1,16 @@ +{ + "task": 12, + "given": "A user installs Atlas through install.sh or the npm package", + "when": "installation completes successfully", + "then": [ + "install.sh sends a best-effort install telemetry event unless opted out", + "--no-telemetry and ATLAS_TELEMETRY=0 suppress the install ping before python is invoked", + "package.json postinstall sends the same best-effort install event unless opted out", + "README discloses exact telemetry fields, event names, and all opt-out mechanisms" + ], + "evidence_required": [ + "bash tests/install/test_install_telemetry.sh", + "python3 -m pytest tests/install tests/test_telemetry.py -q", + "python3 -m pytest tests/ -x -q" + ] +} diff --git a/.atlas-ai/cdd/task-13.json b/.atlas-ai/cdd/task-13.json new file mode 100644 index 0000000..72d84aa --- /dev/null +++ b/.atlas-ai/cdd/task-13.json @@ -0,0 +1 @@ +{"task":13,"given":"The pipeline has internal phases and user-facing gate results","when":"terminal UX output is rendered for setup, discovery, generate, and handoff","then":["phase headers use the Atlas PHASE n/4 grammar","gate output uses Gate passed/Gate blocked plus Fix lines instead of raw JSON","user-facing labels avoid banned internal jargon","locked Atlas Fleet paywall text states $29/mo inline and never defaults to paid mode"],"evidence_required":["pytest tests/core/test_render.py -q","grep audits for phase headers and gate lines in setup/discover/generate/handoff skills","python3 -m pytest tests/ -q shows no new failures vs base"]} diff --git a/.atlas-ai/cdd/task-14.json b/.atlas-ai/cdd/task-14.json new file mode 100644 index 0000000..70a7e69 --- /dev/null +++ b/.atlas-ai/cdd/task-14.json @@ -0,0 +1 @@ +{"task":14,"given":"User-facing UX strings and Pro paywall surfaces are present in skills, phase docs, templates, README, installer, hooks, and recommendation code","when":"the UX conformance lint runs in pytest and CI","then":["Markdown and Python user-facing strings are extracted deterministically","banned jargon primary labels are reported with file:line:term","load-bearing identifiers such as .atlas-ai/cdd, requiresCDD, and the ralph-loop capability key are allowed","Atlas Fleet paywall surfaces state $29/mo inline, use atlas-ai.au/pro, and never default-select locked Fleet"],"evidence_required":["python3 -m pytest tests/test_ux_conformance.py -q","python3 -m pytest tests/ -x -q shows no new failures vs base"]} diff --git a/.atlas-ai/cdd/task-15.json b/.atlas-ai/cdd/task-15.json new file mode 100644 index 0000000..94c76b8 --- /dev/null +++ b/.atlas-ai/cdd/task-15.json @@ -0,0 +1 @@ +{"task":15,"given":"A fresh user installs and runs Atlas from a clean environment","when":"setup, install, and the quickstart walkthrough execute without local developer paths","then":["setup copies customizations from the installed skill-relative skel directory","install.sh handles the task-master-ai peer dependency with clear success and failure grammar","the deterministic quickstart walkthrough passes without credentials","README quickstart matches observed installer output"],"evidence_required":["python3 -m pytest tests/plugin/test_setup_portability.py -q","python3 -m pytest tests/core/test_quickstart_walkthrough.py -q","bash tests/scripts/quickstart-walkthrough.sh","python3 -m pytest tests/ -x -q shows no new failures vs base"]} diff --git a/.atlas-ai/cdd/task-16.json b/.atlas-ai/cdd/task-16.json new file mode 100644 index 0000000..aae5fde --- /dev/null +++ b/.atlas-ai/cdd/task-16.json @@ -0,0 +1 @@ +{"task":16,"given":"Release validation needs locally-generatable KPI and ship-check evidence","when":"ops runs the KPI snapshot and release ship-check from this branch","then":["kpi-snapshot.sh prints A1, A3, AC1, AC2, and C1 rows for last-7d, last-30d, and all-time","unconfigured analytics mode exits 0 with clear not-configured notices and C1 n/a when license telemetry is absent","ops runbook documents environment setup, queries, and weekly review","release evidence records full suite, UX lint, ship-check, and live purchase as blocked when credentials/deploy are unavailable"],"evidence_required":["python3 -m pytest tests/core/test_kpi_snapshot.py -q","python3 scripts/ops/kpi-snapshot.sh or bash scripts/ops/kpi-snapshot.sh output","python3 -m pytest tests/ -x -q","python3 skel/ship-check.py --cwd REPO_PATH"]} diff --git a/.atlas-ai/cdd/task-2.json b/.atlas-ai/cdd/task-2.json new file mode 100644 index 0000000..ddbe3d5 --- /dev/null +++ b/.atlas-ai/cdd/task-2.json @@ -0,0 +1 @@ +{"task":2,"given":"an offline Atlas Pro license key signed with the shipped Ed25519 public key","when":"the client parses, verifies, evaluates, saves, and loads the key without network access or third-party runtime dependencies","then":["RFC 8032 Ed25519 verification accepts valid vectors and rejects tampering","ATLAS-v1 license keys parse into canonical payload bytes and signature bytes with structured invalid results","status resolves to active, grace, expired, or invalid with exp-includes-grace semantics","valid licenses persist under ~/.atlas-ai/license.json with private permissions"],"evidence_required":["python3 -m pytest tests/core/test_license.py -q","python3 -m pytest tests/ -x -q"]} diff --git a/.atlas-ai/cdd/task-3.json b/.atlas-ai/cdd/task-3.json new file mode 100644 index 0000000..f09c150 --- /dev/null +++ b/.atlas-ai/cdd/task-3.json @@ -0,0 +1 @@ +{"task":3,"given":"a user has an Atlas Pro license key and atlas-launcher may be registered","when":"they activate the key through CLI or MCP and capability detection runs","then":["license-activate accepts a key argument and renders the UX-SPEC status block without raw JSON by default","valid active or grace keys are persisted and invalid or expired keys are rejected","detect-capabilities exposes license_status and only unlocks premium tier when atlas-launcher is registered and the license is active or grace","MCP exposes license_activate and license-aware detect_capabilities","handoff skill copy reflects active, grace, expired, and missing license states with inline Pro price and /pro link"],"evidence_required":["python3 -m pytest tests/core/test_license_activate.py tests/core/test_capabilities.py tests/mcp/test_mcp_tools.py tests/plugin/test_skill_files.py -q","python3 -m pytest tests/ -x -q"]} diff --git a/.atlas-ai/cdd/task-4.json b/.atlas-ai/cdd/task-4.json new file mode 100644 index 0000000..6c85cdb --- /dev/null +++ b/.atlas-ai/cdd/task-4.json @@ -0,0 +1,18 @@ +{ + "task": 4, + "given": "A greenfield Worker backend for Atlas Pro billing and telemetry", + "when": "the Worker project is scaffolded with D1, route skeletons, local tests, deployment guardrails, and secret hygiene", + "then": [ + "unknown routes return 404", + "the LICENSE_DB D1 binding and migrations create license and webhook idempotency tables", + "the public route contracts exist for /stripe/webhook, /license/refresh, and /telemetry", + "deployment and secret-scanning procedures are documented and locally verifiable without committing secrets" + ], + "evidence_required": [ + "npm test Worker vitest output", + "wrangler d1 migrations apply LICENSE_DB --local output", + "wrangler deploy --dry-run output or credential-aware blocked log", + "secret scan output", + "FLEET-LOG-BACKEND.md PASS/BLOCKED line" + ] +} diff --git a/.atlas-ai/cdd/task-5.json b/.atlas-ai/cdd/task-5.json new file mode 100644 index 0000000..90e8502 --- /dev/null +++ b/.atlas-ai/cdd/task-5.json @@ -0,0 +1,18 @@ +{ + "task": 5, + "given": "Stripe sends signed billing webhooks to the Worker", + "when": "checkout, invoice, cancellation, duplicate, unsigned, or forged webhook requests are delivered", + "then": [ + "invalid or missing signatures return 400 without mutating D1", + "replayed event IDs return 200 without duplicate side effects", + "checkout.session.completed creates a license row and signed ATLAS key", + "invoice.paid extends the existing license expiry and reissues a key", + "customer.subscription.deleted marks the license cancelled without issuing a key" + ], + "evidence_required": [ + "workers/test/stripe.test.ts red/green output", + "Worker vitest output", + "typecheck output", + "blocked Stripe CLI replay logged because Stripe credentials are absent" + ] +} diff --git a/.atlas-ai/cdd/task-6.json b/.atlas-ai/cdd/task-6.json new file mode 100644 index 0000000..6fe48f7 --- /dev/null +++ b/.atlas-ai/cdd/task-6.json @@ -0,0 +1,18 @@ +{ + "task": 6, + "given": "A Stripe checkout or renewal webhook produces a signed Atlas Pro license key", + "when": "the Worker schedules license email delivery through Resend", + "then": [ + "sendLicenseEmail posts the expected Resend payload with the full key and activation instructions", + "transient Resend failures retry with exponential backoff and typed failure after exhaustion", + "4xx Resend responses fail fast without retry", + "webhook handlers use ctx.waitUntil so email failure does not roll back D1 license persistence", + "email failure logs include the license id but not the raw email" + ], + "evidence_required": [ + "workers/test/email.test.ts red/green output", + "webhook failure-isolation vitest output", + "Worker typecheck output", + "blocked Resend test-mode integration logged because RESEND_API_KEY is absent" + ] +} diff --git a/.atlas-ai/cdd/task-7.json b/.atlas-ai/cdd/task-7.json new file mode 100644 index 0000000..48f0875 --- /dev/null +++ b/.atlas-ai/cdd/task-7.json @@ -0,0 +1,19 @@ +{ + "task": 7, + "given": "A client asks the Worker to silently refresh an Atlas Pro license key by lid", + "when": "POST /license/refresh receives invalid JSON, unknown lids, cancelled subscriptions, active subscriptions, CORS preflight, or repeated requests", + "then": [ + "invalid JSON or missing lid returns 400", + "unknown licenses return 403 with reason license_not_found", + "active or trialing Stripe subscriptions return a fresh signed key with continuity of lid, sub, and plan", + "cancelled D1 rows or inactive Stripe subscriptions return 403 with reason subscription_cancelled", + "per-lid D1 rate limiting allows 10 requests per UTC day and rejects the 11th with 429", + "CORS responses allow https://atlas-ai.au" + ], + "evidence_required": [ + "workers/test/refresh.test.ts red/green output", + "D1 migration 0002 applied locally", + "Worker typecheck and vitest output", + "blocked seeded live Stripe integration logged because Stripe credentials are absent" + ] +} diff --git a/.atlas-ai/cdd/task-8.json b/.atlas-ai/cdd/task-8.json new file mode 100644 index 0000000..2d55b7b --- /dev/null +++ b/.atlas-ai/cdd/task-8.json @@ -0,0 +1,16 @@ +{ + "task": 8, + "given": "A saved Atlas Pro license is active or in grace and may be near expiry", + "when": "capability detection runs and the license is within the refresh window while telemetry is not opted out", + "then": [ + "the client decides refresh eligibility without network access", + "the client POSTs only the license id to the refresh endpoint with a 5s timeout", + "a refreshed key is saved only after the full existing signature verification path accepts it", + "403, timeout, offline, and refresh exceptions never break capability detection" + ], + "evidence_required": [ + "python3 -m pytest tests/core/test_license_refresh.py -q", + "python3 -m pytest tests/core/test_capabilities.py tests/core/test_license_refresh.py -q", + "python3 -m pytest tests/ -q" + ] +} diff --git a/.atlas-ai/cdd/task-9.json b/.atlas-ai/cdd/task-9.json new file mode 100644 index 0000000..15933fc --- /dev/null +++ b/.atlas-ai/cdd/task-9.json @@ -0,0 +1,15 @@ +{ + "task": 9, + "given": "Atlas Pro checkout is hosted outside this repository on atlas-ai.au and depends on Stripe dashboard state", + "when": "this repo documents the checkout surface contract", + "then": [ + "the repo records the monthly and annual Atlas Pro price points", + "the repo records the externally hosted /pro and /pro/success URL contract", + "external Stripe product creation, live checkout wiring, webhook evidence, and site deployment are logged as blocked rather than fabricated" + ], + "evidence_required": [ + "grep -n \"Atlas Pro checkout contract\" README.md", + "python3 -m pytest tests/ -x -q", + "FLEET-LOG-NET.md BLOCKED entries for external Stripe/site subtasks" + ] +} diff --git a/.claude-plugin/.npmignore b/.claude-plugin/.npmignore new file mode 100644 index 0000000..e16645a --- /dev/null +++ b/.claude-plugin/.npmignore @@ -0,0 +1 @@ +marketplace.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e058b8e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "atlas-prd-taskmaster-local", + "description": "Local-path marketplace for prd-taskmaster plugin testing (v5.0.2 dev). Single plugin pointing to this worktree.", + "owner": { + "name": "Atlas-X-AI" + }, + "plugins": [ + { + "name": "prd-taskmaster", + "description": "Zero-config goal-to-tasks engine for Claude Code, plugin-native rebuild with CDD-verified execution", + "source": "./", + "category": "development" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..932e9b6 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "prd-taskmaster", + "version": "4.0.0", + "description": "Zero-config goal-to-tasks engine for Claude Code (the Atlas engine). Graded PRD validation, dependency-ordered task graph, and CDD-verified execution.", + "author": { + "name": "Atlas AI", + "url": "https://atlas-ai.au" + }, + "homepage": "https://github.com/anombyte93/prd-taskmaster", + "repository": "https://github.com/anombyte93/prd-taskmaster.git", + "license": "MIT", + "keywords": ["claude-code", "taskmaster", "prd", "plugin", "cdd", "atlas"] +} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..41ea51b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,108 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + # Proves the zero-dependency promise: the package + CLI must run with NO pip + # installs at all (only the Python stdlib). Skill users install via curl and + # get exactly this. + stdlib-only: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Guard — package imports with no third-party deps + run: | + python -S -c "import sys; sys.path.insert(0,'.'); import prd_taskmaster, prd_taskmaster.cli, prd_taskmaster.pipeline, prd_taskmaster.shipcheck; print('stdlib-only import OK', prd_taskmaster.__version__)" + - name: Guard — no third-party imports anywhere in the package + run: | + python - <<'PY' + import ast, pathlib, sys + STDLIB = set(sys.stdlib_module_names) + allowed = STDLIB | {"prd_taskmaster"} + bad = [] + for f in pathlib.Path("prd_taskmaster").rglob("*.py"): + tree = ast.parse(f.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + mods = [n.name.split(".")[0] for n in node.names] + elif isinstance(node, ast.ImportFrom): + mods = [(node.module or "").split(".")[0]] if node.level == 0 else [] + else: + continue + for m in mods: + if m and m not in allowed: + bad.append(f"{f}: {m}") + if bad: + print("Non-stdlib imports found:"); print("\n".join(bad)); sys.exit(1) + print("no third-party imports in prd_taskmaster/") + PY + - name: Install pytest only (no project deps) + run: pip install pytest + - name: Core + plugin tests (stdlib path) + run: python -m pytest tests/core tests/plugin -q + + # Full suite including the FastMCP plugin surface (needs the mcp package). + full: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install deps + run: | + pip install pytest + pip install -r mcp-server/requirements.txt + - name: Full test suite + run: python -m pytest tests/ -q + + quickstart-walkthrough: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Deterministic quickstart walkthrough + run: bash tests/scripts/quickstart-walkthrough.sh + + # All five version strings must agree. + version-agreement: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check version strings match + run: | + python - <<'PY' + import json, re, pathlib, sys + pkg = pathlib.Path("prd_taskmaster/__init__.py").read_text() + pkg_v = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', pkg).group(1) + pj = json.loads(pathlib.Path("package.json").read_text())["version"] + plug = json.loads(pathlib.Path(".claude-plugin/plugin.json").read_text())["version"] + sh = re.search(r'VERSION="([^"]+)"', pathlib.Path("install.sh").read_text()).group(1) + chlog = pathlib.Path("CHANGELOG.md").read_text() + chlog_has = "v4.0.0" in chlog or "[4.0.0]" in chlog or "4.0.0" in chlog + versions = {"package/__init__": pkg_v, "package.json": pj, "plugin.json": plug, "install.sh": sh} + print(versions, "CHANGELOG mentions 4.0.0:", chlog_has) + vals = set(versions.values()) + if len(vals) != 1: + print("VERSION MISMATCH:", versions); sys.exit(1) + if not chlog_has: + print("CHANGELOG missing the current version entry"); sys.exit(1) + print("all versions agree:", vals.pop()) + PY diff --git a/.github/workflows/worker-secret-scan.yml b/.github/workflows/worker-secret-scan.yml new file mode 100644 index 0000000..1d1a667 --- /dev/null +++ b/.github/workflows/worker-secret-scan.yml @@ -0,0 +1,35 @@ +name: Worker secret scan + +on: + pull_request: + paths: + - "workers/**" + - "scripts/deploy-worker.sh" + - ".github/workflows/worker-secret-scan.yml" + push: + branches: + - fleet-w2-backend + paths: + - "workers/**" + - "scripts/deploy-worker.sh" + - ".github/workflows/worker-secret-scan.yml" + +jobs: + worker-secret-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Scan Worker files for committed secrets + run: | + set -euo pipefail + stripe_key='sk_(live|test)_' + stripe_webhook='wh''sec_[A-Za-z0-9]+' + resend_key='re_[A-Za-z0-9]{20,}' + pem_key='-----BEGIN [A-Z ]*PRIVATE KEY-----' + ed_key='ED25519_PRIVATE_KEY=[0-9a-fA-F]{64}' + secret_pattern="(${stripe_key}|${stripe_webhook}|${resend_key}|${pem_key}|${ed_key})" + if git ls-files workers scripts/deploy-worker.sh .github/workflows/worker-secret-scan.yml \ + | xargs grep -nE "${secret_pattern}"; then + echo "Potential Worker secret material was committed." >&2 + exit 1 + fi diff --git a/.gitignore b/.gitignore index 158e0bc..14e6e98 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,42 @@ PUBLISHING_GUIDE.md DEVELOPER_README.md DISCUSSION_WELCOME.md SHARING_TEMPLATES.md + +# local engine state (dogfood/users) +.taskmaster/ +.atlas-ai/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dev-debug.log +# Dependency directories +node_modules/ +# Environment variables +.env +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# OS specific + +.claude/ +.env +.env.example +CLAUDE.md + +# Task files +# tasks.json +# tasks/ + +# License signing key material +*.key +*.pem +license-signing* diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..891c845 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,6 @@ +{ + "go": { + "command": "python3", + "args": ["${CLAUDE_PLUGIN_ROOT}/mcp-server/server.py"] + } +} diff --git a/.taskmaster/docs/prd.md b/.taskmaster/docs/prd.md deleted file mode 100644 index b9c1b55..0000000 --- a/.taskmaster/docs/prd.md +++ /dev/null @@ -1,2571 +0,0 @@ -# PRD: Agentic Arena-Based Skill Creation & Optimization System - -**Author:** anombyte -**Date:** 2025-01-22 -**Status:** Ready for Implementation -**Version:** 4.0 (Taskmaster Optimized) -**Taskmaster Optimized:** Yes -**Original Version:** v3.0 from skill_creating/planning/PRD.md - ---- - -## Table of Contents - -1. [Executive Summary](#executive-summary) -2. [Problem Statement](#problem-statement) -3. [Goals & Success Metrics](#goals--success-metrics) -4. [User Stories](#user-stories) -5. [Functional Requirements](#functional-requirements) -6. [Non-Functional Requirements](#non-functional-requirements) -7. [Technical Considerations](#technical-considerations) -8. [Implementation Roadmap](#implementation-roadmap) -9. [Out of Scope](#out-of-scope) -10. [Open Questions & Risks](#open-questions--risks) -11. [Validation Checkpoints](#validation-checkpoints) -12. [Appendix: Task Breakdown Hints](#appendix-task-breakdown-hints) - ---- - -## Executive Summary - -Current Claude Code skill creation is "write once" without optimization or validation. Users need an intelligent system that evolves skills through tournament-style arena battles with empirical testing—comparing real outputs, not theoretical code quality. This system provides database-first collective knowledge (show existing skills before creating), delivers quick base skills in 30 seconds for immediate use, then runs background optimization (25-45 min) using agentic orchestration and LLM-as-judge evaluation. Expected impact: average skill scores improve from 78/100 (base) to 93/100 (optimized), with 50% of requests served from collective database within 3 months. - ---- - -## Problem Statement - -### Current Situation - -Users create Claude Code skills manually by writing SKILL.md files without: -- **Quality validation**: No way to know if skill will work well before deploying -- **Optimization**: Skills are written once and never improved -- **Collective knowledge**: Each user reinvents solutions others have already created -- **Empirical testing**: Skills are evaluated by reading code, not running them -- **Evolution mechanism**: No systematic way to iterate and improve skills - -**Evidence:** -- From user requirement: "I can't create PRDs myself. I want the best possible PRD for optimal outcomes." -- User explicitly stated: "Planning is 95% of the work with vibe coding" -- Current skill_creating skill guides creation but doesn't optimize or validate - -### User Impact - -- **Who is affected:** Claude Code users creating custom skills (engineers, technical users) -- **How they're affected:** - - Spend hours writing skills that may not work well - - No feedback on skill quality until they use it in production - - Reinvent solutions others have already created - - No systematic improvement process - - Miss opportunities to leverage collective expertise -- **Severity:** High - Directly impacts development velocity and skill effectiveness - -### Business Impact - -- **Cost of problem:** - - Wasted time creating suboptimal skills (estimated 2-4 hours per skill) - - Poor skill quality reduces Claude Code effectiveness - - User frustration from trial-and-error skill development -- **Opportunity cost:** - - Missing collective intelligence benefits (GitHub Copilot-like network effects) - - Not capitalizing on community improvements - - Slower Claude Code adoption due to skill creation friction -- **Strategic importance:** - - Skills are core differentiator for Claude Code vs competitors - - Quality skill ecosystem drives user retention and engagement - - Collective evolution enables exponential improvement vs linear - -### Why Solve This Now? - -1. **2025 LLM evaluation best practices available**: Arena-Lite architecture, LLM-as-judge patterns, realistic test generation -2. **Technical capability ready**: Claude Code Task tool enables background agentic orchestration -3. **User demand clear**: Explicit request for "best possible" skills with automated optimization -4. **Competitive timing**: First to market with collective skill evolution for AI coding tools -5. **Foundation for future**: This enables advanced features (server farms, continuous evolution) - ---- - -## Goals & Success Metrics - -### Goal 1: Improve Skill Quality Through Arena Optimization - -**Description:** Skills optimized through arena battles score significantly higher than base versions - -**Metric:** Average score improvement (final vs base skill) - -**Baseline:** 0 points (no optimization exists today) - -**Target:** +15 points average (e.g., 78/100 base → 93/100 optimized) - -**Timeframe:** Measured per skill, target achieved for 80% of skills within 30 min arena completion - -**Measurement Method:** Automated scoring via LLM-as-judge comparing base (v0.1) vs optimized (v1.0) skill outputs - ---- - -### Goal 2: Enable Collective Knowledge Reuse - -**Description:** Users find and reuse existing high-quality skills instead of recreating - -**Metric:** Reuse rate (% of skill requests served from collective database) - -**Baseline:** 0% (no collective database exists) - -**Target:** 50% of requests match existing skills within 3 months - -**Timeframe:** 3 months post-launch - -**Measurement Method:** Track database queries with confidence > 0.8, user selection of existing vs "build custom" - ---- - -### Goal 3: Fast Time-to-First-Value - -**Description:** Users get working skill immediately while optimization runs in background - -**Metric:** Time to base skill (v0.1) delivery - -**Baseline:** N/A (current: manual creation 30-120 min) - -**Target:** < 30 seconds for base skill generation - -**Timeframe:** Every skill creation - -**Measurement Method:** Track timestamp from user request to v0.1 skill deployed and usable - ---- - -### Goal 4: Reliable Arena Completion Time - -**Description:** Background optimization completes within predictable time windows - -**Metric:** Arena completion time (p95) - -**Baseline:** N/A - -**Target:** < 30 minutes for moderate-complexity skills (p95) - -**Timeframe:** Every arena execution - -**Measurement Method:** Track arena start → convergence timestamps, categorize by skill complexity - ---- - -### Goal 5: High User Satisfaction - -**Description:** Users rate optimized skills highly and adopt the system - -**Metric:** Average user rating of optimized skills - -**Baseline:** N/A - -**Target:** ≥ 4.5/5 stars average - -**Timeframe:** Ongoing (minimum 50 ratings for statistical validity) - -**Measurement Method:** Post-execution optional rating prompt (1-5 stars), aggregate in database - ---- - -### Goal 6: Build Thriving Collective Database - -**Description:** Grow database of high-quality community-contributed skills - -**Metric:** Total unique skills in collective database - -**Baseline:** 0 skills - -**Target:** 100+ skills across diverse domains in 3 months - -**Timeframe:** 3 months post-launch - -**Measurement Method:** Count unique skill_id entries in Pinecone database - ---- - -## User Stories - -### Story 1: Database-First Skill Discovery - -**As a** Claude Code user, -**I want to** see existing high-quality skills before creating a new one, -**So that I can** reuse proven solutions instead of reinventing. - -**Acceptance Criteria:** -- [ ] System queries Pinecone database before generating new skill -- [ ] Shows matching skills with arena scores (e.g., 91.5/100) -- [ ] Shows user ratings (e.g., ⭐4.7/5 from 342 users) -- [ ] Shows last updated timestamp and key features -- [ ] User can select existing skill or choose "Build custom" -- [ ] Confidence scoring for matches (>0.8 shown, <0.8 clarifies) -- [ ] Results appear within 2 seconds of query - -**Task Breakdown Hint:** -- Task 1.1: Implement Pinecone vector search integration (6h) -- Task 1.2: Build requirement fingerprint generation (4h) -- Task 1.3: Create search results display UI (5h) -- Task 1.4: Add confidence scoring and ranking logic (3h) -- Task 1.5: Implement user selection workflow (3h) -- Task 1.6: Write tests for search accuracy (4h) - -**Dependencies:** R11 (Collective API & Database), R4 (Requirement Extraction) - ---- - -### Story 2: Progressive Skill Delivery (Quick Base → Optimized) - -**As a** Claude Code user, -**I want to** get a working skill immediately while optimization runs in background, -**So that I can** start using it right away without waiting 30 minutes. - -**Acceptance Criteria:** -- [ ] Quick initial research completes in <30 seconds -- [ ] Base skill (v0.1) generated and deployed immediately -- [ ] User can use v0.1 skill while arena runs in background -- [ ] Optional quick scoring of v0.1 (user can decline) -- [ ] Background arena starts automatically after v0.1 delivery -- [ ] User receives notification when optimized v1.0 ready -- [ ] Comparison shows improvement metrics (e.g., +15 points) -- [ ] User can review outputs and choose to deploy v1.0 or keep v0.1 - -**Task Breakdown Hint:** -- Task 2.1: Implement dual-track research system (quick + deep) (8h) -- Task 2.2: Build base skill generation from quick research (6h) -- Task 2.3: Create background job orchestration via Task tool (10h) -- Task 2.4: Implement optional quick scoring workflow (4h) -- Task 2.5: Build notification system for arena completion (3h) -- Task 2.6: Create comparison UI for v0.1 vs v1.0 (6h) -- Task 2.7: Write tests for progressive delivery flow (5h) - -**Dependencies:** R2 (Dual-Track Research), R10 (Background Execution), R14 (Adaptive Complexity) - ---- - -### Story 3: Agentic Question Generation - -**As a** Claude Code user, -**I want** domain-specific questions about my requirements, -**So that** the system can optimize for what matters most to me. - -**Acceptance Criteria:** -- [ ] System analyzes skill domain from user request -- [ ] Generates 3-7 questions specific to domain (e.g., PRD vs PDF extraction) -- [ ] Questions map to evaluation weight priorities -- [ ] Example question: "What's most important: completeness or speed?" -- [ ] User answers converted to weighted criteria (e.g., Quality: 64%, Speed: 5%) -- [ ] Default weights provided if user skips questions -- [ ] Question generation completes within quick research phase (<30s) - -**Task Breakdown Hint:** -- Task 3.1: Build domain analysis agent (6h) -- Task 3.2: Create question generation templates by domain (8h) -- Task 3.3: Implement answer-to-weight conversion logic (5h) -- Task 3.4: Add default weight fallback (2h) -- Task 3.5: Write tests for question relevance (4h) - -**Dependencies:** R3 (Agentic Question Generation), AGENTIC_WEIGHTING_SOLUTIONS.md integration - ---- - -### Story 4: Tournament Arena with Empirical Testing - -**As a** system administrator, -**I want** skills to compete in arena battles using real outputs, -**So that** winners are selected based on empirical quality, not theoretical code review. - -**Acceptance Criteria:** -- [ ] Arena generates 3 skill variations (A, B, C) in Round 1 -- [ ] All variations execute with identical realistic test input -- [ ] System captures complete real outputs (PRDs, code, data, etc.) -- [ ] LLM judge compares outputs directly (not code) -- [ ] Judge uses weighted criteria from user questions -- [ ] Pairwise comparisons with position bias mitigation (randomized order) -- [ ] Bradley-Terry ranking determines winner -- [ ] Winner advances to next round vs 2 new refined variations -- [ ] Arena stops when convergence detected (score plateau, time limit, or target achieved) -- [ ] Maximum 10 rounds or 30 minutes (whichever comes first) - -**Task Breakdown Hint:** -- Task 4.1: Implement skill variation generator agent (10h) -- Task 4.2: Build skill execution isolation sandbox (8h) -- Task 4.3: Create output capture system (4h) -- Task 4.4: Implement LLM-as-judge with pairwise comparison (12h) -- Task 4.5: Add Bradley-Terry ranking algorithm (6h) -- Task 4.6: Build convergence detection logic (5h) -- Task 4.7: Create tournament orchestration loop (8h) -- Task 4.8: Write comprehensive arena tests (10h) - -**Dependencies:** R5 (Realistic Test Data), R6 (Tournament Arena), R7 (Skill Execution), R8 (LLM-as-Judge), R9 (Convergence) - ---- - -### Story 5: Realistic Test Data Generation - -**As a** system administrator, -**I want** realistic test scenarios for skill evaluation, -**So that** arena battles reflect real-world usage, not toy examples. - -**Acceptance Criteria:** -- [ ] Agent discovers realistic use cases via web search -- [ ] LLM takes persona appropriate to skill type (e.g., "Product Manager" for PRD skills) -- [ ] Generates realistic input data (not "Create a PRD" but "Add password reset to fintech SaaS app") -- [ ] Test scenarios evolve across rounds (simple → complex → edge case) -- [ ] Validates realism through domain pattern matching -- [ ] Caches validated scenarios in database for reuse -- [ ] Each skill tested with minimum 1 realistic scenario per round - -**Task Breakdown Hint:** -- Task 5.1: Build test data generator agent with web search (8h) -- Task 5.2: Create persona-based scenario generation (6h) -- Task 5.3: Implement scenario evolution logic (5h) -- Task 5.4: Add realism validation (4h) -- Task 5.5: Build scenario caching in Pinecone (4h) -- Task 5.6: Write tests for scenario quality (4h) - -**Dependencies:** R5 (Realistic Test Data Generation), Pinecone database - ---- - -### Story 6: User-in-the-Loop Validation - -**As a** Claude Code user, -**I want to** review arena outputs and score them myself, -**So that** I can validate automated judgments and provide feedback. - -**Acceptance Criteria:** -- [ ] All arena results stored locally in `.claude/skills/[skill-name]/arena_results/` -- [ ] Results stored as JSON with inputs, outputs, scores, reasoning -- [ ] User can browse results after arena completion -- [ ] UI shows side-by-side comparison of outputs -- [ ] User can score each output (1-5 stars) -- [ ] User feedback submitted to database (opt-in) -- [ ] Feedback improves future arena weights and judgments - -**Task Breakdown Hint:** -- Task 6.1: Create local arena results storage system (4h) -- Task 6.2: Build results browsing UI (8h) -- Task 6.3: Implement side-by-side output comparison (6h) -- Task 6.4: Add user scoring interface (4h) -- Task 6.5: Build feedback submission to database (3h) -- Task 6.6: Write tests for validation flow (4h) - -**Dependencies:** R12 (User-in-the-Loop Validation), R13 (Feedback Collection) - ---- - -### Story 7: Collective Submission & Leaderboards - -**As a** Claude Code user, -**I want to** submit my optimized skill to the collective database, -**So that** others can benefit and my skill can evolve further. - -**Acceptance Criteria:** -- [ ] After arena completion, special notification if skill beats database champions -- [ ] Opt-in prompt to submit to collective -- [ ] Submission includes: skill content, arena scores (dimensional + overall), weights used, generation/lineage -- [ ] Privacy-conscious: input hash (not actual input), output samples (500 chars), anonymous user ID -- [ ] Skills appear in search results for future users -- [ ] Leaderboard shows top skills by domain -- [ ] ELO ratings update based on usage and feedback - -**Task Breakdown Hint:** -- Task 7.1: Build champion comparison logic (3h) -- Task 7.2: Create submission UI with privacy controls (6h) -- Task 7.3: Implement skill submission API endpoint (8h) -- Task 7.4: Build leaderboard display (6h) -- Task 7.5: Add ELO rating calculation (5h) -- Task 7.6: Write tests for submission flow (5h) - -**Dependencies:** R11 (Collective API & Database), R13 (Feedback Collection), skill lineage tracking - ---- - -## Functional Requirements - -### Must Have (P0) - Critical for MVP - -#### REQ-001: Database-First Query System - -**Description:** System MUST query Pinecone collective database before generating new skills, showing existing matches to enable reuse. - -**Acceptance Criteria:** -- [ ] Extract requirements from user request ("Create PRD skill for comprehensive planning") -- [ ] Generate requirement fingerprint (SHA-256 hash of structured requirements) -- [ ] Query Pinecone with semantic search (embedding + metadata filtering) -- [ ] Return matches with confidence scores (0-1 scale) -- [ ] Show skills with confidence > 0.7 -- [ ] Display: name, domain, arena scores (dimensional + overall), user ratings, last updated -- [ ] User can select existing skill or choose "Build custom" -- [ ] Query completes in < 2 seconds - -**Technical Specification:** -```typescript -interface SkillSearchRequest { - userRequest: string; - domain?: string; // Optional: "prd-generation", "pdf-extraction", etc. -} - -interface SkillSearchResult { - skillId: string; - name: string; - domain: string; - confidence: number; // 0-1 - scores: { - completeness: number; - clarity: number; - quality: number; - efficiency: number; - overall: number; - }; - feedback: { - avgRating: number; - totalRatings: number; - successRate: number; - }; - lastUpdated: string; // ISO 8601 - keyFeatures: string[]; -} - -// API Call -POST /api/collective/search -{ - "userRequest": "Create comprehensive PRD skill", - "domain": "prd-generation" -} - -// Response -{ - "matches": [ - { - "skillId": "uuid-123", - "name": "Comprehensive PRD Generator", - "confidence": 0.92, - "scores": { "overall": 91.5, ... }, - "feedback": { "avgRating": 4.7, "totalRatings": 342 }, - ... - } - ], - "queryTime": 1.2 -} -``` - -**Task Breakdown:** -- Implement requirement extraction and fingerprinting: Medium (6h) -- Build Pinecone semantic search integration: Medium (8h) -- Add confidence scoring logic: Small (4h) -- Create search results display: Medium (6h) -- Write integration tests: Small (4h) - -**Dependencies:** Pinecone database setup, embedding model (OpenAI text-embedding-3-small) - ---- - -#### REQ-002: Quick Base Skill Generation (v0.1) - -**Description:** Generate working base skill in < 30 seconds using quick initial research, deployable immediately. - -**Acceptance Criteria:** -- [ ] Quick pattern research: Scan awesome-claude-skills examples (< 10s) -- [ ] Quick domain research: Basic WebSearch for best practices (< 15s) -- [ ] Generate domain-specific questions (3-7 questions) (< 5s) -- [ ] User answers questions or accepts defaults -- [ ] Generate base SKILL.md from quick research + answers (< 5s) -- [ ] Deploy to `.claude/skills/[skill-name]/SKILL.md` -- [ ] Skill immediately usable (activates on triggers) -- [ ] Total time user request → deployed v0.1: < 30 seconds (excluding user answer time) - -**Technical Specification:** -```python -# Quick Research Flow -def generate_base_skill(user_request, user_answers): - # Phase 1: Quick Research (parallel, 15s total) - pattern_research = quick_scan_patterns(user_request) # 10s - domain_research = quick_web_search(user_request) # 15s (parallel) - - # Phase 2: Question Generation (5s) - questions = generate_questions(pattern_research, domain_research) - - # User answers (time not counted) - answers = await user.answer(questions) or get_defaults() - - # Phase 3: Skill Generation (5s) - skill_content = generate_skill_md( - pattern_research, - domain_research, - answers - ) - - # Phase 4: Deployment (1s) - deploy_skill(skill_content, skill_name) - - return {"version": "0.1", "path": skill_path, "time": elapsed} -``` - -**Task Breakdown:** -- Implement quick pattern scanner: Medium (6h) -- Build quick domain research: Small (4h) -- Create question generator agent: Medium (8h) -- Implement base skill template engine: Medium (6h) -- Add deployment automation: Small (3h) -- Write tests for 30s SLA: Small (4h) - -**Dependencies:** WebSearch tool, pattern database (awesome-claude-skills) - ---- - -#### REQ-003: Background Arena Orchestration - -**Description:** After v0.1 delivery, automatically start background arena optimization using Task tool for non-blocking execution. - -**Acceptance Criteria:** -- [ ] Arena starts automatically after v0.1 deployed -- [ ] User can continue working (non-blocking) -- [ ] Orchestrator-worker pattern: central orchestrator + worker agents -- [ ] Workers run in parallel via Task tool -- [ ] State persistence (can resume if interrupted) -- [ ] Progress indicator (optional, non-intrusive) -- [ ] Graceful handling of interruptions -- [ ] User can manually stop optimization -- [ ] Arena completes in < 30 min for moderate skills (p95) - -**Technical Specification:** -```typescript -interface ArenaOrchestrator { - skillName: string; - baseSkill: string; // v0.1 content - userWeights: WeightConfig; - complexity: 1-10; - - async run(): ArenaResult { - // Start background job - const jobId = await Task.start({ - subagent_type: "arena-orchestrator", - prompt: `Run arena optimization for ${skillName}...`, - async: true - }); - - // State machine - while (!converged) { - // Round N - variations = await generateVariations(currentBest); - testData = await generateRealisticTest(round); - outputs = await Promise.all( - variations.map(v => executeSkill(v, testData)) - ); - scores = await judgeOutputs(outputs, userWeights); - currentBest = selectWinner(scores); - - // Check convergence - if (shouldStop(scores, elapsed, rounds)) { - converged = true; - } - } - - return { winner: currentBest, rounds, time: elapsed }; - } -} -``` - -**Task Breakdown:** -- Implement orchestrator-worker pattern: Large (12h) -- Build state persistence system: Medium (6h) -- Add graceful interruption handling: Small (4h) -- Create progress tracking (optional): Small (3h) -- Write orchestration tests: Medium (8h) - -**Dependencies:** Claude Code Task tool, R6 (Tournament Arena), R9 (Convergence Detection) - ---- - -#### REQ-004: Realistic Test Data Generation - -**Description:** Generate realistic test scenarios using web search and persona-based LLM generation, not generic toy examples. - -**Acceptance Criteria:** -- [ ] Web search for domain-specific realistic examples -- [ ] LLM takes persona appropriate to skill (e.g., "Product Manager" for PRD) -- [ ] Generates realistic input matching real-world complexity -- [ ] Example: NOT "Create a PRD" but "Add OAuth 2.0 authentication supporting Google, Microsoft, GitHub to B2B SaaS app with existing auth system, 2FA support, SOC 2 compliance" -- [ ] Scenarios evolve across rounds: Round 1 (simple), Round 2 (complex), Round 3 (edge case) -- [ ] Validates realism via domain pattern matching -- [ ] Caches validated scenarios in Pinecone for reuse - -**Technical Specification:** -```python -# Realistic Test Data Generation -class RealisticTestGenerator: - def generate(self, skill_domain, round_num): - # Step 1: Web search for real examples - examples = web_search(f"{skill_domain} use cases 2025") - - # Step 2: Take persona - persona = get_persona(skill_domain) - # e.g., "Product Manager at B2B SaaS company" - - # Step 3: Generate realistic scenario - scenario = llm.generate( - prompt=f"""You are a {persona}. - Generate a realistic request for {skill_domain}. - Based on these real-world examples: {examples} - - Complexity level: {get_complexity(round_num)} - - Round 1: Simple, common use case - - Round 2: Complex, multi-part scenario - - Round 3: Edge case, unusual constraints - - Be specific, include real constraints and context.""" - ) - - # Step 4: Validate realism - if validate_realism(scenario, examples): - cache_scenario(skill_domain, scenario) - return scenario - else: - return generate(skill_domain, round_num) # Retry -``` - -**Task Breakdown:** -- Build web search integration for examples: Medium (5h) -- Create persona mapping by domain: Small (4h) -- Implement scenario generation with evolution: Medium (8h) -- Add realism validation: Medium (5h) -- Build scenario caching in Pinecone: Small (4h) -- Write tests for scenario quality: Medium (6h) - -**Dependencies:** WebSearch tool, Pinecone database, LLM access - ---- - -#### REQ-005: Arena Tournament with Pairwise Comparison - -**Description:** Run tournament battles using Arena-Lite architecture with direct pairwise comparison of real skill outputs. - -**Acceptance Criteria:** -- [ ] Round 1: Generate 3 variations (A, B, C) -- [ ] Execute all variations with identical test input -- [ ] Capture complete real outputs (not truncated) -- [ ] Judge compares outputs pairwise (A vs B, B vs C, A vs C) -- [ ] Judge uses weighted criteria from user questions -- [ ] Position bias mitigation: randomize output order -- [ ] Bradley-Terry ranking from pairwise results -- [ ] Winner (highest rank) advances to next round -- [ ] Next round: Winner vs 2 new refined variations -- [ ] Repeat until convergence -- [ ] Maximum 10 variations tested per round (performance limit) - -**Technical Specification:** -```python -# Arena Tournament Flow -class ArenaTournament: - def run_round(self, variations, test_input, weights): - # 1. Execute all variations - outputs = [] - for var in variations: - output = execute_skill(var, test_input) - outputs.append({ - "variation": var.id, - "output": output, - "exec_time": elapsed, - "tokens": token_count - }) - - # 2. Pairwise comparisons - comparisons = [] - for i, out_a in enumerate(outputs): - for j, out_b in enumerate(outputs[i+1:]): - # Randomize order to mitigate position bias - order = random.choice(['AB', 'BA']) - - result = judge.compare( - output_a=out_a if order=='AB' else out_b, - output_b=out_b if order=='AB' else out_a, - weights=weights, - criteria=["completeness", "clarity", "quality", "efficiency"] - ) - - comparisons.append({ - "pair": (out_a.variation, out_b.variation), - "winner": result.winner, - "reasoning": result.reasoning, - "scores": result.dimensional_scores - }) - - # 3. Bradley-Terry ranking - rankings = bradley_terry_rank(comparisons) - - # 4. Select winner - winner = rankings[0] - - return { - "winner": winner, - "rankings": rankings, - "outputs": outputs, - "comparisons": comparisons - } -``` - -**Task Breakdown:** -- Implement skill variation generator: Large (10h) -- Build skill execution sandbox: Medium (8h) -- Create output capture system: Small (4h) -- Implement pairwise judge with bias mitigation: Large (12h) -- Add Bradley-Terry ranking: Medium (6h) -- Build tournament loop: Medium (8h) -- Write comprehensive tests: Large (10h) - -**Dependencies:** R7 (Skill Execution), R8 (LLM-as-Judge), Arena-Lite algorithm - ---- - -#### REQ-006: LLM-as-Judge with Weighted Evaluation - -**Description:** Separate judge model evaluates real skill outputs using user-configured weighted criteria with chain-of-thought reasoning. - -**Acceptance Criteria:** -- [ ] Judge model separate from skill execution model (avoid self-evaluation bias) -- [ ] Judges real outputs, NOT skill code -- [ ] Weighted dimensions: Completeness, Clarity, Quality, Efficiency (user-configured) -- [ ] Dimensional scores (0-100) with evidence from outputs -- [ ] Chain-of-thought reasoning before final score -- [ ] Anti-verbosity instructions (prefer concise accurate answers) -- [ ] Position bias mitigation (randomize output order) -- [ ] Store detailed reasoning for user review -- [ ] Integration with agentic weighting (failure mode analysis) - -**Technical Specification:** -```typescript -interface JudgeRequest { - outputA: string; - outputB: string; - weights: { - completeness: number; // 0-100, sums to 100 - clarity: number; - quality: number; - efficiency: number; - }; - domain: string; -} - -interface JudgeResponse { - winner: "A" | "B" | "TIE"; - reasoning: string; // Chain-of-thought - dimensionalScores: { - A: { completeness: number; clarity: number; quality: number; efficiency: number; }; - B: { completeness: number; clarity: number; quality: number; efficiency: number; }; - }; - overallScores: { - A: number; // Weighted average - B: number; - }; - evidence: { - dimension: string; - winner: "A" | "B"; - example: string; // Specific quote from output - }[]; -} - -// Example Judge Prompt -const judgePrompt = ` -You are an expert evaluator for ${domain} skills. - -Compare these two outputs for the task: "${testInput}" - -Output A: -${outputA} - -Output B: -${outputB} - -Evaluation Criteria (weights): -- Completeness (${weights.completeness}%): Does it address all requirements? -- Clarity (${weights.clarity}%): Is it clear and understandable? -- Quality (${weights.quality}%): Is it high quality and detailed? -- Efficiency (${weights.efficiency}%): Is it concise without unnecessary verbosity? - -IMPORTANT: Prefer concise, accurate answers over verbose ones. - -Step 1: Analyze each dimension -[Your chain-of-thought reasoning] - -Step 2: Score each dimension (0-100) -[Dimensional scores with evidence] - -Step 3: Calculate weighted overall score -[Final scores] - -Winner: [A/B/TIE] -`; -``` - -**Task Breakdown:** -- Implement separate judge model call: Small (3h) -- Build weighted evaluation logic: Medium (6h) -- Add chain-of-thought prompting: Small (4h) -- Implement dimensional scoring: Medium (6h) -- Add evidence extraction: Medium (5h) -- Build position bias mitigation: Small (3h) -- Write judge accuracy tests: Medium (8h) - -**Dependencies:** LLM API access (Opus for judging), agentic weighting integration - ---- - -#### REQ-007: Convergence Detection (Multi-Criteria) - -**Description:** Stop arena when ANY stopping condition met: score plateau, time limit, iteration limit, or target achieved. - -**Acceptance Criteria:** -- [ ] Score plateau: Improvement < 2% for 3 consecutive rounds -- [ ] Time limit: Elapsed time > MAX_TIME (adaptive: 10min simple, 25min moderate, 45min complex) -- [ ] Iteration limit: Rounds > MAX_ROUNDS (adaptive: 3-10 based on complexity) -- [ ] Target achieved: Score >= TARGET_SCORE (e.g., 95/100) -- [ ] User interruption: Manual stop by user -- [ ] Log convergence reason for transparency -- [ ] Early stopping prevents wasted computation - -**Technical Specification:** -```python -class ConvergenceDetector: - def should_stop(self, history, elapsed, complexity): - # Adaptive limits based on complexity - MAX_TIME = {1-3: 10, 4-7: 25, 8-10: 45}[complexity] # minutes - MAX_ROUNDS = {1-3: 3, 4-7: 5, 8-10: 10}[complexity] - TARGET_SCORE = 95 - - # Criterion 1: Score plateau - if len(history) >= 3: - recent = history[-3:] - improvements = [ - recent[i].score - recent[i-1].score - for i in range(1, 3) - ] - if all(imp < 0.02 for imp in improvements): - return True, "Score plateau: < 2% improvement for 3 rounds" - - # Criterion 2: Time limit - if elapsed > MAX_TIME * 60: - return True, f"Time limit reached ({MAX_TIME} min)" - - # Criterion 3: Iteration limit - if len(history) >= MAX_ROUNDS: - return True, f"Max rounds reached ({MAX_ROUNDS})" - - # Criterion 4: Target achieved - if history[-1].score >= TARGET_SCORE: - return True, f"Target score achieved ({TARGET_SCORE})" - - # Criterion 5: User interruption (checked elsewhere) - - return False, None -``` - -**Task Breakdown:** -- Implement multi-criteria convergence logic: Medium (6h) -- Add adaptive limits based on complexity: Small (3h) -- Build user interruption handling: Small (3h) -- Add convergence logging: Small (2h) -- Write convergence tests: Small (4h) - -**Dependencies:** R14 (Adaptive Complexity) - ---- - -#### REQ-008: Pinecone Collective Database Integration - -**Description:** HTTP API for skill storage, search, and feedback with Pinecone vector database backend (no MCP installation required). - -**Acceptance Criteria:** -- [ ] Direct HTTP API calls via Bash curl (no MCP to install) -- [ ] POST /api/collective/search - Query for matching skills -- [ ] POST /api/collective/submit - Submit winning skill -- [ ] POST /api/collective/feedback - Submit user rating -- [ ] GET /api/collective/leaderboard - Top skills by domain -- [ ] Pinecone schema includes: skill_id, embedding (1536-dim), metadata (scores, weights, feedback, lineage, ELO, usage stats) -- [ ] Authentication for submissions (API key) -- [ ] Rate limiting (100 requests/min per user) -- [ ] Response time < 2s for search queries - -**Technical Specification:** -```typescript -// Pinecone Schema -interface SkillVector { - id: string; // skill_id - values: number[]; // 1536-dim embedding - metadata: { - // Basic info - name: string; - domain: string; - skill_content: string; // Full SKILL.md - - // Dimensional scores - scores: { - completeness: number; - clarity: number; - quality: number; - efficiency: number; - overall: number; - }; - - // Impact-based weights used - weights: { - completeness: number; - clarity: number; - quality: number; - efficiency: number; - reasoning: string; - }; - - // Real-world user feedback - feedback: { - avg_rating: number; - total_ratings: number; - success_rate: number; - recent_comments: string[]; - }; - - // Lineage & evolution - generation: number; - parent_id: string; - improvement_pct: number; - - // Rankings - elo_rating: number; - leaderboard_rank: number; - - // Usage stats - usage_count: number; - last_used: string; // ISO 8601 - - // Test data - test_scenarios: string[]; - arena_results_url: string; - }; -} - -// API Endpoints -POST /api/collective/search -{ - "query": "comprehensive PRD skill", - "domain": "prd-generation", - "top_k": 5 -} -→ { "matches": [...], "queryTime": 1.2 } - -POST /api/collective/submit -{ - "skill_content": "...", - "scores": {...}, - "weights": {...}, - "parent_id": "uuid-123", - "test_scenarios": [...] -} -→ { "skillId": "uuid-456", "rank": 12 } - -POST /api/collective/feedback -{ - "skill_id": "uuid-456", - "rating": 5, - "comment": "Excellent PRD generation", - "success": true -} -→ { "updated": true } - -GET /api/collective/leaderboard?domain=prd-generation&limit=10 -→ { "skills": [...], "lastUpdated": "2025-01-22T10:00:00Z" } -``` - -**Task Breakdown:** -- Set up Pinecone database and schema: Medium (6h) -- Implement POST /search endpoint: Medium (8h) -- Implement POST /submit endpoint: Medium (8h) -- Implement POST /feedback endpoint: Small (4h) -- Implement GET /leaderboard endpoint: Small (4h) -- Add authentication and rate limiting: Medium (6h) -- Write API integration tests: Medium (8h) - -**Dependencies:** Pinecone account, OpenAI embeddings API - ---- - -### Should Have (P1) - Important for Full Experience - -#### REQ-009: User-in-the-Loop Validation - -**Description:** Store arena results locally, allow users to review outputs and score them manually for validation and feedback. - -**Acceptance Criteria:** -- [ ] All arena results stored in `.claude/skills/[skill-name]/arena_results/` -- [ ] Format: JSON with inputs, outputs, scores, judge reasoning -- [ ] User can browse results after arena completes -- [ ] Side-by-side output comparison UI -- [ ] User can score each output (1-5 stars) -- [ ] User scores submitted to database (opt-in) -- [ ] Feedback improves future arena weights - -**Task Breakdown:** -- Create local storage system: Small (4h) -- Build results browsing UI: Medium (8h) -- Implement comparison view: Medium (6h) -- Add user scoring interface: Small (4h) -- Build feedback submission: Small (3h) -- Write validation tests: Small (4h) - -**Dependencies:** R12 (User-in-the-Loop Validation), Pinecone feedback API - ---- - -#### REQ-010: Adaptive Tournament Sizing - -**Description:** Tournament size (variations, rounds) adapts to skill complexity for optimal time/quality trade-off. - -**Acceptance Criteria:** -- [ ] Simple skills (1-3): 3 variations, 3 rounds, ~10 min -- [ ] Moderate skills (4-7): 5 variations, 5 rounds, ~25 min -- [ ] Complex skills (8-10): 7 variations, 7 rounds, ~45 min -- [ ] Complexity auto-detected from user request and domain -- [ ] User can override default sizing -- [ ] Adaptive convergence limits (time, iterations) - -**Task Breakdown:** -- Implement complexity detection: Medium (6h) -- Build adaptive sizing logic: Small (4h) -- Add user override option: Small (2h) -- Write adaptive tests: Small (4h) - -**Dependencies:** R14 (Adaptive Complexity) - ---- - -#### REQ-011: Skill Lineage Tracking - -**Description:** Track skill evolution over time (parent → child relationships, improvement percentages, generation numbers). - -**Acceptance Criteria:** -- [ ] Each skill stores parent_id in metadata -- [ ] Generation number auto-increments (parent.gen + 1) -- [ ] Improvement percentage calculated vs parent -- [ ] Can trace ancestry (skill → parent → grandparent → ...) -- [ ] Lineage displayed in search results and leaderboard - -**Task Breakdown:** -- Add lineage fields to schema: Small (2h) -- Implement ancestry tracking: Small (4h) -- Build lineage display UI: Small (4h) -- Write lineage tests: Small (3h) - -**Dependencies:** Pinecone schema update - ---- - -### Nice to Have (P2) - Future Enhancement - -#### REQ-012: Test Scenario Evolution Across Rounds - -**Description:** Tests get progressively harder across arena rounds (simple → complex → edge case). - -**Acceptance Criteria:** -- [ ] Round 1: Simple, common use case -- [ ] Round 2: Complex, multi-part scenario -- [ ] Round 3: Edge case, unusual constraints -- [ ] Winner must excel at all difficulty levels - -**Task Breakdown:** -- Implement scenario difficulty progression: Medium (6h) -- Write evolution tests: Small (4h) - -**Dependencies:** R5 (Realistic Test Data) - ---- - -#### REQ-013: Multi-Model Judge Ensemble - -**Description:** Use multiple judge models (e.g., Opus + Sonnet) and aggregate results for more reliable judgments. - -**Acceptance Criteria:** -- [ ] Run same comparison with 2+ judge models -- [ ] Aggregate results (majority vote or average scores) -- [ ] Higher confidence when judges agree -- [ ] Flag for human review when judges disagree - -**Task Breakdown:** -- Implement multi-model judging: Medium (8h) -- Build aggregation logic: Small (4h) -- Add disagreement detection: Small (3h) - -**Dependencies:** Access to multiple LLM models - ---- - -## Non-Functional Requirements - -### Performance - -**Response Time:** -- Database search queries: < 2 seconds (p95) -- Base skill (v0.1) generation: < 30 seconds total -- Arena completion: < 30 minutes for moderate skills (p95) -- LLM judge comparison: < 10 seconds per pairwise comparison - -**Throughput:** -- Support 100 concurrent users creating skills -- Database can handle 1000 queries/hour -- Arena can run 10 background jobs concurrently - -**Resource Usage:** -- Local storage: < 100MB per skill (arena results) -- Memory: < 2GB for background arena process -- Network: Minimize API calls (batch where possible) - ---- - -### Security - -**Authentication:** -- API key required for database submissions -- User ID anonymized in database (hash) -- No PII stored in collective database - -**Data Protection:** -- Skill content public (stored in database) -- User inputs hashed (not stored in plaintext) -- Output samples truncated (500 chars max) -- Local arena results private (stored on user machine) - -**Privacy:** -- Opt-in for database submission -- Opt-in for feedback collection -- YAML flag: `collect-feedback: true/false` -- Can disable via setting - ---- - -### Scalability - -**User Load:** -- Support 1000 active users in first 3 months -- Scale to 10,000 users within 1 year -- Serverless API (auto-scales) - -**Database Volume:** -- Initial: 100 skills -- Growth: 100-500 skills/month -- Storage: 1GB vector database initially -- Pinecone free tier: 1 index, 100k vectors (sufficient for MVP) - -**Arena Jobs:** -- 10 concurrent background jobs per user machine -- Each job runs 10-45 min -- State persistence allows resume - ---- - -### Reliability - -**Uptime:** -- API SLA: 99% monthly uptime -- Graceful degradation if database unavailable (create skills without search) -- Resume capability if arena interrupted - -**Error Handling:** -- Retry logic for API failures (3 retries with exponential backoff) -- Timeout for skill execution (5 min max per skill) -- Fallback to defaults if web search fails - -**Monitoring:** -- Track arena completion rate -- Alert on high failure rate (> 10%) -- Log all convergence reasons - ---- - -### Compatibility - -**Claude Code Version:** -- Requires Claude Code with Task tool support -- Compatible with latest stable release - -**System Requirements:** -- Linux, macOS, Windows (WSL2) -- Internet connection for API calls -- Disk space: 1GB free for arena results - -**Dependencies:** -- Bash (for curl API calls) -- No MCP installation required -- No additional software needed - ---- - -## Technical Considerations - -### System Architecture - -**Current Architecture:** -Claude Code skill system with: -- Skills stored in `~/.claude/skills/[skill-name]/SKILL.md` -- YAML frontmatter for metadata -- Activation via triggers in user messages -- No optimization or validation currently - -**Proposed Architecture:** - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Claude Code (User Machine) │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ skill_creating Skill (Enhanced) │ │ -│ │ ┌────────────────────────────────────────────────┐ │ │ -│ │ │ 1. Database-First Query │ │ │ -│ │ │ └─> Pinecone Search API │ │ │ -│ │ └────────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────────┐ │ │ -│ │ │ 2. Quick Base Generation (v0.1) │ │ │ -│ │ │ ├─> Pattern Research (awesome-claude) │ │ │ -│ │ │ ├─> Domain Research (WebSearch) │ │ │ -│ │ │ ├─> Question Generator Agent │ │ │ -│ │ │ └─> Base Skill Template │ │ │ -│ │ └────────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────────┐ │ │ -│ │ │ 3. Background Arena (via Task tool) │ │ │ -│ │ │ │ │ │ -│ │ │ Orchestrator Agent │ │ │ -│ │ │ ├─> Deep Research Agent │ │ │ -│ │ │ ├─> Test Data Generator Agent │ │ │ -│ │ │ │ └─> WebSearch (realistic examples) │ │ │ -│ │ │ ├─> Variation Generator Agent │ │ │ -│ │ │ ├─> Execution Workers (parallel) │ │ │ -│ │ │ │ ├─ Worker A: Execute Skill A │ │ │ -│ │ │ │ ├─ Worker B: Execute Skill B │ │ │ -│ │ │ │ └─ Worker C: Execute Skill C │ │ │ -│ │ │ ├─> Judge Agent (LLM-as-judge) │ │ │ -│ │ │ │ └─> Opus (separate from exec) │ │ │ -│ │ │ └─> Synthesis Agent (Bradley-Terry) │ │ │ -│ │ │ │ │ │ -│ │ │ State Persistence: Local JSON │ │ │ -│ │ │ Arena Results: .claude/skills/.../arena... │ │ │ -│ │ └────────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────────┐ │ │ -│ │ │ 4. User Validation & Submission │ │ │ -│ │ │ ├─> Review UI (compare outputs) │ │ │ -│ │ │ ├─> User Scoring (1-5 stars) │ │ │ -│ │ │ └─> Submit to Collective (opt-in) │ │ │ -│ │ └────────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - │ HTTP API (curl) - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Collective API (Serverless) │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ POST /search │ │ POST /submit │ │ POST /feedback│ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬────────┘ │ -│ │ │ │ │ -│ └──────────────────┼──────────────────┘ │ -│ ▼ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Pinecone Vector Database │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ Index: claude-skills │ │ │ -│ │ │ - Embeddings (1536-dim) │ │ │ -│ │ │ - Metadata (scores, weights, feedback, ELO) │ │ │ -│ │ │ - Semantic search │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ GET /leaderboard│ │ Auth & Rate │ │ -│ │ │ │ Limiting │ │ -│ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - │ OpenAI API - ▼ - ┌──────────────────┐ - │ OpenAI Embeddings│ - │ text-embed-3-small│ - └──────────────────┘ -``` - -**Key Components:** - -1. **skill_creating Skill (Enhanced):** - - Main entry point for user requests - - Orchestrates entire workflow - - Uses Task tool for background jobs - -2. **Agentic Workers:** - - **Question Generator:** Domain-specific questions - - **Research Agents:** Quick (30s) + Deep (background) - - **Test Data Generator:** Realistic scenarios via web search - - **Variation Generator:** Creates competing skill variations - - **Execution Workers:** Run skills in isolation (parallel) - - **Judge Agent:** LLM-as-judge with weighted criteria - - **Synthesis Agent:** Bradley-Terry ranking, convergence detection - -3. **Collective API:** - - Serverless HTTP API (no MCP required) - - Pinecone vector database backend - - Authentication, rate limiting - - Leaderboard and feedback system - -4. **Local Storage:** - - Arena results: `.claude/skills/[skill-name]/arena_results/` - - State persistence for resume capability - - User can review and validate - ---- - -### API Specifications - -See REQ-008 for detailed API specs. - -**Key Endpoints:** -- `POST /api/collective/search` - Query skills -- `POST /api/collective/submit` - Submit skill -- `POST /api/collective/feedback` - Submit rating -- `GET /api/collective/leaderboard` - Top skills - -**Authentication:** -```bash -curl -X POST https://api.collective.claude-skills.ai/search \ - -H "Authorization: Bearer ${API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{"query": "PRD skill", "domain": "prd-generation"}' -``` - ---- - -### Technology Stack - -**Frontend (User-Facing):** -- Claude Code CLI interface -- Text-based UI for questions, results, comparison -- Markdown formatting for output - -**Backend (Skill Logic):** -- Language: Integrated with Claude Code (no separate backend) -- Agentic orchestration: Claude Code Task tool -- State management: Local JSON files -- LLM calls: Anthropic API (Sonnet for execution, Opus for judging) - -**Database:** -- Vector DB: Pinecone (free tier for MVP) -- Embeddings: OpenAI text-embedding-3-small (1536 dimensions) -- Storage format: JSON metadata + vector embeddings - -**Infrastructure:** -- Execution: User's machine (local) -- API: Serverless (Vercel, AWS Lambda, or Cloudflare Workers) -- No additional installation required (uses existing Claude Code) - -**External Dependencies:** -- WebSearch tool (Claude Code built-in) -- WebFetch tool (Claude Code built-in) -- Anthropic API (Claude Sonnet, Opus) -- OpenAI Embeddings API -- Pinecone API - ---- - -### External Dependencies - -**Third-Party Services:** - -1. **Pinecone Vector Database:** - - Purpose: Collective skill storage and semantic search - - API: https://docs.pinecone.io/ - - Rate Limits: 100 requests/second (free tier) - - Fallback: If down, create skills without search (graceful degradation) - - Cost: Free tier (1 index, 100k vectors) - -2. **OpenAI Embeddings API:** - - Purpose: Generate 1536-dim embeddings for semantic search - - Model: text-embedding-3-small - - Rate Limits: 3000 requests/min - - Fallback: Cache embeddings, retry on failure - - Cost: $0.02 per 1M tokens (~$0.0001 per skill) - -3. **Anthropic API:** - - Purpose: LLM calls for skill execution and judging - - Models: Sonnet (execution), Opus (judging) - - Rate Limits: Per user API key - - Fallback: Use Sonnet for judging if Opus unavailable - - Cost: User pays via their own API key - -**Internal Dependencies:** -- **Claude Code Task tool:** Required for background execution -- **WebSearch tool:** For realistic test scenario discovery -- **WebFetch tool:** For pattern research (awesome-claude-skills) -- **Bash tool:** For curl API calls to collective database - ---- - -### Migration Strategy - -**For Existing skill_creating Skill:** - -1. **Phase 1: Enhance Existing Skill** - - Add database-first query to SKILL.md workflow - - Implement quick base generation (v0.1) - - No breaking changes to current functionality - -2. **Phase 2: Add Background Arena** - - Implement arena orchestration via Task tool - - Runs after v0.1 delivery (non-blocking) - - Opt-in initially (user can skip arena) - -3. **Phase 3: Collective Database Integration** - - Deploy serverless API - - Set up Pinecone database - - Enable submissions - -4. **Phase 4: Gradual Feature Rollout** - - Week 1-2: Database search only - - Week 3-4: Base generation + optional arena - - Week 5-6: Full arena with convergence - - Week 7-8: User validation and feedback - -5. **Phase 5: Optimize Based on Usage** - - Monitor arena completion rates - - Adjust convergence criteria - - Improve test data generation - -**Rollback Plan:** -- Disable database queries (fall back to current behavior) -- Skip arena optimization (deploy v0.1 only) -- Feature flags control each component independently - ---- - -### Testing Strategy - -**Unit Tests:** -- Test coverage: > 80% for new code -- Key areas: - - Requirement fingerprint generation - - Question generation by domain - - Skill variation creation - - Pairwise comparison logic - - Bradley-Terry ranking - - Convergence detection - - API request/response handling - -**Integration Tests:** -- Full workflow tests: - - Database query → results display → user selection - - Quick research → base skill → deployment - - Arena orchestration → workers → judge → synthesis - - Local storage → user review → feedback submission - -**E2E Tests:** -- User journeys: - - New user creates PRD skill (finds existing, chooses it) - - User builds custom skill (v0.1 → arena → v1.0) - - User reviews arena outputs and scores them - - User submits winning skill to collective - - Another user discovers submitted skill via search - -**Performance Tests:** -- Base skill generation completes < 30s (95th percentile) -- Database query completes < 2s (95th percentile) -- Arena completes < 30 min for moderate skills (95th percentile) -- API endpoints respond < 2s (95th percentile) - -**Quality Tests:** -- Realistic test scenarios validated by domain experts -- Judge consistency: Same comparison run 3x should agree ≥ 80% -- Score improvement: v1.0 beats v0.1 in ≥ 80% of cases -- User satisfaction: ≥ 4.5/5 average rating - -**Security Tests:** -- API authentication required for submissions -- Rate limiting prevents abuse -- No PII leaked in database -- Anonymous user IDs cannot be reversed - ---- - -## Implementation Roadmap - -### Phase 1: Foundation (Weeks 1-3) - -**Goal:** Database setup, basic API, requirement extraction, quick research - -**Tasks:** - -- [ ] **Task 1.1:** Set up Pinecone database and schema - - Complexity: Medium (6h) - - Dependencies: None - - Owner: Backend team - - Deliverable: Pinecone index created, schema documented - -- [ ] **Task 1.2:** Implement OpenAI embeddings generation - - Complexity: Small (3h) - - Dependencies: Task 1.1 - - Owner: Backend team - - Deliverable: Function to generate 1536-dim vectors - -- [ ] **Task 1.3:** Build POST /search API endpoint - - Complexity: Medium (8h) - - Dependencies: Tasks 1.1, 1.2 - - Owner: Backend team - - Deliverable: Working semantic search API - -- [ ] **Task 1.4:** Implement requirement extraction and fingerprinting - - Complexity: Medium (6h) - - Dependencies: None - - Owner: Agent team - - Deliverable: Parse user requests into structured requirements - -- [ ] **Task 1.5:** Build quick pattern research (awesome-claude-skills scan) - - Complexity: Small (4h) - - Dependencies: None - - Owner: Agent team - - Deliverable: Quick scan returns relevant patterns in <10s - -- [ ] **Task 1.6:** Build quick domain research (WebSearch integration) - - Complexity: Medium (6h) - - Dependencies: None - - Owner: Agent team - - Deliverable: Web search returns best practices in <15s - -**Validation Checkpoint:** -- [ ] Can query database and get relevant results in < 2s -- [ ] Can extract requirements from user request -- [ ] Quick research completes in < 20s total -- [ ] Unit tests passing for all components - -**Total Effort:** ~33 hours (~1.5 weeks with 2-person team) - ---- - -### Phase 2: Quick Base Generation (Weeks 4-5) - -**Goal:** Question generation, base skill creation, v0.1 deployment - -**Tasks:** - -- [ ] **Task 2.1:** Build question generator agent - - Complexity: Medium (8h) - - Dependencies: Phase 1 complete (uses quick research) - - Owner: Agent team - - Deliverable: Generates 3-7 domain-specific questions - -- [ ] **Task 2.2:** Implement answer-to-weight conversion - - Complexity: Medium (5h) - - Dependencies: Task 2.1 - - Owner: Agent team - - Deliverable: User answers → weighted criteria (e.g., Quality: 64%) - -- [ ] **Task 2.3:** Create base skill template engine - - Complexity: Medium (6h) - - Dependencies: Quick research, questions - - Owner: Agent team - - Deliverable: Generates SKILL.md from inputs - -- [ ] **Task 2.4:** Implement skill deployment automation - - Complexity: Small (3h) - - Dependencies: Task 2.3 - - Owner: Agent team - - Deliverable: Writes SKILL.md to `.claude/skills/[name]/` - -- [ ] **Task 2.5:** Build database-first workflow integration - - Complexity: Medium (8h) - - Dependencies: Task 1.3, Task 2.4 - - Owner: Integration team - - Deliverable: Full flow: query → show results → user select → deploy - -- [ ] **Task 2.6:** Add optional quick scoring of v0.1 - - Complexity: Small (4h) - - Dependencies: Task 2.4 - - Owner: Agent team - - Deliverable: User can score v0.1 immediately (optional) - -**Validation Checkpoint:** -- [ ] Full base generation completes in < 30s (excluding user answer time) -- [ ] Generated v0.1 skill is valid and activates correctly -- [ ] Database query → user selection → deployment works end-to-end -- [ ] Integration tests passing - -**Total Effort:** ~34 hours (~1.5 weeks) - ---- - -### Phase 3: Arena Core (Weeks 6-8) - -**Goal:** Background orchestration, skill execution, realistic test data, pairwise judge - -**Tasks:** - -- [ ] **Task 3.1:** Implement orchestrator-worker pattern via Task tool - - Complexity: Large (12h) - - Dependencies: None (new subsystem) - - Owner: Agent team - - Deliverable: Orchestrator manages arena rounds in background - -- [ ] **Task 3.2:** Build skill variation generator agent - - Complexity: Large (10h) - - Dependencies: Base skill template - - Owner: Agent team - - Deliverable: Creates 3 variations (A, B, C) from base - -- [ ] **Task 3.3:** Build realistic test data generator agent - - Complexity: Medium (8h) - - Dependencies: WebSearch tool - - Owner: Agent team - - Deliverable: Generates realistic scenarios using personas - -- [ ] **Task 3.4:** Implement skill execution sandbox - - Complexity: Medium (8h) - - Dependencies: None - - Owner: Execution team - - Deliverable: Isolated skill execution with timeout - -- [ ] **Task 3.5:** Build output capture system - - Complexity: Small (4h) - - Dependencies: Task 3.4 - - Owner: Execution team - - Deliverable: Captures full skill outputs (no truncation) - -- [ ] **Task 3.6:** Implement LLM-as-judge with pairwise comparison - - Complexity: Large (12h) - - Dependencies: Weighted criteria from questions - - Owner: Judge team - - Deliverable: Compares 2 outputs, returns winner with reasoning - -- [ ] **Task 3.7:** Add position bias mitigation (randomize order) - - Complexity: Small (3h) - - Dependencies: Task 3.6 - - Owner: Judge team - - Deliverable: Multiple comparisons with order flipped - -- [ ] **Task 3.8:** Implement Bradley-Terry ranking - - Complexity: Medium (6h) - - Dependencies: Task 3.6 - - Owner: Judge team - - Deliverable: Ranks variations from pairwise results - -**Validation Checkpoint:** -- [ ] Can generate 3 variations from base skill -- [ ] Can execute all variations with same test input -- [ ] Judge compares outputs and selects winner -- [ ] Ranking produces consistent results -- [ ] Background orchestration runs without blocking user - -**Total Effort:** ~63 hours (~3 weeks) - ---- - -### Phase 4: Convergence & Iteration (Weeks 9-10) - -**Goal:** Multi-round tournaments, convergence detection, state persistence - -**Tasks:** - -- [ ] **Task 4.1:** Build tournament iteration loop - - Complexity: Medium (8h) - - Dependencies: Phase 3 complete - - Owner: Orchestrator team - - Deliverable: Winner advances to next round vs 2 new variations - -- [ ] **Task 4.2:** Implement convergence detection (multi-criteria) - - Complexity: Medium (6h) - - Dependencies: Tournament loop - - Owner: Orchestrator team - - Deliverable: Stops when score plateau, time limit, or target met - -- [ ] **Task 4.3:** Add adaptive tournament sizing - - Complexity: Small (4h) - - Dependencies: Complexity detection - - Owner: Orchestrator team - - Deliverable: Simple (3 rounds), Moderate (5), Complex (7) - -- [ ] **Task 4.4:** Build state persistence system - - Complexity: Medium (6h) - - Dependencies: Tournament loop - - Owner: Orchestrator team - - Deliverable: Can resume arena if interrupted - -- [ ] **Task 4.5:** Implement graceful interruption handling - - Complexity: Small (4h) - - Dependencies: State persistence - - Owner: Orchestrator team - - Deliverable: User can stop arena, resume later - -- [ ] **Task 4.6:** Build arena completion notification - - Complexity: Small (3h) - - Dependencies: Convergence detection - - Owner: UI team - - Deliverable: User notified when v1.0 ready - -**Validation Checkpoint:** -- [ ] Arena runs multiple rounds until convergence -- [ ] Convergence criteria working correctly (no infinite loops) -- [ ] Can interrupt and resume arena -- [ ] Notification appears when complete -- [ ] Arena completes in < 30 min for moderate skills - -**Total Effort:** ~31 hours (~1.5 weeks) - ---- - -### Phase 5: User Validation & Feedback (Weeks 11-12) - -**Goal:** Local storage, review UI, user scoring, feedback collection - -**Tasks:** - -- [ ] **Task 5.1:** Create local arena results storage - - Complexity: Small (4h) - - Dependencies: Output capture - - Owner: Storage team - - Deliverable: JSON files in `.claude/skills/.../arena_results/` - -- [ ] **Task 5.2:** Build results browsing UI - - Complexity: Medium (8h) - - Dependencies: Local storage - - Owner: UI team - - Deliverable: User can view all round results - -- [ ] **Task 5.3:** Implement side-by-side output comparison - - Complexity: Medium (6h) - - Dependencies: Results browsing - - Owner: UI team - - Deliverable: Compare outputs from different variations - -- [ ] **Task 5.4:** Add user scoring interface (1-5 stars) - - Complexity: Small (4h) - - Dependencies: Comparison UI - - Owner: UI team - - Deliverable: User can rate each output - -- [ ] **Task 5.5:** Build version comparison (v0.1 vs v1.0) - - Complexity: Medium (6h) - - Dependencies: Arena results - - Owner: UI team - - Deliverable: Shows improvement metrics (+15 points, etc.) - -- [ ] **Task 5.6:** Implement feedback submission to database - - Complexity: Small (3h) - - Dependencies: User scoring, Pinecone API - - Owner: API team - - Deliverable: POST /feedback endpoint working - -**Validation Checkpoint:** -- [ ] Arena results stored locally -- [ ] User can review and compare outputs -- [ ] User can score outputs and submit feedback -- [ ] Feedback appears in database -- [ ] UI is clear and usable - -**Total Effort:** ~31 hours (~1.5 weeks) - ---- - -### Phase 6: Collective Database Features (Weeks 13-14) - -**Goal:** Skill submission, leaderboards, lineage tracking - -**Tasks:** - -- [ ] **Task 6.1:** Implement POST /submit API endpoint - - Complexity: Medium (8h) - - Dependencies: Pinecone database - - Owner: API team - - Deliverable: Can submit skills to collective - -- [ ] **Task 6.2:** Build submission UI with privacy controls - - Complexity: Medium (6h) - - Dependencies: Arena completion - - Owner: UI team - - Deliverable: User prompted to submit, opt-in, privacy clear - -- [ ] **Task 6.3:** Add champion comparison logic - - Complexity: Small (3h) - - Dependencies: Database query, arena scores - - Owner: API team - - Deliverable: Detect when user skill beats database champions - -- [ ] **Task 6.4:** Implement GET /leaderboard endpoint - - Complexity: Small (4h) - - Dependencies: Pinecone database - - Owner: API team - - Deliverable: Returns top skills by domain - -- [ ] **Task 6.5:** Build leaderboard display UI - - Complexity: Medium (6h) - - Dependencies: Leaderboard API - - Owner: UI team - - Deliverable: Shows top skills with scores, ratings - -- [ ] **Task 6.6:** Add skill lineage tracking - - Complexity: Small (4h) - - Dependencies: Submission API - - Owner: API team - - Deliverable: parent_id, generation, improvement_pct stored - -- [ ] **Task 6.7:** Implement ELO rating calculation - - Complexity: Medium (5h) - - Dependencies: Usage data, feedback - - Owner: API team - - Deliverable: Skills have ELO ratings that update - -**Validation Checkpoint:** -- [ ] Can submit skills to collective -- [ ] Leaderboard shows top skills accurately -- [ ] Lineage tracking works (can trace ancestry) -- [ ] ELO ratings update based on usage -- [ ] Privacy controls working (anonymized data) - -**Total Effort:** ~36 hours (~1.5 weeks) - ---- - -### Phase 7: Testing & Polish (Weeks 15-16) - -**Goal:** Comprehensive testing, bug fixes, performance optimization, documentation - -**Tasks:** - -- [ ] **Task 7.1:** Write comprehensive unit tests - - Complexity: Large (16h) - - Dependencies: All features implemented - - Owner: QA team - - Deliverable: > 80% code coverage - -- [ ] **Task 7.2:** Write integration tests - - Complexity: Large (12h) - - Dependencies: All features implemented - - Owner: QA team - - Deliverable: All workflows tested end-to-end - -- [ ] **Task 7.3:** Write E2E tests (user journeys) - - Complexity: Medium (10h) - - Dependencies: All features implemented - - Owner: QA team - - Deliverable: Key user journeys automated - -- [ ] **Task 7.4:** Performance testing and optimization - - Complexity: Medium (8h) - - Dependencies: All features implemented - - Owner: Performance team - - Deliverable: Meet all performance targets (< 30s base, < 30min arena, etc.) - -- [ ] **Task 7.5:** Bug fixes from testing - - Complexity: Variable (16h estimated) - - Dependencies: Tests written - - Owner: All teams - - Deliverable: All critical bugs fixed, P1 bugs addressed - -- [ ] **Task 7.6:** Write user documentation - - Complexity: Medium (8h) - - Dependencies: All features implemented - - Owner: Docs team - - Deliverable: README, usage guide, troubleshooting - -- [ ] **Task 7.7:** Create example arena results - - Complexity: Small (4h) - - Dependencies: Arena working - - Owner: Docs team - - Deliverable: Example outputs for documentation - -**Validation Checkpoint:** -- [ ] All tests passing (unit, integration, E2E) -- [ ] Performance benchmarks met -- [ ] Zero critical bugs, minimal P1 bugs -- [ ] Documentation complete and clear -- [ ] System ready for production - -**Total Effort:** ~74 hours (~3.5 weeks) - ---- - -### Phase 8: Deployment & Rollout (Weeks 17-18) - -**Goal:** Deploy to production, gradual rollout, monitoring, iteration - -**Tasks:** - -- [ ] **Task 8.1:** Deploy serverless API to production - - Complexity: Small (4h) - - Dependencies: API tested - - Owner: DevOps team - - Deliverable: API live at production URL - -- [ ] **Task 8.2:** Set up Pinecone production database - - Complexity: Small (2h) - - Dependencies: Schema finalized - - Owner: DevOps team - - Deliverable: Production Pinecone index ready - -- [ ] **Task 8.3:** Deploy enhanced skill_creating skill - - Complexity: Small (2h) - - Dependencies: All features tested - - Owner: DevOps team - - Deliverable: Updated SKILL.md deployed - -- [ ] **Task 8.4:** Set up monitoring and alerting - - Complexity: Small (4h) - - Dependencies: API deployed - - Owner: DevOps team - - Deliverable: Track API uptime, error rates, arena completion rates - -- [ ] **Task 8.5:** Gradual rollout (beta users first) - - Complexity: Small (2h) - - Dependencies: Monitoring set up - - Owner: Product team - - Deliverable: 10 beta users test system - -- [ ] **Task 8.6:** Gather feedback from beta users - - Complexity: Medium (8h) - - Dependencies: Beta rollout - - Owner: Product team - - Deliverable: Feedback collected, prioritized - -- [ ] **Task 8.7:** Iterate based on feedback - - Complexity: Medium (8h) - - Dependencies: Feedback gathered - - Owner: All teams - - Deliverable: Top 3-5 improvements implemented - -- [ ] **Task 8.8:** Full public launch - - Complexity: Small (2h) - - Dependencies: Beta successful - - Owner: Product team - - Deliverable: Announcement, full availability - -**Validation Checkpoint:** -- [ ] API stable and responding correctly -- [ ] Beta users successfully creating and optimizing skills -- [ ] Monitoring shows healthy metrics -- [ ] Feedback incorporated -- [ ] Ready for public launch - -**Total Effort:** ~32 hours (~1.5 weeks) - ---- - -### Task Dependencies Visualization - -``` -Phase 1 (Foundation): - 1.1 (Pinecone) → 1.2 (Embeddings) → 1.3 (Search API) - 1.4 (Requirements) ──────────────────┐ - 1.5 (Pattern Research) ──────────────┤ - 1.6 (Domain Research) ───────────────┴─→ Phase 2 - -Phase 2 (Base Generation): - [Phase 1] → 2.1 (Questions) → 2.2 (Weights) → 2.3 (Template) → 2.4 (Deploy) - 1.3 (Search) ────────────────────────────────────────┐ - 2.4 (Deploy) ────────────────────────────────────────┴─→ 2.5 (Workflow) - 2.4 (Deploy) → 2.6 (Quick Scoring) - -Phase 3 (Arena Core): - 3.1 (Orchestrator) ──────────────────────────────────┐ - 3.2 (Variation Gen) ─────────────────────────────────┤ - 3.3 (Test Data) ─────────────────────────────────────┤ - 3.4 (Execution) → 3.5 (Output Capture) ──────────────┼─→ 3.8 (Ranking) - 3.6 (Judge) → 3.7 (Bias Mitigation) ─────────────────┘ - -Phase 4 (Convergence): - [Phase 3] → 4.1 (Tournament Loop) → 4.2 (Convergence) → 4.6 (Notification) - 4.1 → 4.3 (Adaptive Sizing) - 4.1 → 4.4 (State Persist) → 4.5 (Interruption) - -Phase 5 (User Validation): - 3.5 (Output Capture) → 5.1 (Local Storage) → 5.2 (Browse UI) - 5.2 → 5.3 (Comparison) → 5.4 (User Scoring) → 5.6 (Feedback API) - 4.6 (Notification) → 5.5 (Version Compare) - -Phase 6 (Collective): - 1.1 (Pinecone) ──────────────────────────────────────┐ - 4.2 (Convergence) → 6.1 (Submit API) → 6.2 (Submit UI)│ - 6.1 → 6.3 (Champion Compare) ───────────────────────┤ - 6.1 → 6.4 (Leaderboard API) → 6.5 (Leaderboard UI) ─┤ - 6.1 → 6.6 (Lineage) ─────────────────────────────────┤ - 6.1 → 6.7 (ELO) ─────────────────────────────────────┘ - -Phase 7 (Testing): - [All Phases] → 7.1 (Unit) ─┐ - 7.2 (Integration) ─┼→ 7.5 (Bug Fixes) → 7.6 (Docs) - 7.3 (E2E) ─┘ → 7.4 (Performance) → 7.7 (Examples) - -Phase 8 (Deployment): - 7.5 (Bug Fixes) → 8.1 (API Deploy) ─┐ - 7.5 → 8.2 (Pinecone Prod) ──────────┼→ 8.4 (Monitoring) → 8.5 (Beta) - 7.5 → 8.3 (Skill Deploy) ───────────┘ - 8.5 → 8.6 (Feedback) → 8.7 (Iterate) → 8.8 (Launch) - -Critical Path: - 1.1 → 1.2 → 1.3 → 2.5 → 3.1 → 3.6 → 4.1 → 4.2 → 5.1 → 6.1 → 7.5 → 8.8 -``` - ---- - -### Effort Estimation - -**Total Estimated Effort by Phase:** -- Phase 1 (Foundation): 33 hours -- Phase 2 (Base Generation): 34 hours -- Phase 3 (Arena Core): 63 hours -- Phase 4 (Convergence): 31 hours -- Phase 5 (User Validation): 31 hours -- Phase 6 (Collective): 36 hours -- Phase 7 (Testing): 74 hours -- Phase 8 (Deployment): 32 hours - -**Total: ~334 hours** - -**With 2-person team:** -- ~167 hours per person -- ~21 weeks at 8 hours/week -- **~5 months calendar time** - -**With 3-person team:** -- ~111 hours per person -- ~14 weeks at 8 hours/week -- **~3.5 months calendar time** - -**Risk Buffer:** +25% (84 hours) for unknowns, integration challenges, and iteration - -**Final Estimate with Buffer:** -- 2-person team: **~6-7 months** -- 3-person team: **~4-5 months** - -**MVP (Phases 1-5 only):** -- Total: 192 hours -- 2-person team: ~3 months -- 3-person team: ~2 months - ---- - -## Out of Scope - -Explicitly NOT included in this release: - -### 1. Server Farm Optimization (Future Phase 2) - -**What:** Centralized server farms running continuous global tournaments of all submitted skills to find absolute best across all users. - -**Why Out of Scope:** -- Requires significant infrastructure ($$) -- MVP focuses on local execution (user pays own tokens) -- Can be added later without changing architecture -- Future enhancement: 24/7 deep research and optimization - -**Future Consideration:** Document for Phase 2, 6-12 months post-launch - ---- - -### 2. Multi-Language Skill Support - -**What:** Skills that work across multiple programming languages or natural languages. - -**Why Out of Scope:** -- MVP focuses on English, code-agnostic skills -- Adds complexity to question generation and judging -- Limited user demand in initial research - -**Future Consideration:** Add if international users request it - ---- - -### 3. Skill Marketplace / Monetization - -**What:** Paid skills, premium collective access, skill authors earning revenue. - -**Why Out of Scope:** -- Collective is free and open initially -- Monetization complicates launch -- Focus on quality and adoption first - -**Future Consideration:** Evaluate after 100+ skills and 1000+ users - ---- - -### 4. IDE Integration Beyond Claude Code - -**What:** VS Code extension, JetBrains plugin, etc. - -**Why Out of Scope:** -- MVP is Claude Code only -- Different architecture for each IDE -- Limited resources - -**Future Consideration:** Partner integrations post-launch - ---- - -### 5. Advanced Judge Models (GPT-4, Gemini, etc.) - -**What:** Support for non-Anthropic judge models. - -**Why Out of Scope:** -- Anthropic models (Sonnet, Opus) sufficient for MVP -- Cross-platform adds complexity -- Focus on one provider initially - -**Future Consideration:** Add multi-model support if users request it - ---- - -### 6. Real-Time Collaboration on Skills - -**What:** Multiple users co-creating skills simultaneously. - -**Why Out of Scope:** -- MVP is single-user workflow -- Requires collaborative editing infrastructure -- Not in initial requirements - -**Future Consideration:** If teams request it - ---- - -### 7. Automated Skill Maintenance - -**What:** System automatically updates skills when dependencies change or new best practices emerge. - -**Why Out of Scope:** -- Requires continuous monitoring -- Risk of breaking working skills -- Manual updates sufficient for MVP - -**Future Consideration:** Auto-suggest updates, user approves - ---- - -### 8. Skill Analytics Dashboard - -**What:** Detailed analytics on skill usage, performance, user demographics. - -**Why Out of Scope:** -- Basic metrics sufficient for MVP (ratings, usage count) -- Privacy concerns with detailed tracking -- Focus on core functionality first - -**Future Consideration:** Opt-in analytics for skill authors - ---- - -## Open Questions & Risks - -### Open Questions - -#### Q1: What judge model should we use? - -**Current Status:** Considering Opus (highest quality) vs Sonnet (faster, cheaper) - -**Options:** -- **A)** Always use Opus for judging (consistent, highest quality) -- **B)** Haiku for early rounds, Opus for finals (optimized cost) -- **C)** User chooses in questions (flexibility) - -**Owner:** Technical lead - -**Deadline:** End of Phase 2 (before arena implementation) - -**Impact:** Medium (affects arena runtime and cost) - -**Recommendation:** Option B (progressive) - balances quality and cost. Early rounds don't need Opus-level precision, finals do. - ---- - -#### Q2: How many test scenarios per round? - -**Current Status:** Considering 1 vs 3 scenarios per round - -**Options:** -- **A)** 1 scenario per round (faster, less reliable) -- **B)** 3 scenarios per round (slower, more reliable) -- **C)** Adaptive based on skill complexity (1 for simple, 3 for complex) - -**Owner:** Arena architect - -**Deadline:** End of Phase 3 (before convergence testing) - -**Impact:** High (affects arena reliability and runtime) - -**Recommendation:** Option C (adaptive) - simple skills don't need extensive testing, complex skills do. - ---- - -#### Q3: Should we allow user override of convergence criteria? - -**Current Status:** System auto-detects convergence - -**Options:** -- **A)** Fully automatic (no user control) -- **B)** User can set max time/rounds (simple override) -- **C)** User can configure all criteria (full control) - -**Owner:** Product team - -**Deadline:** End of Phase 4 (convergence implementation) - -**Impact:** Low (nice-to-have, not critical) - -**Recommendation:** Option B (simple override) - most users trust defaults, power users want time control. - ---- - -#### Q4: How should we handle skill activation conflicts? - -**Current Status:** Multiple skills might activate on same trigger - -**Options:** -- **A)** First match wins (simple, may not be best) -- **B)** Highest-scored skill wins (quality-focused) -- **C)** User prompted to choose (manual, slower) - -**Owner:** skill_creating maintainer - -**Deadline:** End of Phase 2 (affects base generation) - -**Impact:** Medium (affects user experience) - -**Recommendation:** Option B (highest-scored) - leverages arena scores for automatic quality selection. - ---- - -#### Q5: Should database submissions be moderated? - -**Current Status:** Considering auto-accept vs review queue - -**Options:** -- **A)** Auto-accept all submissions (fast, risk of spam) -- **B)** Auto-scan for dangerous patterns, flag suspicious (balanced) -- **C)** Manual review all submissions (slow, thorough) - -**Owner:** Security team - -**Deadline:** End of Phase 6 (before collective launch) - -**Impact:** High (affects collective quality and safety) - -**Recommendation:** Option B (auto-scan + flag) - prevents obvious abuse, human review for edge cases. - ---- - -### Risks & Mitigation - -| Risk | Likelihood | Impact | Severity | Mitigation | Contingency | -|------|------------|--------|----------|------------|-------------| -| Arena takes longer than 30 min target | Medium | Medium | **Medium** | Adaptive tournament sizing (simple skills fewer rounds), early convergence detection, user can interrupt | User can keep using v0.1 if arena takes too long, improve convergence criteria based on data | -| Test data not realistic enough | Medium | High | **High** | Web search for current examples, persona-based generation, evolution refinement, user validation scoring | Cache proven realistic scenarios, allow users to provide examples, improve generation prompts | -| Judge produces inconsistent results | Medium | High | **High** | Multiple comparisons with order randomization, chain-of-thought required, user review override, aggregate multiple calls | Flag inconsistent judgments for human review, use ensemble of judges, tune prompts | -| User machine offline during arena | Low | Medium | **Low** | State persistence (save progress), resume capability, graceful degradation (partial results usable) | Allow arena restart from last checkpoint, warn users before starting | -| Pinecone API down or rate limited | Low | High | **Medium** | Graceful degradation (create skills without search), retry logic with exponential backoff, cache common queries | Fall back to local-only mode, queue submissions for later | -| Malicious skill submissions | Low | Critical | **High** | Automated scanning for dangerous patterns (rm -rf, curl to unknown domains), review queue for flagged skills, rate limiting per user, community flagging | Manual review, blocklist, user reputation system | -| High API costs (OpenAI embeddings) | Medium | Low | **Low** | Cache embeddings aggressively, batch requests, use efficient models (text-embed-3-small), minimal re-embedding | User pays own API costs, optimize caching strategy | -| Low skill submission rate | Medium | Medium | **Medium** | Encourage submissions (special notification if beats champion), gamification (leaderboards), showcase benefits (others using your skill) | Pre-populate database with high-quality seed skills, improve submission UX | -| User confusion about v0.1 vs v1.0 | Medium | Low | **Low** | Clear labeling (v0.1 "Quick Base", v1.0 "Arena Optimized"), show improvement metrics (+15 points), user can test both | Improve UI copy, add tooltips, documentation | - ---- - -## Validation Checkpoints - -### Checkpoint 1: End of Phase 1 (Foundation) - -**Criteria:** -- [ ] Pinecone database set up and queryable -- [ ] Can generate embeddings and search semantically -- [ ] Search returns relevant results in < 2s -- [ ] Can extract requirements from user requests -- [ ] Quick research (pattern + domain) completes in < 20s -- [ ] Unit tests passing for all components - -**If Failed:** -- Debug Pinecone integration -- Optimize search query performance -- Improve requirement extraction accuracy -- Don't proceed to Phase 2 until stable - ---- - -### Checkpoint 2: End of Phase 2 (Base Generation) - -**Criteria:** -- [ ] Question generator produces relevant domain-specific questions -- [ ] User answers convert to reasonable weights -- [ ] Base skill (v0.1) generates in < 30s -- [ ] v0.1 skill is valid SKILL.md and activates correctly -- [ ] Database-first workflow works end-to-end (query → select → deploy) -- [ ] Integration tests passing - -**If Failed:** -- Refine question templates by domain -- Fix skill template generation bugs -- Improve database query UI -- Don't proceed to Phase 3 until v0.1 generation reliable - ---- - -### Checkpoint 3: End of Phase 3 (Arena Core) - -**Criteria:** -- [ ] Can generate 3 variations from base skill -- [ ] All variations execute with same test input -- [ ] Test data is realistic (validated by spot-checking) -- [ ] Judge compares outputs and selects winner with reasoning -- [ ] Bradley-Terry ranking produces consistent results -- [ ] Background orchestration runs without blocking user -- [ ] Integration tests for arena passing - -**If Failed:** -- Improve variation generator diversity -- Enhance test data realism validation -- Tune judge prompts for consistency -- Don't proceed to Phase 4 until core arena reliable - ---- - -### Checkpoint 4: End of Phase 4 (Convergence) - -**Criteria:** -- [ ] Arena runs multiple rounds until convergence -- [ ] Convergence detects score plateau correctly -- [ ] Time and iteration limits prevent infinite loops -- [ ] Can interrupt and resume arena from saved state -- [ ] Arena completes in < 30 min for moderate skills (test with 5+ examples) -- [ ] User receives notification when arena completes - -**If Failed:** -- Adjust convergence thresholds (may need < 2% → < 3%) -- Optimize arena performance (reduce round time) -- Fix state persistence bugs -- Don't proceed to Phase 5 until convergence robust - ---- - -### Checkpoint 5: End of Phase 5 (User Validation) - -**Criteria:** -- [ ] Arena results stored locally and readable -- [ ] User can browse all round results -- [ ] Side-by-side comparison shows outputs clearly -- [ ] User can score outputs (1-5 stars) -- [ ] Version comparison (v0.1 vs v1.0) shows improvement metrics -- [ ] Feedback submission to database works - -**If Failed:** -- Improve UI clarity and usability -- Fix local storage bugs -- Ensure feedback API working -- Don't proceed to Phase 6 until user can validate effectively - ---- - -### Checkpoint 6: End of Phase 6 (Collective) - -**Criteria:** -- [ ] Can submit skills to collective database -- [ ] Submissions include all required metadata (scores, weights, lineage) -- [ ] Leaderboard shows top skills accurately -- [ ] Lineage tracking works (can trace parent → child) -- [ ] ELO ratings update based on usage -- [ ] Privacy controls working (anonymized data, no PII) -- [ ] Champion comparison detects when user skill beats database leaders - -**If Failed:** -- Fix submission API bugs -- Improve leaderboard ranking algorithm -- Ensure privacy compliance -- Don't proceed to Phase 7 until collective reliable - ---- - -### Checkpoint 7: End of Phase 7 (Testing) - -**Criteria:** -- [ ] Unit test coverage > 80% -- [ ] All integration tests passing -- [ ] E2E tests cover key user journeys -- [ ] Performance benchmarks met: - - [ ] Base generation < 30s (p95) - - [ ] Database query < 2s (p95) - - [ ] Arena completion < 30 min moderate skills (p95) -- [ ] Zero critical bugs (P0) -- [ ] P1 bugs addressed or accepted as known issues -- [ ] Documentation complete (README, usage guide, troubleshooting) - -**If Failed:** -- Fix critical bugs before launch -- Optimize performance to meet benchmarks -- Complete documentation -- Don't proceed to Phase 8 until quality gates met - ---- - -### Checkpoint 8: Production Launch - -**Criteria (at each rollout stage):** - -**Beta (10 users):** -- [ ] At least 5 skills created successfully -- [ ] At least 2 arenas completed successfully -- [ ] Error rate < 5% (acceptable for beta) -- [ ] User feedback collected (survey or interviews) - -**If Beta Failed:** -- Fix bugs discovered -- Improve UX based on feedback -- Iterate before wider rollout - -**Public Launch (all users):** -- [ ] Beta successful with no critical issues -- [ ] Monitoring shows healthy metrics (API uptime > 99%, error rate < 1%) -- [ ] Database has at least 10 seed skills (pre-populated) -- [ ] Documentation published and accessible -- [ ] Support channel ready (GitHub issues, Discord, etc.) - -**If Public Launch Failed:** -- Rollback to beta (disable for most users) -- Fix issues -- Re-launch when stable - ---- - -## Appendix: Task Breakdown Hints - -### Suggested Taskmaster Task Structure - -**Phase 1: Foundation (10 tasks, ~33 hours)** -1. Set up Pinecone database and schema (6h) - Dependencies: None -2. Implement OpenAI embeddings generation (3h) - Dependencies: Task 1 -3. Build POST /search API endpoint (8h) - Dependencies: Tasks 1, 2 -4. Implement requirement extraction (6h) - Dependencies: None -5. Build quick pattern research (4h) - Dependencies: None -6. Build quick domain research via WebSearch (6h) - Dependencies: None - -**Phase 2: Base Generation (6 tasks, ~34 hours)** -7. Build question generator agent (8h) - Dependencies: Tasks 5, 6 -8. Implement answer-to-weight conversion (5h) - Dependencies: Task 7 -9. Create base skill template engine (6h) - Dependencies: Tasks 5, 6, 7 -10. Implement skill deployment automation (3h) - Dependencies: Task 9 -11. Build database-first workflow integration (8h) - Dependencies: Tasks 3, 10 -12. Add optional quick scoring of v0.1 (4h) - Dependencies: Task 10 - -**Phase 3: Arena Core (8 tasks, ~63 hours)** -13. Implement orchestrator-worker pattern via Task tool (12h) - Dependencies: None -14. Build skill variation generator agent (10h) - Dependencies: Task 9 -15. Build realistic test data generator with web search (8h) - Dependencies: None -16. Implement skill execution sandbox (8h) - Dependencies: None -17. Build output capture system (4h) - Dependencies: Task 16 -18. Implement LLM-as-judge with pairwise comparison (12h) - Dependencies: Task 8 -19. Add position bias mitigation (3h) - Dependencies: Task 18 -20. Implement Bradley-Terry ranking (6h) - Dependencies: Task 18 - -**Phase 4: Convergence (6 tasks, ~31 hours)** -21. Build tournament iteration loop (8h) - Dependencies: Phase 3 complete -22. Implement convergence detection (6h) - Dependencies: Task 21 -23. Add adaptive tournament sizing (4h) - Dependencies: Task 21 -24. Build state persistence system (6h) - Dependencies: Task 21 -25. Implement graceful interruption handling (4h) - Dependencies: Task 24 -26. Build arena completion notification (3h) - Dependencies: Task 22 - -**Phase 5: User Validation (6 tasks, ~31 hours)** -27. Create local arena results storage (4h) - Dependencies: Task 17 -28. Build results browsing UI (8h) - Dependencies: Task 27 -29. Implement side-by-side output comparison (6h) - Dependencies: Task 28 -30. Add user scoring interface (4h) - Dependencies: Task 29 -31. Build version comparison (v0.1 vs v1.0) (6h) - Dependencies: Task 27 -32. Implement feedback submission to database (3h) - Dependencies: Task 30 - -**Phase 6: Collective (7 tasks, ~36 hours)** -33. Implement POST /submit API endpoint (8h) - Dependencies: Task 1 -34. Build submission UI with privacy controls (6h) - Dependencies: Task 26 -35. Add champion comparison logic (3h) - Dependencies: Tasks 3, 22 -36. Implement GET /leaderboard endpoint (4h) - Dependencies: Task 1 -37. Build leaderboard display UI (6h) - Dependencies: Task 36 -38. Add skill lineage tracking (4h) - Dependencies: Task 33 -39. Implement ELO rating calculation (5h) - Dependencies: Task 33 - -**Phase 7: Testing (7 tasks, ~74 hours)** -40. Write comprehensive unit tests (16h) - Dependencies: All features -41. Write integration tests (12h) - Dependencies: All features -42. Write E2E tests (10h) - Dependencies: All features -43. Performance testing and optimization (8h) - Dependencies: All features -44. Bug fixes from testing (16h) - Dependencies: Tasks 40-43 -45. Write user documentation (8h) - Dependencies: Task 44 -46. Create example arena results (4h) - Dependencies: Task 44 - -**Phase 8: Deployment (8 tasks, ~32 hours)** -47. Deploy serverless API to production (4h) - Dependencies: Task 44 -48. Set up Pinecone production database (2h) - Dependencies: Task 44 -49. Deploy enhanced skill_creating skill (2h) - Dependencies: Task 44 -50. Set up monitoring and alerting (4h) - Dependencies: Task 47 -51. Gradual rollout (beta users) (2h) - Dependencies: Task 50 -52. Gather feedback from beta users (8h) - Dependencies: Task 51 -53. Iterate based on feedback (8h) - Dependencies: Task 52 -54. Full public launch (2h) - Dependencies: Task 53 - -**Total: 54 tasks, ~334 hours** - ---- - -### Parallelizable Tasks - -**Can work in parallel:** - -**Phase 1:** -- Tasks 1-2 (sequential), Tasks 4-6 (parallel) - -**Phase 2:** -- Task 7 (after 5-6), Tasks 8-10 (mostly sequential), Task 11 (integrates), Task 12 (parallel with 11) - -**Phase 3:** -- Tasks 13-15 (parallel), Tasks 16-17 (sequential), Tasks 18-20 (sequential) -- Two sub-teams: Orchestration (13-15) and Execution+Judge (16-20) - -**Phase 4:** -- Tasks 21-26 mostly sequential (each depends on previous) - -**Phase 5:** -- Tasks 27-32 mostly sequential, but Task 31 parallel with 28-30 - -**Phase 6:** -- Tasks 33-39 parallel after Task 33 complete - -**Phase 7:** -- Tasks 40-43 parallel, Task 44 after all tests, Tasks 45-46 parallel - -**Phase 8:** -- Tasks 47-49 parallel, Task 50 after 47, Tasks 51-54 sequential - ---- - -### Critical Path Tasks - -**Critical path (longest dependency chain):** -1. Set up Pinecone (Task 1) -2. Build embeddings (Task 2) -3. Build search API (Task 3) -4. Build workflow integration (Task 11) -5. Implement orchestrator (Task 13) -6. Implement judge (Task 18) -7. Build tournament loop (Task 21) -8. Implement convergence (Task 22) -9. Create local storage (Task 27) -10. Implement submit API (Task 33) -11. Bug fixes (Task 44) -12. Public launch (Task 54) - -**Critical path duration:** ~85 hours (~11 weeks at 8h/week with 1 person on critical path) - -**With parallel teams (3 people):** -- Critical path person: 85 hours -- Arena team: 63 hours (Phase 3) -- UI/Validation team: 31 hours (Phase 5) -- **Total calendar time: ~14 weeks (3.5 months)** - ---- - -**End of PRD** - -*This PRD is optimized for taskmaster AI task generation. All requirements include task breakdown hints, complexity estimates, and dependency mapping to enable effective automated task planning.* - -**Ready for Implementation:** Yes -**Next Steps:** Review PRD → Approve → Begin Phase 1 (Foundation) → taskmaster init → taskmaster generate diff --git a/.taskmaster/scripts/execution-state.py b/.taskmaster/scripts/execution-state.py deleted file mode 100755 index 5c653b9..0000000 --- a/.taskmaster/scripts/execution-state.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -""" -Task execution state manager for crash recovery. -""" - -import json -from pathlib import Path -from datetime import datetime, timezone - -class ExecutionState: - def __init__(self): - self.state_file = Path(".taskmaster/state/execution-state.json") - self.state_file.parent.mkdir(parents=True, exist_ok=True) - self.load_state() - - def load_state(self): - """Load current execution state.""" - if self.state_file.exists(): - with open(self.state_file, 'r') as f: - self.state = json.load(f) - else: - self.state = { - "mode": None, - "current_task": None, - "current_subtask": None, - "tasks_completed": [], - "last_update": None - } - - def save_state(self): - """Persist current state to disk.""" - self.state["last_update"] = datetime.now(timezone.utc).isoformat() - with open(self.state_file, 'w') as f: - json.dump(self.state, f, indent=2) - - def set_mode(self, mode): - """Set execution mode (sequential, parallel, etc.).""" - self.state["mode"] = mode - self.save_state() - - def start_task(self, task_id): - """Mark task as started.""" - self.state["current_task"] = task_id - self.state["current_subtask"] = None - self.save_state() - - def start_subtask(self, task_id, subtask_id): - """Mark subtask as started.""" - self.state["current_task"] = task_id - self.state["current_subtask"] = subtask_id - self.save_state() - - def complete_task(self, task_id): - """Mark task as completed.""" - if task_id not in self.state["tasks_completed"]: - self.state["tasks_completed"].append(task_id) - self.state["current_task"] = None - self.state["current_subtask"] = None - self.save_state() - - def has_incomplete_work(self): - """Check if there's incomplete work from previous session.""" - return self.state["current_task"] is not None - - def get_resume_point(self): - """Get information about where to resume.""" - if not self.has_incomplete_work(): - return None - - return { - "mode": self.state["mode"], - "task": self.state["current_task"], - "subtask": self.state["current_subtask"], - "last_update": self.state["last_update"], - "completed_tasks": self.state["tasks_completed"] - } - -if __name__ == "__main__": - state = ExecutionState() - if state.has_incomplete_work(): - resume = state.get_resume_point() - print(f"Incomplete work detected:") - print(f" Task: {resume['task']}") - print(f" Subtask: {resume['subtask']}") - print(f" Last update: {resume['last_update']}") - else: - print("No incomplete work found.") diff --git a/.taskmaster/scripts/learn-accuracy.py b/.taskmaster/scripts/learn-accuracy.py deleted file mode 100755 index 7272b7a..0000000 --- a/.taskmaster/scripts/learn-accuracy.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -""" -Estimation accuracy learning system. -Analyzes actual vs estimated times and improves future estimates. -""" - -import json -from pathlib import Path -import sys -sys.path.insert(0, str(Path(__file__).parent)) -from track_time import get_all_task_times, calculate_accuracy - -class AccuracyLearner: - def __init__(self): - self.reports_dir = Path(".taskmaster/reports") - self.reports_dir.mkdir(parents=True, exist_ok=True) - self.accuracy_file = self.reports_dir / "estimation-accuracy.json" - self.load_accuracy_data() - - def load_accuracy_data(self): - """Load historical accuracy data.""" - if self.accuracy_file.exists(): - with open(self.accuracy_file, 'r') as f: - self.data = json.load(f) - else: - self.data = { - "tasks_analyzed": 0, - "average_accuracy": 100.0, - "adjustment_factor": 1.0, - "history": [] - } - - def save_accuracy_data(self): - """Save accuracy data.""" - with open(self.accuracy_file, 'w') as f: - json.dump(self.data, f, indent=2) - - def analyze_task(self, task_id, estimated_minutes, actual_minutes): - """Analyze a single task's accuracy.""" - accuracy = calculate_accuracy(estimated_minutes, actual_minutes) - - entry = { - "task_id": task_id, - "estimated_minutes": estimated_minutes, - "actual_minutes": actual_minutes, - "accuracy_percent": accuracy, - "variance_percent": accuracy - 100 - } - - self.data["history"].append(entry) - self.data["tasks_analyzed"] += 1 - - # Recalculate average - total_accuracy = sum(h["accuracy_percent"] for h in self.data["history"]) - self.data["average_accuracy"] = round(total_accuracy / len(self.data["history"]), 1) - - # Update adjustment factor - self.data["adjustment_factor"] = round(self.data["average_accuracy"] / 100, 2) - - self.save_accuracy_data() - - return entry - - def get_adjusted_estimate(self, base_estimate_minutes): - """Apply learned adjustment to new estimate.""" - adjusted = base_estimate_minutes * self.data["adjustment_factor"] - return round(adjusted, 0) - - def generate_report(self): - """Generate human-readable accuracy report.""" - if self.data["tasks_analyzed"] < 3: - return "❌ Need at least 3 completed tasks for accuracy analysis." - - history = self.data["history"] - avg_accuracy = self.data["average_accuracy"] - adjustment = self.data["adjustment_factor"] - - # Find outliers - outliers = [h for h in history if abs(h["variance_percent"]) > 50] - - report = f""" -📊 Estimation Accuracy Report - -Tasks Analyzed: {self.data["tasks_analyzed"]} -Average Accuracy: {avg_accuracy}% -Adjustment Factor: {adjustment}x - -Recent Tasks: -""" - - for h in history[-10:]: # Last 10 tasks - status = "✅" if 90 <= h["accuracy_percent"] <= 110 else "⚠️" - report += f" Task {h['task_id']}: {h['actual_minutes']}min (est: {h['estimated_minutes']}min) - {h['accuracy_percent']}% {status}\n" - - if avg_accuracy < 90: - report += f"\n⚠️ You're completing tasks {100 - avg_accuracy:.0f}% faster than estimated.\n" - report += f"🎯 Recommendation: Reduce future estimates by {(1 - adjustment) * 100:.0f}%\n" - elif avg_accuracy > 110: - report += f"\n⚠️ You're taking {avg_accuracy - 100:.0f}% longer than estimated.\n" - report += f"🎯 Recommendation: Increase future estimates by {(adjustment - 1) * 100:.0f}%\n" - else: - report += f"\n✅ Your estimates are accurate! Keep it up.\n" - - if outliers: - report += f"\n⚠️ {len(outliers)} outlier tasks found (>50% variance):\n" - for o in outliers: - report += f" Task {o['task_id']}: {o['variance_percent']:+.0f}% variance\n" - - return report - -if __name__ == "__main__": - learner = AccuracyLearner() - print(learner.generate_report()) diff --git a/.taskmaster/scripts/rollback.sh b/.taskmaster/scripts/rollback.sh deleted file mode 100755 index b6f24b3..0000000 --- a/.taskmaster/scripts/rollback.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/bash -# .taskmaster/scripts/rollback.sh -# Rollback to any task checkpoint - -TASK_ID=$1 - -if [ -z "$TASK_ID" ]; then - echo "❌ Error: Task ID required" - echo "Usage: rollback.sh " - exit 1 -fi - -TAG="checkpoint-task-${TASK_ID}" - -# Check if tag exists -if ! git tag -l | grep -q "^${TAG}$"; then - echo "❌ Error: Checkpoint tag '${TAG}' not found" - echo "Available checkpoints:" - git tag -l "checkpoint-task-*" | sort -V - exit 1 -fi - -echo "🔄 Rolling back to ${TAG}..." - -# Safety check -echo "⚠️ This will:" -echo " - Discard all changes after Task ${TASK_ID}" -echo " - Reset to checkpoint-task-${TASK_ID}" -echo " - Preserve current work in rollback-backup branch" -echo "" -echo "Continue? (yes/no)" -read -r CONFIRM - -if [ "$CONFIRM" != "yes" ]; then - echo "❌ Rollback cancelled" - exit 0 -fi - -# Create backup branch of current state -BACKUP_BRANCH="rollback-backup-$(date +%Y%m%d-%H%M%S)" -git checkout -b "$BACKUP_BRANCH" -git checkout main - -echo "💾 Backed up current state to: ${BACKUP_BRANCH}" - -# Reset to checkpoint -git reset --hard "${TAG}" - -echo "✅ Rolled back to Task ${TASK_ID} completion state" -echo "📝 Updating progress.md..." - -# Log rollback -cat >> .taskmaster/docs/progress.md <= 1: - duration_str = f"{int(duration_hours)}h {int(duration_minutes % 60)}min" - else: - duration_str = f"{int(duration_minutes)} min" - - end_time = end.strftime("%Y-%m-%d %H:%M:%S UTC") - print(f"✅ COMPLETED") - print(f"📅 Ended: {end_time}") - print(f"⏱️ Actual: {duration_str} ({duration_minutes:.1f} min)") - - return duration_minutes - - def get_duration(self): - """Get current duration for in-progress task.""" - if not self.state_file.exists(): - return 0 - - with open(self.state_file, 'r') as f: - data = json.load(f) - - if data['status'] == 'completed': - return data['actual_duration_minutes'] - - start = datetime.fromisoformat(data['start_time']) - now = datetime.now(timezone.utc) - duration_minutes = (now - start).total_seconds() / 60 - return round(duration_minutes, 2) - -def get_all_task_times(): - """Get timing data for all tasks.""" - state_dir = Path(".taskmaster/state") - if not state_dir.exists(): - return [] - - times = [] - for state_file in state_dir.glob("task-*.json"): - with open(state_file, 'r') as f: - data = json.load(f) - if data['status'] == 'completed': - times.append(data) - - return times - -def calculate_accuracy(estimated_minutes, actual_minutes): - """Calculate estimation accuracy percentage.""" - if estimated_minutes == 0: - return 0 - accuracy = (actual_minutes / estimated_minutes) * 100 - return round(accuracy, 1) - -if __name__ == "__main__": - # CLI usage - import sys - - if len(sys.argv) < 3: - print("Usage: python track-time.py [subtask_id]") - print("Commands: start, complete") - sys.exit(1) - - command = sys.argv[1] - task_id = sys.argv[2] - subtask_id = sys.argv[3] if len(sys.argv) > 3 else None - - timer = TaskTimer(task_id, subtask_id) - - if command == "start": - timer.start() - elif command == "complete": - timer.complete() diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3786a56 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this project are documented here. Format based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] — v4.0.0 (in progress) + +The v4.0.0 release merges two previously-unpublished internal lines back into this repo and +re-launches it as an **open-core** product: a free MIT engine plus a commercial **Atlas Pro** +tier. + +### Added +- **Unified deterministic core** (`prd_taskmaster/`) — a single stdlib-only Python package that + is the one source of truth for PRD validation, task calculation, complexity enrichment, + capability detection, the pipeline state machine, and the ship-check gate. Imported by both the + zero-dependency skill (`script.py`) and the FastMCP plugin (`mcp-server/server.py`). +- **5-phase gated pipeline** — `SETUP → DISCOVER → GENERATE → HANDOFF → EXECUTE` with atomic + compare-and-swap transitions over a flock-guarded `pipeline.json`. +- **CDD execute loop** — `skills/execute-task` runs a 13-step contract-driven cycle with evidence + cards; completion is gated by a deterministic `SHIP_CHECK_OK` token (a non-zero exit code in any + evidence file blocks it). +- **Parallel research fan-out** (`prd_taskmaster/parallel.py`) — `plan`/`apply` research packets + let an agent expand tasks across parallel subagents and merge results atomically. +- **`/atlas` command** — the primary user-facing invocation (alias of the orchestrator skill). +- **Product + UX specs** (`docs/product/`) — the living contract the dogfood ship-check verifies + against. +- **Atlas Pro teaser** — the handoff surfaces **Atlas Fleet** (parallel multi-session execution) + as a locked, clearly-priced upgrade. The free engine stays fully functional standalone. + +### Changed +- Repo keeps the name **prd-taskmaster** (and its 508★). The product/command brand is **Atlas**; + the internal "atlas-go" name from the plugin line is retired. +- PRD validation is stricter than older internal versions (grade thresholds + placeholder + attribution). PRDs that previously passed loosely may now score lower — by design. +- Two install paths from one repo: curl one-liner (zero-dependency skill) and Claude Code plugin / + `npm install` (full FastMCP plugin). + +### Fixed +- Ship-check tests aligned to the live `skel/ship-check.py` gate contract (3 stale-schema + failures resolved). + +### Lineage +v4.0.0 merges the **prd-taskmaster-v2 skill line** (internally versioned 2.x, never published) and +the **atlas-go plugin line** (internally 5.x, never published) back into this repository. Those +internal version numbers do not appear as tags here; the public lineage runs +v3.0.0 → v4.0.0. + +## [3.0.0] — 2026-02-12 +- Codified deterministic operations into `script.py`; curl installer with update notifications; + template-based PRD and CLAUDE.md generation; community files. (Pre-merge single-skill product.) diff --git a/FLEET-LOG-BACKEND.md b/FLEET-LOG-BACKEND.md new file mode 100644 index 0000000..e04cd8d --- /dev/null +++ b/FLEET-LOG-BACKEND.md @@ -0,0 +1,16 @@ +# FLEET LOG BACKEND + +## PASS + +- Task 4 PASS: `npm run typecheck` exit 0; `npm test` exit 0 (2 files, 9 tests); `npm run d1:migrate:local` exit 0 (`No migrations to apply!` after initial successful apply); `npm run deploy:dry-run` exit 0; worker secret scan exit 0 (`no matches`); `wrangler dev --port 8790` smoke returned `HTTP/1.1 404 Not Found`. +- Task 5 PASS: `npm run typecheck` exit 0; `npm test` exit 0 (4 files, 18 tests); `npm run deploy:dry-run` exit 0; worker secret scan exit 0 (`no matches`); focused `npm test -- test/stripe.test.ts` exit 0 (9 tests). +- Task 6 PASS: `npm run typecheck` exit 0; `npm test` exit 0 (5 files, 23 tests); `npm run deploy:dry-run` exit 0; worker secret scan exit 0 (`no matches`); focused `npm test -- test/email.test.ts` exit 0 (4 tests) and `npm test -- test/stripe.test.ts` exit 0 (10 tests). +- Task 7 PASS: `npm run typecheck` exit 0; `npm test` exit 0 (6 files, 31 tests); `npm run d1:migrate:local` exit 0 (`0002_rate_limits.sql` applied locally); `npm run deploy:dry-run` exit 0; worker secret scan exit 0 (`no matches`); focused `npm test -- test/refresh.test.ts` exit 0 (9 tests). +- Task 10 PASS: `npm run typecheck` exit 0; `npm test` exit 0 (7 files, 44 tests); `npm run d1:migrate:local` exit 0 (`0003_telemetry.sql` applied locally); `npm run deploy:dry-run` exit 0 (D1 + Analytics Engine bindings shown); worker secret scan exit 0 (`no matches`); focused `npm test -- test/telemetry.test.ts` exit 0 (14 tests). + +## BLOCKED + +- Task 5.6 BLOCKED: live Stripe CLI replay (`stripe listen` / `stripe trigger`) skipped because no Stripe API key or Stripe webhook secret exists on this host; procedure documented in `docs/ops/worker-runbook.md`. +- Task 6.4 BLOCKED: live Resend test-mode delivery skipped because no `RESEND_API_KEY` exists on this host; setup, safe test-mode procedure, and manual resend notes documented in `docs/ops/worker-runbook.md`. +- Task 7.5 BLOCKED: seeded live `/license/refresh` integration against a Stripe test-mode subscription skipped because no Stripe API key exists on this host; local seeded procedure documented in `docs/ops/worker-runbook.md`. +- Task 10.6 BLOCKED: production deploy and public `/telemetry` smoke test skipped per mission guardrail; deploy/smoke procedure and KPI queries documented in `docs/ops/worker-runbook.md` and `docs/ops/telemetry-queries.md`. diff --git a/FLEET-LOG-CLIENT.md b/FLEET-LOG-CLIENT.md new file mode 100644 index 0000000..5499cdf --- /dev/null +++ b/FLEET-LOG-CLIENT.md @@ -0,0 +1,6 @@ +Task 2 PASS — python3 -m pytest tests/ -x -q — exit 0 — 190 passed in 53.68s +Task 3 BLOCKED — python3 -m pytest tests/ -x -q — exit 1 — 73 passed, 1 failed: tests/core/test_fleet_detection.py::test_detect_capabilities_flips_both_paths_to_premium_with_launcher_mcp expects launcher-only premium, conflicting with Task 3 license-aware gate; focused owned-domain verification passed: python3 -m pytest tests/core/test_license_activate.py tests/core/test_capabilities.py tests/mcp/test_mcp_tools.py tests/plugin/test_skill_files.py -q — exit 0 — 61 passed in 7.79s + +## BLOCKED + +- Task 3 full-suite compatibility: `tests/core/test_fleet_detection.py::test_detect_capabilities_flips_both_paths_to_premium_with_launcher_mcp` is outside the CLIENT file domain and still asserts `atlas-launcher` registration alone sets `tier=premium`; Task 3 requires `tier=premium` only when launcher is registered and `license_status` is `active` or `grace`. diff --git a/FLEET-LOG-KEY.md b/FLEET-LOG-KEY.md new file mode 100644 index 0000000..e574bf5 --- /dev/null +++ b/FLEET-LOG-KEY.md @@ -0,0 +1,10 @@ +# FLEET LOG KEY + +## Task 1 + +PASS task 1 license slice: `python3 -m pytest tests/license/ -q` -> exit 0; `10 passed in 0.51s`. +PASS task 1 full suite: `python3 -m pytest tests/ -q` -> exit 0; `173 passed in 54.36s`. + +## BLOCKED + +none diff --git a/FLEET-LOG-NET.md b/FLEET-LOG-NET.md new file mode 100644 index 0000000..fa1b8ba --- /dev/null +++ b/FLEET-LOG-NET.md @@ -0,0 +1,17 @@ +## Task 8 +PASS: Client silent license refresh implemented with mocked refresh tests and guarded capabilities hook. Verification: `python3 -m pytest tests/ -x -q` exit 0 (`214 passed in 59.90s`). + +## Task 9 +PASS: Repo-local checkout contract documented in README without hardcoding live Stripe price IDs. Verification: `grep -n "Atlas Pro checkout contract" README.md` exit 0; `python3 -m pytest tests/ -x -q` exit 0 (`214 passed in 60.03s`). + +## BLOCKED +Task 9 external: Stripe test-mode product/price creation, atlas-ai.au/pro Buy button wiring, /pro/success page deployment, hosted checkout E2E, webhook/email proof, and live-mode promotion are blocked because this worktree has no atlas-ai.au site repo, Stripe dashboard/API credentials, Resend credentials, or live Worker deployment. Skipped rather than fabricated. + +## Task 11 +PASS: Client telemetry module implemented with stable anonymous install IDs, exact four-field payloads, env/config opt-outs, daemon-thread fire-and-forget sending, and guarded pipeline/ship-check hooks. Verification: `python3 -m pytest tests/test_telemetry.py -q` exit 0 (`12 passed in 0.22s`); `python3 -m pytest tests/core/test_pipeline_state.py tests/core/test_ship_check.py tests/test_telemetry.py -q` exit 0 (`29 passed in 0.69s`); `python3 -m pytest tests/ -x -q` exit 0 (`226 passed in 61.85s`). + +## Task 12 +PASS: install.sh and package postinstall now send best-effort `install` telemetry with env/flag opt-outs; README documents exact payload, event names, and opt-out paths. Verification: `bash tests/install/test_install_telemetry.sh` exit 0 (`install telemetry shell tests passed`); `python3 -m pytest tests/install tests/test_telemetry.py -q` exit 0 (`15 passed in 0.27s`); `python3 -m pytest tests/ -x -q` exit 0 (`229 passed in 65.64s`). + +## BLOCKED +Task 12 external: end-to-end install telemetry verification against a running Worker is blocked because no live Worker deployment or local wrangler/D1 telemetry target is available in this worktree. Verified local shell/postinstall opt-in and opt-out behavior instead. diff --git a/FLEET-LOG-UXREL.md b/FLEET-LOG-UXREL.md new file mode 100644 index 0000000..6daeb4c --- /dev/null +++ b/FLEET-LOG-UXREL.md @@ -0,0 +1,9 @@ +## PASS +- Task 13: `python3 -m pytest tests/ -x -q` -> exit 0; 235 passed in 61.58s. +- Task 15: `python3 -m pytest tests/ -x -q` -> exit 0; 240 passed in 63.03s. +- Task 14: `python3 -m pytest tests/ -x -q` -> exit 0; 246 passed in 61.11s. +- Task 16: `python3 -m pytest tests/ -x -q` -> exit 0; 248 passed in 62.25s. + +## BLOCKED +- Task 16 ship-check: `python3 skel/ship-check.py --cwd /home/anombyte/Shade_Gen/Projects/.fleet-wt/w4-uxrel` -> exit 1; missing `.atlas-ai/state/pipeline.json`, `.taskmaster/tasks/tasks.json`, and plan file. Mission forbids creating `.taskmaster/`, so no fabricated release state. +- Task 16 live purchase E2E: BLOCKED; real Stripe live purchase, Resend email, deployed Worker telemetry, and Atlas Fleet unlock require live credentials/deployment authorization. diff --git a/FLEET-REPORT-BACKEND.md b/FLEET-REPORT-BACKEND.md new file mode 100644 index 0000000..2de85ac --- /dev/null +++ b/FLEET-REPORT-BACKEND.md @@ -0,0 +1,17 @@ +CLAIM: chunk BACKEND done-with-concerns +SHIPPED: 4 -> Cloudflare Worker scaffold, D1 license/idempotency schema, route skeleton, deploy dry-run path, secret scan, and runbook + 4f92297 +SHIPPED: 5 -> Stripe webhook verification, idempotency, license issuance/renewal/cancellation, Worker Ed25519 signing, and Stripe replay docs + be8374e +SHIPPED: 6 -> Resend license email delivery, retry handling, webhook waitUntil isolation, failure logging, and email ops docs + 20615d5 +SHIPPED: 7 -> /license/refresh endpoint with Stripe checks, signed refresh keys, CORS, cancellation handling, D1 daily rate limiting, and local migration + de890c7 +SHIPPED: 10 -> /telemetry endpoint with schema validation, D1 KPI storage, Analytics Engine counter writes, KPI query docs, and telemetry migration + 538c70a +EVIDENCE: Task 4 -> `npm run typecheck` exit 0; `npm test` exit 0; `npm run d1:migrate:local` exit 0; `npm run deploy:dry-run` exit 0; worker secret scan exit 0; `wrangler dev --port 8790` smoke returned 404. See `FLEET-LOG-BACKEND.md`. +EVIDENCE: Task 5 -> `npm run typecheck` exit 0; `npm test` exit 0 (4 files, 18 tests); `npm run deploy:dry-run` exit 0; worker secret scan exit 0; `npm test -- test/stripe.test.ts` exit 0. See `FLEET-LOG-BACKEND.md`. +EVIDENCE: Task 6 -> `npm run typecheck` exit 0; `npm test` exit 0 (5 files, 23 tests); `npm run deploy:dry-run` exit 0; worker secret scan exit 0; focused email and Stripe tests exit 0. See `FLEET-LOG-BACKEND.md`. +EVIDENCE: Task 7 -> `npm run typecheck` exit 0; `npm test` exit 0 (6 files, 31 tests); `npm run d1:migrate:local` exit 0 for `0002_rate_limits.sql`; `npm run deploy:dry-run` exit 0; worker secret scan exit 0; `npm test -- test/refresh.test.ts` exit 0. See `FLEET-LOG-BACKEND.md`. +EVIDENCE: Task 10 -> `npm run typecheck` exit 0; `npm test` exit 0 (7 files, 44 tests); `npm run d1:migrate:local` exit 0 for `0003_telemetry.sql`; `npm run deploy:dry-run` exit 0 with D1 and Analytics Engine bindings; worker secret scan exit 0; `npm test -- test/telemetry.test.ts` exit 0. See `FLEET-LOG-BACKEND.md`. +EVIDENCE: Repo regression -> `python3 -m pytest tests/ -q` exit 0 (`173 passed in 53.00s`), no new failures vs baseline. +BLOCKED: Task 5.6 -> live Stripe CLI replay skipped because no Stripe API key or webhook secret exists on this host. +BLOCKED: Task 6.4 -> live Resend test-mode delivery skipped because no `RESEND_API_KEY` exists on this host. +BLOCKED: Task 7.5 -> seeded live `/license/refresh` integration against Stripe test-mode skipped because no Stripe API key exists on this host. +BLOCKED: Task 10.6 -> production deploy and public `/telemetry` smoke test skipped per mission guardrail. +NEXT: Provide Stripe API key/webhook secret and Resend API key for live replay/send checks; authorize production deploy when ready; run the documented smoke procedures in `docs/ops/worker-runbook.md` and KPI queries in `docs/ops/telemetry-queries.md`. diff --git a/FLEET-REPORT-CLIENT.md b/FLEET-REPORT-CLIENT.md new file mode 100644 index 0000000..6c342a2 --- /dev/null +++ b/FLEET-REPORT-CLIENT.md @@ -0,0 +1,8 @@ +CLAIM: chunk CLIENT done-with-concerns +SHIPPED: 2 -> client license module with vendored verify-only Ed25519, v1 key parsing, status state machine, and 0600 persistence + 3d3e009 +SHIPPED: 3 -> license-activate CLI/MCP flow, license-aware premium gating, detect-capabilities license_status, and handoff license-state copy + 8dd1f8b +EVIDENCE: Task 2: python3 -m pytest tests/ -x -q — exit 0 — 190 passed in 53.68s; see FLEET-LOG-CLIENT.md +EVIDENCE: Task 3 focused: python3 -m pytest tests/core/test_license_activate.py tests/core/test_capabilities.py tests/mcp/test_mcp_tools.py tests/plugin/test_skill_files.py -q — exit 0 — 61 passed in 7.79s; see FLEET-LOG-CLIENT.md +EVIDENCE: Final full suite: python3 -m pytest tests/ -q — exit 1 — 204 passed, 1 failed in 59.63s; failing test is tests/core/test_fleet_detection.py::test_detect_capabilities_flips_both_paths_to_premium_with_launcher_mcp +BLOCKED: Task 3 full-suite compatibility -> tests/core/test_fleet_detection.py is outside CLIENT file domain and still asserts atlas-launcher registration alone sets tier=premium; Task 3 requires tier=premium only when launcher is registered and license_status is active or grace. +NEXT: Orchestrator should update/authorize the legacy fleet-detection test expectation to require active/grace license, or explicitly allow CLIENT to edit tests/core/test_fleet_detection.py; no product code blocker remains in owned files. diff --git a/FLEET-REPORT-KEY.md b/FLEET-REPORT-KEY.md new file mode 100644 index 0000000..277867c --- /dev/null +++ b/FLEET-REPORT-KEY.md @@ -0,0 +1,5 @@ +CLAIM: chunk KEY done +SHIPPED: task 1 -> License key v1 spec, stdlib Ed25519 signing, Python CLI, TS interop signer, vectors, and hygiene gate + c5e5eb1 +EVIDENCE: `python3 -m pytest tests/license/ -q` -> exit 0 (`10 passed in 0.51s`); `python3 -m pytest tests/ -q` -> exit 0 (`173 passed in 54.36s`); see `FLEET-LOG-KEY.md` +BLOCKED: none +NEXT: orchestrator can integrate chunk KEY with the other Atlas Pro monetization chunks diff --git a/FLEET-REPORT-NET.md b/FLEET-REPORT-NET.md new file mode 100644 index 0000000..74b0461 --- /dev/null +++ b/FLEET-REPORT-NET.md @@ -0,0 +1,21 @@ +CLAIM: chunk NET done-with-concerns + +SHIPPED: +8 -> Client silent license refresh with throttled attempts, verified key save, and guarded capabilities hook. Commit d74bcc8 +9 -> Repo-local Atlas Pro checkout contract documented; external Stripe/site work blocked honestly. Commit 22c87b8 +11 -> Privacy-first telemetry module with opt-out, exact payloads, daemon sender, and lifecycle hooks. Commit 5debf4f +12 -> Install and postinstall telemetry pings with opt-out flag/env plus README disclosure. Commit ac26645 + +EVIDENCE: +Task 8 -> `python3 -m pytest tests/ -x -q` exit 0 (`214 passed in 59.90s`); see FLEET-LOG-NET.md +Task 9 -> `grep -n "Atlas Pro checkout contract" README.md` exit 0; `python3 -m pytest tests/ -x -q` exit 0 (`214 passed in 60.03s`); see FLEET-LOG-NET.md +Task 11 -> `python3 -m pytest tests/test_telemetry.py -q` exit 0 (`12 passed in 0.22s`); `python3 -m pytest tests/ -x -q` exit 0 (`226 passed in 61.85s`); see FLEET-LOG-NET.md +Task 12 -> `bash tests/install/test_install_telemetry.sh` exit 0 (`install telemetry shell tests passed`); `python3 -m pytest tests/install tests/test_telemetry.py -q` exit 0 (`15 passed in 0.27s`); `python3 -m pytest tests/ -x -q` exit 0 (`229 passed in 65.64s`); see FLEET-LOG-NET.md +Final -> `bash tests/install/test_install_telemetry.sh` exit 0; `python3 -m pytest tests/ -q` exit 0 (`229 passed in 61.90s`) + +BLOCKED: +Task 9 external -> Stripe test-mode products/prices, atlas-ai.au/pro Buy buttons, /pro/success deployment, checkout E2E, webhook/email proof, and live-mode checklist require atlas-ai.au site repo plus Stripe/Resend credentials and a Worker deployment. +Task 12 external -> End-to-end install telemetry verification against Worker logs/D1 is blocked because no live Worker deployment or local wrangler/D1 telemetry target is available in this worktree. + +NEXT: +Orchestrator should wire the external atlas-ai.au /pro surface and Stripe dashboard items with real credentials, then run Worker-backed checkout and telemetry E2E verification. diff --git a/FLEET-REPORT-UXREL.md b/FLEET-REPORT-UXREL.md new file mode 100644 index 0000000..fd97fd2 --- /dev/null +++ b/FLEET-REPORT-UXREL.md @@ -0,0 +1,21 @@ +CLAIM: chunk UXREL done-with-concerns + +SHIPPED: +- Task 13 -> Terminal phase/gate renderer plus UX grammar and paywall copy conformance. Commit: 380f588 +- Task 15 -> Portable setup, peer install self-test, deterministic quickstart walkthrough, README truth-check. Commit: 5c97ed1 +- Task 14 -> UX vocabulary/paywall lint with repo-wide pytest enforcement. Commit: 8c99376 +- Task 16 -> KPI snapshot script, runbook, release evidence, blocker checklist. Commit: bc07256 + +EVIDENCE: +- Task 13: `python3 -m pytest tests/ -x -q` -> exit 0; 235 passed. See FLEET-LOG-UXREL.md. +- Task 15: `python3 -m pytest tests/ -x -q` -> exit 0; 240 passed. See FLEET-LOG-UXREL.md. +- Task 14: `python3 -m pytest tests/ -x -q` -> exit 0; 246 passed. See FLEET-LOG-UXREL.md. +- Task 16: `python3 -m pytest tests/ -x -q` -> exit 0; 248 passed. See FLEET-LOG-UXREL.md and docs/evidence/release-e2e.md. + +BLOCKED: +- Task 16 ship-check -> `python3 skel/ship-check.py --cwd /home/anombyte/Shade_Gen/Projects/.fleet-wt/w4-uxrel` exits 1 because release `.atlas-ai/state/pipeline.json`, `.taskmaster/tasks/tasks.json`, and plan files are absent. This worker is forbidden to create `.taskmaster/`, so no release state was fabricated. +- Task 16 live purchase E2E -> blocked by missing live Stripe/Resend/deployed Worker credentials and deployment authorization. + +NEXT: +- Authorized release operator must run live Stripe purchase -> email -> license activation -> premium detect_capabilities -> Atlas Fleet unlock in a deployed environment. +- Release operator must run ship-check in a real release project state that already contains `.taskmaster/` and pipeline evidence, then tag only after `SHIP_CHECK_OK`. diff --git a/README.md b/README.md index cc73e42..99237d8 100644 --- a/README.md +++ b/README.md @@ -1,611 +1,202 @@ -# PRD-Taskmaster: AI-Generated Product Requirements Documents +# Turn any goal into shipped code. -> **Comprehensive PRD generation optimized for AI-assisted development workflows** +**prd-taskmaster** by [Atlas AI](https://atlas-ai.au) is an open-source engine for Claude Code +that takes a one-line goal, interviews you like a senior PM, writes a **graded, +placeholder-proof PRD**, compiles it into a **dependency-ordered task graph**, and executes every +task with **verification evidence** — so "done" means proven, not claimed. -[![Claude Code Skill](https://img.shields.io/badge/Claude_Code-Skill-8A2BE2)](https://github.com/anombyte93/prd-taskmaster) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/anombyte93/prd-taskmaster/blob/main/LICENSE) -[![Status: Beta](https://img.shields.io/badge/Status-Beta-orange)](https://github.com/anombyte93/prd-taskmaster) -[![GitHub stars](https://img.shields.io/github/stars/anombyte93/prd-taskmaster)](https://github.com/anombyte93/prd-taskmaster/stargazers) -[![GitHub last commit](https://img.shields.io/github/last-commit/anombyte93/prd-taskmaster)](https://github.com/anombyte93/prd-taskmaster/commits) -[![GitHub issues](https://img.shields.io/github/issues/anombyte93/prd-taskmaster)](https://github.com/anombyte93/prd-taskmaster/issues) - -## What is This? - -A Claude Code skill that generates **detailed, engineer-focused Product Requirements Documents (PRDs)** designed to work seamlessly with AI task breakdown tools like Taskmaster. - -Think of it as your AI product manager that asks the right questions, writes comprehensive specs, and sets you up for successful implementation. - -## Why You Might Want This - -### The Problem - -You have an idea for a feature or product, but: -- Writing comprehensive PRDs takes hours -- You're not sure what details to include -- You want to use AI task breakdown tools (like Taskmaster) but they need detailed requirements -- Vague specs lead to vague tasks, which lead to poor implementations - -### The Solution +Free and MIT, forever. Works with Claude, Codex, and Gemini. -This skill: -1. **Asks 12+ detailed questions** to extract everything from your brain -2. **Generates a comprehensive PRD** with all the sections engineers need -3. **Sets up taskmaster integration** with proper directory structure -4. **Validates quality** with automated checks (13 different validations) -5. **Suggests task breakdowns** with complexity estimates and dependencies - -**Result:** You go from "I have an idea" to "I have a complete, validated PRD ready for AI task generation" in minutes. - -## Installation - -This skill works with **Claude Code CLI** and **Codex** (VS Code extension). Choose your tool below: - -### Option A: Claude Code CLI (Recommended) - -**Prerequisites:** -- Claude Code CLI installed ([installation guide](https://docs.claude.com/en/docs/claude-code/installation)) -- Git - -**Install the skill:** - -```bash -# Clone to your Claude Code skills directory -cd ~/.claude/skills -git clone https://github.com/anombyte93/prd-taskmaster.git ``` - -**Verify installation:** - -```bash -# Start Claude Code in any project -claude - -# In the chat, type: -# "I want a PRD for adding dark mode" +Grade: GOOD ▰▰▰▰▰▰▰▰▱▱ 49/57 (86%) · 0 placeholders · 14 tasks parsed ``` -Claude Code should recognize the skill and activate it automatically! - -**Troubleshooting:** -- If skill doesn't activate, check it's in `~/.claude/skills/prd-taskmaster/` -- Verify `SKILL.md` exists in that directory -- Try restarting Claude Code -- Check skill is enabled: Look for the skill description when typing "/help" or similar commands +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/anombyte93/prd-taskmaster)](https://github.com/anombyte93/prd-taskmaster/stargazers) +[![works with](https://img.shields.io/badge/works_with-Claude%20%C2%B7%20Codex%20%C2%B7%20Gemini-8A2BE2)](https://atlas-ai.au) +[![free engine](https://img.shields.io/badge/engine-MIT%20free%20forever-brightgreen)](#whats-open-whats-not) --- -### Option B: Codex (Untested) - -**Prerequisites:** -- Codex ([see](https://github.com/openai/codex/blob/main/README.md)) -- Git - -**Install the skill:** +## How it works -```bash -# Clone to where you want to run codex -cd ~/ -git clone https://github.com/anombyte93/prd-taskmaster.git -cd prd-taskmaster -``` - -**Configure Codex to find the skill:** - -1. Run Codex in the `prd-taskmaster` directory -2. Initialize Codex: `/init` -3. Codex will read `SKILL.md` and understand how to generate PRDs - -**Verify installation:** - -Ask Codex: ``` -What would you do if I told you to generate a PRD? +goal → discovery interview → graded PRD → dependency-ordered task graph → verified execution ``` -Confirm it outputs something similar to the workflow described in this README. - -**Using the generated codex.md:** - -When the skill generates a PRD for your project, it will ask if you're using Codex: -- If yes: Creates both `CLAUDE.md` and `codex.md` in your project root -- If no: Creates only `CLAUDE.md` - -The `codex.md` file guides Codex to follow TDD workflow, use agents, and maintain quality gates throughout development. - -**Troubleshooting:** -- Check you cloned to the correct directory -- Check you ran Codex in the `prd-taskmaster` directory -- Run `/init` to ensure Codex reads `SKILL.md` -- Verify `SKILL.md` exists in the directory +1. **Preflight** — detects your environment (TaskMaster, model CLIs, research) and configures it. Zero setup questions. +2. **Discovery** — an adaptive, one-question-at-a-time interview captures your real constraints. +3. **Generate** — writes a PRD, scores it against deterministic quality checks (letter grade), then parses it into a task graph with complexity scores and full subtask coverage. +4. **Handoff** — detects what you have installed and recommends one execution mode. +5. **Execute** — an evidence-gated loop implements each task and proves it with evidence, ending in a deterministic `SHIP_CHECK_OK` token. --- -## Quick Start Guide +## Quickstart -### Basic Usage +90 seconds to your first run. -Once installed, just tell Claude/Codex you want a PRD: +### Path 1 — one-liner (recommended) +```bash +curl -fsSL https://atlas-ai.au/install | bash +# installs the skill + prd_taskmaster package, sets up the task-master-ai peer ``` -"I want a PRD for [your feature/product]" -``` - -**Activation phrases:** -- "I want a PRD for adding two-factor authentication" -- "Create product requirements for a user dashboard" -- "Write a PRD for integrating with Stripe payments" -- "Generate requirements for building a dark mode feature" - -### What Happens Next - -The skill will: - -1. **Ask you 12+ detailed questions** - - What problem are you solving? - - Who's the target user? - - What are success metrics? - - What's the tech stack? - - Any constraints or dependencies? - -2. **Analyze your codebase** (if applicable) - - Scans for related code - - Identifies integration points - - References existing patterns - -3. **Generate a comprehensive PRD** - - All essential sections included - - Task breakdown hints - - Complexity estimates - -4. **Set up taskmaster integration** - - Creates `.taskmaster/` directory - - Places PRD in correct location - - Updates `.gitignore` - -5. **Validate everything** - - 13 automated quality checks - - Warns about vague language - - Scores PRD quality - -6. **Show you next steps** - - Summary of what was created - - Suggestions for taskmaster usage - - Open questions to address - -### First-Time Tips - -**Be detailed in your answers!** The more context you provide, the better the PRD. - -**Example good answer:** -> "We need 2FA because we're seeing 150 security incidents per month from compromised accounts. Target users are enterprise customers who require SOC2 compliance. Success = reduce incidents to <10/month and meet SOC2 requirements." -**Example too-vague answer:** -> "We need 2FA for security." +Expected finish: -**Don't worry about perfect answers** - the skill will ask follow-ups if needed! - -## What You Get - -### 📄 Comprehensive PRD +```text +✓ Peer dependency: task-master-ai +✓ Self-test: task-master-ai --version -> +Done in . +Installed. Try: /atlas or say "I want to build..." +``` -A complete product requirements document with: +### Path 2 — Claude Code plugin -- **Executive Summary** - Quick overview -- **Problem Statement** - User pain points & business impact -- **Goals & Metrics** - SMART success criteria -- **User Stories** - With acceptance criteria -- **Functional Requirements** - Detailed, testable specs -- **Technical Considerations** - Architecture, data models, APIs -- **Task Breakdown Hints** - For AI task generation -- **Dependencies** - What depends on what -- **Out of Scope** - What you're NOT building +```bash +# add the marketplace, then install the plugin +/plugin marketplace add anombyte93/prd-taskmaster +/plugin install prd-taskmaster +npm install -g task-master-ai +``` -### 🗂️ Taskmaster Integration +### First run -Automatically sets up: +Open any project in Claude Code and type: ``` -.taskmaster/ -├── docs/ -│ ├── prd.md # Your generated PRD -│ └── architecture.md # Placeholder for architecture docs -├── tasks/ -│ └── .gitkeep -├── notes/ -│ └── .gitkeep -└── .gitignore # Updated to exclude taskmaster artifacts +/atlas (or /prd-taskmaster, or just say: "I want to build...") ``` -### 🤖 CLAUDE.md / codex.md - TDD Workflow Guide - -Generates a comprehensive workflow file in your project root that guides Claude Code/Codex to: - -- **Follow TDD by default** - Write tests first, then implementation -- **Use blind-validator agent** - Validate against PRD without seeing code -- **Execute parallel tasks** - Run independent tasks simultaneously -- **Leverage agents** - For validation, exploration, and context optimization -- **Enforce quality gates** - Automated validation before marking tasks complete -- **Follow taskmaster best practices** - Optimal workflow for AI-assisted development +Requires Python 3.11+ and Linux / macOS / WSL. The free engine needs **no paid API key** — it +uses the model CLIs you already have, with a local free-research option. -**Key sections:** -- TDD workflow (RED → GREEN → REFACTOR cycle) -- Agent usage guidelines (when/how to use each agent type) -- Parallel task execution strategies -- Validation & quality gates -- Tool preferences & context optimization -- Project-specific configuration (tech stack, test commands, etc.) - -**File naming:** -- **Claude Code:** Creates `CLAUDE.md` (read automatically by Claude Code) -- **Codex:** Creates `codex.md` (read by Codex when initialized with `/init`) -- The skill will ask which tool you're using and create the appropriate file(s) -- Both files have identical content -- Includes instructions for keeping them in sync if you use both tools - -This ensures consistent, high-quality development across your entire project! +--- -### ✅ Quality Validation +## What "verified" means -13 automated checks ensure: -- All required sections are present -- Requirements are testable (not vague) -- Success metrics are SMART -- Technical considerations address architecture -- Task breakdown hints are included -- Dependencies are mapped +Most AI coding tools tell you a task is done. This one makes "done" provable: -### 📊 Example Output +- **Graded PRDs.** Every spec is scored against deterministic checks (EXCELLENT / GOOD / ACCEPTABLE / NEEDS WORK). Bare placeholders (`TBD`, `{{...}}`, `TODO`) are a hard fail. +- **A real task graph.** Requirements become TaskMaster tasks with dependencies, complexity scores, and full subtask coverage — not a flat checklist. +- **Evidence-gated execution.** Each task is implemented and must produce execution evidence before it counts as done. +- **A completion token you can trust.** `SHIP_CHECK_OK` is emitted only when every gate passes — and a single non-zero `Exit status` in any evidence file blocks it. It is structurally hard to fake. ``` -📄 PRD Created: .taskmaster/docs/prd.md -🤖 CLAUDE.md Generated: Project root (TDD workflow guide) - + codex.md (if you're using Codex) - -📊 Overview: - - Feature: Two-Factor Authentication - - Complexity: Medium - - Estimated Effort: 26 tasks, ~119 hours - - Key Goal: Reduce security incidents from 150/month to <10/month - -🎯 Key Requirements: - 1. REQ-001: TOTP/SMS 2FA support - 2. REQ-002: Backup codes for recovery - 3. REQ-003: Login flow integration - -🔧 Technical Highlights: - - Architecture: Auth service + Redis for sessions - - Integration: Twilio for SMS delivery - - Database: 2 new tables (user_2fa, backup_codes) - -⚠️ Quality Validation: 58/60 (EXCELLENT ✅) - ✅ All required elements present - ⚠️ 1 minor warning (REQ-007 has vague language) - -📋 Suggested Task Breakdown: - - Phase 1: 3 tasks (foundation) - - Phase 2: 8 tasks (core features) - - Phase 3: 5 tasks (testing) - -🚀 Next Steps: - 1. Review PRD: .taskmaster/docs/prd.md - 2. Install taskmaster: npm install -g task-master-ai - 3. Initialize: taskmaster init - 4. Generate tasks: taskmaster generate +┌─ atlas ── PHASE 3/4: GENERATE ─────────────────────────────┐ +│ Grade: GOOD ▰▰▰▰▰▰▰▰▱▱ 49/57 (86%) │ +│ ✓ 11 checks passed structure · testability · metrics │ +│ ⚠ 2 warnings (quoted + located, not just counted) │ +│ ✓ 0 placeholders (TBD/TODO/{{...}} scan clean) │ +│ Tasks: 14 parsed · 52 subtasks · dependencies mapped │ +└────────────────────────────────────────────────────────────┘ ``` -## Who Is This For? - -### Perfect For You If: - -- ✅ You use AI-assisted development workflows (Claude, Cursor, etc.) -- ✅ You want to use Taskmaster or similar task breakdown tools -- ✅ You're building features/products and need comprehensive specs -- ✅ You prefer detailed planning before coding -- ✅ You're tired of writing PRDs manually - -### Maybe Not For You If: - -- ❌ You prefer writing PRDs entirely yourself -- ❌ You don't use AI development tools -- ❌ You prefer minimal documentation -- ❌ You work in a strict corporate environment with specific PRD templates - -## Features - -### 🤖 Intelligent Discovery - -Asks smart questions: -- What problem are you solving? -- Who's the user? -- What's the tech stack? -- What are success metrics? -- Timeline expectations? - -### 🔍 Codebase-Aware - -If you're working in an existing codebase: -- Scans related code -- References specific files -- Ensures consistency with existing patterns -- Identifies integration points - -### 📐 Multiple Templates +--- -Choose based on project size: -- **Comprehensive** (default) - Full 12-section PRD -- **Minimal** - Quick template for simple features +## Free vs Atlas Pro + +| | Free (MIT) | Atlas Pro — $29/mo | +|---|:---:|:---:| +| Discovery interview (adaptive, one question at a time) | ✓ | ✓ | +| Graded PRD validation + placeholder scan | ✓ | ✓ | +| Dependency-ordered task graph (TaskMaster) | ✓ | ✓ | +| Verified solo execution — evidence required per task | ✓ | ✓ | +| Model-agnostic: Claude / Codex / Gemini | ✓ | ✓ | +| Parallel research fan-out | ✓ | ✓ | +| **Atlas Fleet** — parallel waves of isolated workers, checker-gated merges, one final PR | — | ✓ | +| Browser-verification MCP (UI proof, not just logs) | — | ✓ | +| Secrets-vault MCP (keys never in your repo or prompts) | — | ✓ | +| License & priority support | community | ✓ | + +The engine is the whole engine — the free tier is **not a demo**. Pro adds the fleet orchestrator +and bundled MCPs. [Get Atlas Pro →](https://atlas-ai.au/pro) + +Atlas Pro checkout contract: the externally hosted `/pro` page must offer Atlas Pro Monthly +($29/mo) and Atlas Pro Annual ($290/yr) subscriptions through Stripe Checkout, with cancel +returning to `/pro`. The externally hosted `/pro/success` page must tell buyers: "License key +sent to your email. Check spam folder. Activation: script.py license-activate LICENSE_KEY. +Docs: atlas-ai.au/docs." and link to `/docs/activation`. -### 🎯 Taskmaster-Optimized +--- -Everything taskmaster needs: -- Task breakdown hints -- Complexity estimates -- Dependency mapping -- Acceptance criteria -- Implementation notes +## Atlas Fleet (Atlas Pro) -### ✨ Smart Validation +When a build is big enough to want overnight parallelism, **Atlas Fleet** lifts the same engine +from one session to many. It splits your task graph into dependency **waves** of file-disjoint +chunks, spawns model-agnostic workers (Claude / Codex / Gemini) in isolated **git worktrees**, +collects results through a durable inbox rather than trusting an agent's word, and merges waves +sequentially into an integration branch behind a checker gate — never touching `main` until one +final green PR. -Catches common issues: -- Vague language ("fast", "secure" without specifics) -- Missing acceptance criteria -- Non-testable requirements -- Incomplete technical specs +It runs entirely on your machine; only the license key phones home. Cancel anytime — your specs +and tasks are plain files in your repo, nothing is held hostage. -## How It Works +[See Atlas Pro →](https://atlas-ai.au/pro) -### The 8-Step Workflow +--- -1. **Discovery** - Ask comprehensive questions -2. **Environment Check** - Look for existing taskmaster setup -3. **Codebase Analysis** - Understand existing code (if applicable) -4. **PRD Generation** - Write comprehensive requirements -5. **Directory Setup** - Create `.taskmaster/` structure -6. **Validation** - Run 13 quality checks -7. **Task Hints** - Suggest breakdowns and dependencies -8. **Presentation** - Show summary and next steps +## What's open, what's not -## Advanced Usage +**Open (MIT, free forever):** discovery, PRD validation, the task graph, and solo verified +execution — the entire engine in this repo. Read every line. -### Using with Taskmaster +**Closed (Atlas Pro):** the `atlas-launcher` fleet orchestrator and the two bundled MCPs +(browser verification, secrets vault). The engine repo will never require a license key. -The skill now **automatically detects and prefers MCP** over CLI for seamless integration! +--- -#### Automatic Detection (Recommended) +## Telemetry -The skill will automatically: -1. **Detect MCP Task-Master-AI** (if installed in Claude Code) -2. **Fallback to CLI** (if MCP not available but CLI is installed) -3. **Provide installation instructions** (if neither is available) +Atlas sends anonymous, best-effort client telemetry for product health. The payload schema is +exactly `{install_id, event, version, os}`. `install_id` is a random UUID stored locally at +`~/.atlas-ai/install_id`; `event` is one of `install`, `atlas_invoked`, `reach_execute`, or +`ship_check_ok`; `version` is the package version; `os` is `platform.system()`. -**With MCP (PREFERRED):** -- ✅ Seamless integration with direct function calls -- ✅ No shell dependency -- ✅ Automatic task initialization, parsing, and expansion -- ✅ Query tasks using MCP tools directly in Claude Code +Telemetry contains no PII, goal text, code, paths, prompts, file names, or project content. +Network failures are ignored and never block the CLI or installer. -The skill will automatically: -- Initialize taskmaster project structure -- Parse your PRD to generate tasks -- Expand all tasks into subtasks -- No manual CLI commands needed! +Opt out with `ATLAS_TELEMETRY=0`, including pipe installs: -**With CLI (Fallback):** ```bash -# After PRD is generated: -npm install -g task-master-ai -cd your-project -taskmaster init -taskmaster parse-prd --input .taskmaster/docs/prd.md -taskmaster expand-all --research -taskmaster next-task # Begin implementation +curl -fsSL https://raw.githubusercontent.com/anombyte93/prd-taskmaster/main/install.sh | ATLAS_TELEMETRY=0 bash ``` -**Without Taskmaster:** -- Skill generates manual task files in `.taskmaster/tasks/` -- Provides installation instructions for MCP or CLI -- You can still follow the PRD and task files manually - -### Customizing Templates - -Edit templates in `templates/` directory: -- `taskmaster-prd-comprehensive.md` - Full template -- `taskmaster-prd-minimal.md` - Quick template +For downloaded installs, use: -### Manual Validation - -Use the validation checklist: ```bash -cat reference/validation-checklist.md -``` - -## Files & Structure - +bash install.sh --no-telemetry ``` -prd-taskmaster/ -├── SKILL.md # Main skill (480 lines) -├── PUBLIC_README.md # This file -├── README.md # Developer documentation -├── templates/ -│ ├── taskmaster-prd-comprehensive.md # Full PRD template -│ └── taskmaster-prd-minimal.md # Quick template -├── scripts/ -│ └── setup-taskmaster.sh # Directory setup script -└── reference/ - ├── taskmaster-integration-guide.md # Taskmaster best practices - └── validation-checklist.md # Quality criteria -``` - -## Development Approach - -### Honest Disclosure - -This skill was built using an iterative "vibe-coding" approach: -- Designed multiple variations -- Evaluated with evidence-based scoring -- Validated with test scenarios -- Refined based on impact analysis - -**Status:** Beta - Works well for the creator's workflow, but hasn't been extensively tested by others. - -### Known Limitations - -- Primarily tested for web/API projects -- English only -- Assumes taskmaster workflow -- May ask redundant questions for very simple features -- Validation is helpful but not perfect - -### Your Feedback Matters - -This is a **living skill**. If you: -- Find bugs or issues -- Have suggestions for improvement -- Want additional templates or patterns -- Need different validation rules - -**Please open an issue!** Your real-world usage will make this better. - -## Why Share This? - -### The Philosophy - -> "Planning is 95% of the work. A comprehensive, validated PRD is the foundation of successful implementation." - -If you're using AI to help build software, the **quality of your requirements** directly impacts the **quality of your results**. - -This skill codifies lessons learned from: -- Writing PRDs manually (painful) -- Using AI task breakdown tools (needs good input) -- Iterating on what makes a "good enough" PRD -- Automating the boring parts - -### The Hope -Maybe this helps you: -- Save time on PRD writing -- Improve your planning process -- Get better results from AI task tools -- Ship features more successfully +You can also set `"telemetry": false` in `~/.atlas-ai/config.json` or a project +`.taskmaster/config.json`. -If it does, great! If not, no worries - maybe you'll fork it and make it better for your needs. +--- ## FAQ -### Q: Do I need Taskmaster to use this? - -**A:** No. The PRD is useful on its own. Taskmaster integration is optional. - -### Q: Will this work for my project? - -**A:** Probably? It's designed for web/API projects but adaptable. Try it and see! - -### Q: Can I modify the templates? - -**A:** Absolutely! That's encouraged. Edit templates to match your needs. - -### Q: What if the PRD quality validation fails? - -**A:** The skill will warn you about issues. You can still use the PRD - validation is guidance, not enforcement. +**Will the free engine stay free?** Yes — MIT, and this repo will never require a key. -### Q: How long does it take? +**What happens if I cancel Pro?** Fleet locks again; everything free keeps working. Your specs +and tasks are plain files in your repo. -**A:** 5-15 minutes depending on how detailed your answers are. +**Do I need a paid API key?** No. The engine uses the model CLIs you already have (Claude Code, +Codex, Gemini) and a local free-research option. -### Q: Is this better than writing PRDs manually? - -**A:** Different trade-off. Faster and more comprehensive, but less customized. Your call! - -## Contributing - -### Ways to Help - -1. **Use it and report issues** - Real-world usage is invaluable -2. **Share improvements** - Better templates, validation rules, etc. -3. **Add patterns** - More examples for common project types -4. **Documentation** - Clarify confusing parts - -### How to Contribute - -```bash -# Fork the repo -# Make your changes -# Test with real projects -# Submit PR with: -# - What you changed -# - Why you changed it -# - How you tested it -``` +**Do I need TaskMaster?** Yes — `task-master-ai >= 0.43.0` is the task engine (a peer +dependency; the installer sets it up). A `--manual` path exists if you'd rather not. -## License - -MIT License - Use freely, modify as needed, share improvements if you want. - -## Acknowledgments - -**Built with:** -- Claude Code (obviously) -- Research from Product School, Atlassian, Aha.io, Leanware -- Taskmaster AI documentation -- Lessons learned from shipping features - -**Philosophy:** -- LEARN → PRACTICE → MASTER methodology -- Impact-weighted decision making -- Evidence-based evaluation -- Quality over speed - -## Support & Contact - -- **Issues:** [GitHub Issues](https://github.com/anombyte93/prd-taskmaster/issues) -- **Discussions:** [GitHub Discussions](https://github.com/anombyte93/prd-taskmaster/discussions) -- **Questions:** Open an issue with the "question" label - -## Version History - -- **v1.0** (2025-01-22) - Initial public release - - Comprehensive PRD generation - - Full taskmaster integration - - Automated validation (13 checks) - - Impact-weighted evaluation methodology +**Which platforms?** Linux, macOS, and WSL. (Native Windows is not supported — the atomic state +machine uses POSIX file locking.) --- -**Made with Claude Code** | **Status: Beta** | **Feedback Welcome** - -*Planning is 95% of the work. Start with a solid PRD.* - -## What's New in v2.0 (Top 5 Enhancements) - -### 1. Real DateTime Tracking -- Precise UTC timestamps for all tasks/subtasks -- Automatic duration calculation -- JSON state persistence -- Compare actual vs estimated time - -### 2. Instant Rollback Command -- Type "rollback to task X" any time during execution -- Reverts to any checkpoint tag -- Backs up current work before rollback -- Safety confirmations - -### 3. Accuracy Learning System -- Analyzes estimated vs actual times -- Calculates adjustment factor -- Recommends estimate improvements -- Learns from your pace - -### 4. Security Audit Checklist -- Auto-generated based on your code -- Scans for security patterns -- Standard security checks included -- Automated scan suggestions - -### 5. Auto-Resume After Crash -- Detects incomplete work from previous session -- Offers multiple resume points -- Continues exactly where crashed -- No work lost +## Contributing & License -All enhancements work seamlessly with MCP or CLI integration! +Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Product and UX specifications live in +[`docs/product/`](docs/product/). +The engine is **MIT licensed and will always be** — see [LICENSE](LICENSE). Upgrading from v3? +See [CHANGELOG.md](CHANGELOG.md). v3 remains available via `git checkout v3.0.0`. diff --git a/SKILL.md b/SKILL.md index c44974f..2097eba 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,11 +1,10 @@ --- name: prd-taskmaster description: >- - Smart PRD generator with TaskMaster integration. Detects existing PRDs and offers execute/update/replace options. - Generates comprehensive technical PRDs optimized for task breakdown, validates with 13 automated checks, - and optionally executes tasks autonomously with datetime tracking and rollback support. Use when user requests - "PRD", "product requirements", or mentions task-driven development. Defaults to PRD generation with - handoff to TaskMaster. Optionally supports autonomous execution with 4 modes. + Zero-config goal-to-tasks engine (the Atlas engine). Takes any goal (software, pentest, + business, learning), runs adaptive discovery via brainstorming, generates a validated spec, + parses into TaskMaster tasks, and hands off to execution. Use when user says "PRD", "product + requirements", "I want to build", invokes /atlas, or wants task-driven development. allowed-tools: - Read - Write @@ -13,290 +12,232 @@ allowed-tools: - Grep - Glob - Bash + - Skill - AskUserQuestion + - WebSearch --- -# PRD Generator for TaskMaster v3.0 +# prd-taskmaster — the Atlas engine -Smart PRD generation with deterministic operations handled by `script.py`. -AI handles judgment (questions, content, decisions); script handles mechanics. +Zero-config goal-to-tasks engine. AI handles discovery and content; `script.py` handles mechanics. -**Script location**: `~/.claude/skills/prd-taskmaster/script.py` -**All script commands output JSON.** +**Command:** `/atlas` is the canonical invocation (or `/prd-taskmaster`, or just say "I want to +build …"). The full plugin install also exposes phase skills; this standalone skill runs the whole +pipeline inline. -## When to Use +**Script:** `~/.claude/skills/prd-taskmaster/script.py` (all commands output JSON). It is a thin +shim over the bundled `prd_taskmaster/` package — the single source of truth shared with the plugin. -Activate when user says: PRD, product requirements, taskmaster, task-driven development. -Do NOT activate for: API docs, test specs, project timelines, PDF creation. +**Manual flag:** If the user says `--manual`, `manual=true`, or "do it manually", perform the +TaskMaster mechanics yourself: write `.taskmaster/docs/prd.md`, write `.taskmaster/tasks/tasks.json` +with tasks and subtasks, run `validate-tasks`, then run `enrich-tasks`. Do not block on TaskMaster +CLI/MCP parsing. -## Core Principles +## When to Use -- **Quality Over Speed**: Planning is 95% of the work -- **Taskmaster Required**: Blocks if not detected -- **Engineer-Focused**: Technical depth, code examples, architecture -- **Validation-Driven**: 13 automated checks via script -- **User Testing Checkpoints**: Every 5 tasks +Activate: PRD, product requirements, taskmaster, task-driven development, "I want to build X", any goal. +Skip: API docs, test specs, project timelines, PDF creation. ---- +## Phase 1: Zero-Config Preflight -## Workflow (12 Steps) +Run preflight and auto-detect everything. Ask zero setup questions. -### Step 1: Preflight & Resume Detection +**MCP (preferred — ONE batched call, no script spam):** if an `engine_preflight` +MCP tool is available in your session (plugin install, or the engine's MCP server +registered), call it once — it covers preflight + taskmaster detection + provider +configuration + capabilities and returns a `summary` list to present verbatim. +Skip the individual script calls below entirely. + +**CLI fallback (zero-dependency installs):** one batched subcommand, same result: ```bash -python3 ~/.claude/skills/prd-taskmaster/script.py preflight +python3 ~/.claude/skills/prd-taskmaster/script.py engine-preflight ``` -Returns JSON: `has_taskmaster`, `prd_path`, `task_count`, `tasks_completed`, `tasks_pending`, `taskmaster_method`, `has_claude_md`, `has_crash_state`, `crash_state`. - -**If `has_crash_state` is true**: Present resume options to user: -1. Continue from last subtask -2. Restart current task -3. Resume from last checkpoint -4. Start fresh - -**Then proceed to Step 2.** - ---- - -### Step 2: Detect Existing PRD - -Use preflight JSON: if `prd_path` is not null and `task_count > 0`, an existing PRD is found. +**From preflight JSON, determine the state:** -**If existing PRD found**, use AskUserQuestion: -- **Execute tasks** from existing PRD (skip to Step 11) -- **Update/refine** existing PRD (edit and re-parse) -- **Create new PRD** (replace - backup first via `script.py backup-prd --input `) -- **Review** existing PRD (display summary, then exit) +| Condition | Action | +|-----------|--------| +| `prd_path` exists + `task_count > 0` | Ask: execute tasks / update PRD / new PRD / review | +| `taskmaster_method == "none"` + no manual flag | Show install: `npm install -g task-master-ai`, wait, re-detect | +| manual flag present | Proceed using Manual Mechanics Mode, regardless of TaskMaster CLI/MCP state | +| `has_taskmaster == false` + CLI present | Run `init-taskmaster` (below), then continue | +| `has_taskmaster` but no PRD | Proceed to Discovery | +| `has_crash_state` | Offer: resume from crash point or start fresh | -**If no PRD found**: Proceed to Step 3. +**Initialise the project if needed, then auto-configure providers** (silent). Always use +`init-taskmaster` — it protects an existing `.mcp.json`, which raw `task-master init` +overwrites with a placeholder template: ---- - -### Step 3: Detect Taskmaster - -Use preflight JSON field `taskmaster_method`: `mcp`, `cli`, or `none`. - -**If `none`**: Block and show installation instructions: -- Option 1 (recommended): Install MCP Task-Master-AI -- Option 2: `npm install -g task-master-ai` -- Wait for user to install and confirm, then re-run: `script.py detect-taskmaster` - -**No proceeding without taskmaster detected.** - ---- - -### Step 4: Discovery Questions - -Ask detailed questions to build comprehensive PRD. Use AskUserQuestion for structured input. +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py init-taskmaster # only when .taskmaster/ absent +python3 ~/.claude/skills/prd-taskmaster/script.py configure-providers +python3 ~/.claude/skills/prd-taskmaster/script.py detect-providers +``` -**Essential (5):** -1. What problem does this solve? (user pain point, business impact) -2. Who is the target user/audience? -3. What is the proposed solution or feature? -4. What are the key success metrics? -5. What constraints exist? (technical, timeline, resources) +If `configure-providers` returns `recommended_action: "init_taskmaster"`, run +`init-taskmaster` and retry once. -**Technical (4):** -6. Existing codebase or greenfield? -7. Tech stack? -8. Integration requirements? -9. Performance/scale requirements? +Report compact status: +``` + ✓ Detected: TaskMaster (MCP|CLI) + ✓ Detected: Provider (Claude Code|Codex CLI|Anthropic API) + ✓ Detected: Research (Perplexity API Free|Perplexity MCP|Perplexity API|fallback) +``` -**TaskMaster-specific (3):** -10. Used taskmaster before? -11. Estimated complexity? (simple/typical/complex) -12. Timeline expectations? +**Gate: TaskMaster detected OR manual flag present. Providers configured. Proceed to Discovery.** -**Open-ended (1):** -13. Anything else? (edge cases, constraints, context) +### Provider Defaults -**Smart defaults**: If user provides minimal answers, use best guesses and document assumptions. +Always prefer subscription/native providers before paid API keys: ---- +1. Main: `claude-code` / `sonnet` when `claude` exists; otherwise `codex-cli` / `gpt-5.2-codex` when `codex` exists. +2. Fallback: `codex-cli` / `gpt-5.2-codex` when available; otherwise `claude-code` / `sonnet`. +3. Research: local Perplexity API Free through TaskMaster `openai-compatible` provider: + - model: `sonar` + - baseURL: `http://127.0.0.1:8765` + - `.env` key: `OPENAI_COMPATIBLE_API_KEY="local-perplexity-api-free"` (dummy local key only) -### Step 5: Initialize Taskmaster +Do not require `ANTHROPIC_API_KEY` or paid `PERPLEXITY_API_KEY` when native Claude/Codex and Perplexity API Free are available. -Only if `.taskmaster/` doesn't exist (check preflight `has_taskmaster`). +## Phase 2: Discovery -```bash -python3 ~/.claude/skills/prd-taskmaster/script.py init-taskmaster --method +Read the phase file and follow it: ``` - -For MCP: use the returned params to call `mcp__task-master-ai__initialize_project`. -For CLI: script runs `taskmaster init` directly. - ---- - -### Step 6: Generate PRD - -Load template: -```bash -python3 ~/.claude/skills/prd-taskmaster/script.py load-template --type +Read ~/.claude/skills/prd-taskmaster/phases/DISCOVER.md ``` -Returns JSON with `content` field containing the template. - -**AI judgment**: Fill template with user's answers from Step 4: -- Replace placeholders with actual content -- Expand examples with project-specific details -- Add technical depth based on discovery answers +Progressive, adaptive, domain-agnostic discovery via superpowers:brainstorming. -Write completed PRD to `.taskmaster/docs/prd.md`. +**Gate: Discovery complete and user approved design. Proceed to Generate.** ---- +## Phase 3: Generate & Validate -### Step 7: Validate PRD Quality - -```bash -python3 ~/.claude/skills/prd-taskmaster/script.py validate-prd --input .taskmaster/docs/prd.md +Read the phase file and follow it: +``` +Read ~/.claude/skills/prd-taskmaster/phases/GENERATE.md ``` -Returns JSON: `score`, `max_score`, `grade`, `checks` (13 items), `warnings`. +Generate spec, validate quality, parse tasks, enrich with metadata. -**Grading**: EXCELLENT (91%+), GOOD (83-90%), ACCEPTABLE (75-82%), NEEDS_WORK (<75%). +**Gate: PRD validated GOOD+ and tasks created through TaskMaster parse/expand OR Manual Mechanics Mode. Proceed to Handoff.** -**AI judgment**: If warnings exist, offer user three options: -1. Proceed with current PRD -2. Auto-fix warnings -3. Review and fix manually +### Manual Mechanics Mode -If grade is NEEDS_WORK, strongly recommend fixing before proceeding. +Use this when the user passes `--manual` or TaskMaster parsing/expansion is a poor fit. ---- +1. Generate `.taskmaster/docs/prd.md` normally. +2. Manually write `.taskmaster/tasks/tasks.json` in TaskMaster-compatible shape: + - top-level object with `tasks: []` + - each task has `id`, `title`, `description`, `details`, `testStrategy`, `status`, `dependencies`, `priority`, and `subtasks` + - every task has at least 2 subtasks with `id`, `title`, `description`, `status`, and `dependencies` +3. Run: + ```bash + python3 ~/.claude/skills/prd-taskmaster/script.py validate-tasks + python3 ~/.claude/skills/prd-taskmaster/script.py enrich-tasks + python3 ~/.claude/skills/prd-taskmaster/script.py validate-tasks --require-phase-config + ``` +4. Treat successful validation + enrichment + phaseConfig validation as equivalent to TaskMaster parse + expand. -### Step 8: Parse & Expand Tasks +## Phase 4: Handoff -Calculate task count: -```bash -python3 ~/.claude/skills/prd-taskmaster/script.py calc-tasks --requirements +Read the phase file and follow it: ``` - -Returns `recommended` task count. - -**For MCP**: -``` -mcp__task-master-ai__parse_prd: input=".taskmaster/docs/prd.md", numTasks=, research=true -mcp__task-master-ai__expand_all: research=true +Read ~/.claude/skills/prd-taskmaster/phases/HANDOFF.md ``` -**For CLI**: +Detect capabilities, recommend ONE execution mode, hand off. Modes (user-facing names): +**Verified Loop** (recommended when superpowers + a loop runner are present), **Auto-Execute** +(TaskMaster's native loop), **Plan & Drive** (plan only). **Atlas Fleet** — parallel multi-session +execution — appears as an **Atlas Pro** option when a licensed `atlas-launcher` is detected; +otherwise it shows as a locked teaser pointing to https://atlas-ai.au/pro. The free engine is +always fully usable on its own. + +**Gate: User chose mode and handoff complete.** + +## Script Commands Reference + +| Command | Purpose | +|---------|---------| +| `engine-preflight` | ONE batched call: preflight + taskmaster + providers + capabilities + summary | +| `preflight` | Detect environment state | +| `detect-taskmaster` | Find MCP or CLI taskmaster | +| `init-taskmaster` | task-master init with `.mcp.json` protection | +| `configure-providers` | Configure native Claude/Codex + local Perplexity API Free defaults | +| `detect-providers` | Auto-detect AI providers | +| `detect-capabilities` | Scan for available skills/tools; returns tier + recommended mode with reason | +| `load-template --type comprehensive\|minimal` | Load PRD template | +| `calc-tasks --requirements [--scale solo\|team\|enterprise]` | Recommended task count (scale-banded) | +| `validate-prd --input ` | Quality checks + placeholder detection | +| `backup-prd --input ` | Timestamped backup | +| `validate-tasks [--input ]` | Validate manually-authored tasks.json | +| `enrich-tasks` | Add phaseConfig metadata to tasks | +| `parallel-plan [--missing-only]` | Emit per-task research packets for parallel subagents | +| `parallel-apply --input ` | Merge parallel research results atomically | +| `parallel-extract --output ` / `parallel-inject --input ` | Tagged ⇄ flat tasks bridge | + +## Parallel Research & Complexity + +TaskMaster's own `expand --all --research` and `analyze-complexity --research` are SEQUENTIAL +(every call rewrites tasks.json — parallel CLI invocations clobber each other). With Perplexity API +Free, TaskMaster's strict object-generation call can also receive prose instead of JSON even though +the proxy itself works. Therefore the `parallel-*` commands are the default research/complexity path +when there are ≥4 tasks, when Perplexity API Free is configured, or when any TaskMaster AI provider +errors. + +Pattern — the parallelism lives in the AGENT, not the script: + ```bash -taskmaster parse-prd --input .taskmaster/docs/prd.md --research --num-tasks -taskmaster expand-all --research +python3 ~/.claude/skills/prd-taskmaster/script.py parallel-plan --missing-only # research packets JSON +# AGENT: split packets into N groups (by lane/domain), spawn N parallel research +# subagents; each verifies files in-repo + researches APIs and returns +# [{id, complexityScore, recommendedSubtasks, reasoning, researchNotes, subtasks[]}] +# AGENT: concatenate results -> results.json +python3 ~/.claude/skills/prd-taskmaster/script.py parallel-apply --input results.json # ONE atomic write +# + writes .taskmaster/reports/task-complexity-report[_].json (TaskMaster format) +# + returns needs_more_subtasks (score >= threshold w/ too-few subtasks) for a second pass ``` ---- - -### Step 9: Insert User Test Tasks +If the `perplexity-api-free` MCP wrapper times out or says the proxy is unreachable, check direct +proxy health: ```bash -python3 ~/.claude/skills/prd-taskmaster/script.py gen-test-tasks --total +curl -sS -X POST http://127.0.0.1:8765/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"sonar","messages":[{"role":"user","content":"Return exactly: ok"}],"max_tokens":16}' ``` -Returns array of USER-TEST task definitions with `title`, `description`, `dependencies`, `template`. +If direct `curl` works, continue: use MCP, direct proxy calls, or agent research to produce the +`results.json` schema, then normalize prose into valid JSON before `parallel-apply`. Do not block on +native `task-master analyze-complexity --research`. -**For each task in the array**: -- MCP: `mcp__task-master-ai__add_task` with title, description, details=template, dependencies, priority=high -- CLI: `taskmaster add-task --title="..." --description="..." --dependencies="..." --priority=high` - ---- - -### Step 10: Setup Tracking Scripts +Tag bridge for explicit flat-file workflows (the script also reads tagged TaskMaster files directly): ```bash -python3 ~/.claude/skills/prd-taskmaster/script.py gen-scripts --output-dir .taskmaster/scripts +python3 ~/.claude/skills/prd-taskmaster/script.py parallel-extract --output /tmp/flat.json +python3 ~/.claude/skills/prd-taskmaster/script.py validate-tasks --input /tmp/flat.json +python3 ~/.claude/skills/prd-taskmaster/script.py enrich-tasks --input /tmp/flat.json +python3 ~/.claude/skills/prd-taskmaster/script.py validate-tasks --input /tmp/flat.json --require-phase-config +python3 ~/.claude/skills/prd-taskmaster/script.py parallel-inject --input /tmp/flat.json ``` -Creates 5 scripts: track-time.py, rollback.sh, learn-accuracy.py, security-audit.py, execution-state.py. - ---- - -### Step 10.5: Generate CLAUDE.md - -**Pre-check**: Use Glob to check if `./CLAUDE.md` exists. If it exists, skip. - -If generating: -1. Load template: `script.py load-template` won't work here -- use Read tool on `~/.claude/skills/prd-taskmaster/templates/CLAUDE.md.template` -2. **AI judgment**: Replace placeholders with project-specific values from discovery: - - `{{PROJECT_NAME}}`, `{{TECH_STACK}}`, `{{ARCHITECTURE_OVERVIEW}}` - - `{{KEY_DEPENDENCIES}}`, `{{TESTING_FRAMEWORK}}`, `{{DEV_ENVIRONMENT}}`, `{{TEST_COMMAND}}` -3. Write to `./CLAUDE.md` -4. Ask if user uses Codex -- if yes and no `codex.md`, write identical copy - ---- - -### Step 11: Choose Next Action - -Use AskUserQuestion: - -**Question**: "PRD and tasks ready. How to proceed?" -- **Show TaskMaster Commands** (default): Display command reference, then exit skill -- **Autonomous Execution**: Ask follow-up for execution mode - -**If Autonomous Execution selected**, ask execution mode: -- **Sequential to Checkpoint** (recommended): Tasks one-by-one until next USER-TEST -- **Parallel to Checkpoint**: Independent tasks in parallel until USER-TEST -- **Full Autonomous**: All tasks parallel, skip user validation -- **Manual Control**: User decides each task +All commands default `--tag` to `.taskmaster/state.json` currentTag and run from the project root. -**AI judgment**: Recommend mode based on context: -- First-time/critical: Sequential -- Experienced/non-critical: Parallel -- Trusted/time-critical: Full Autonomous -- Complex/learning: Manual +## Context ---- - -### Step 12: Summary & Start - -**If Handoff**: Display PRD location, task counts, key requirements, validation score, task phases, user test checkpoints, and TaskMaster commands. Then exit skill. - -**If Autonomous**: Display same summary plus execution mode, then begin execution using the selected mode's rules. - ---- - -## Execution Mode Rules - -### All Modes Include - -- **DateTime tracking**: `python3 .taskmaster/scripts/track-time.py start|complete [subtask_id]` -- **Progress logging**: `python3 ~/.claude/skills/prd-taskmaster/script.py log-progress --task-id --title "..." --duration "..." --subtasks "..." --tests "..." --issues "..."` -- **Git policy**: Branch per task (`task-{id}-{slug}`), sub-branch per subtask, merge to main with checkpoint tag -- **Rollback**: If user says "rollback to task X", run `bash .taskmaster/scripts/rollback.sh X` -- **State tracking**: `python3 .taskmaster/scripts/execution-state.py start|complete|checkpoint ` - -### Sequential to Checkpoint - -Execute tasks one-by-one. For each task: -1. Start time tracking -2. Create feature branch -3. For each subtask: create sub-branch, implement, test, commit, merge to task branch -4. Complete time tracking -5. Log progress -6. Merge to main, create checkpoint tag -7. Stop at next USER-TEST for user validation - -### Parallel to Checkpoint - -Same as sequential but launch up to 3 concurrent independent tasks. -Handle merge conflicts automatically. Stop at USER-TEST. - -### Full Autonomous - -Maximum parallelization (up to 5 concurrent). Auto-complete USER-TEST tasks. -Only stop when ALL tasks complete. - -### Manual Control - -Wait for user commands: "next task", "task {id}", "status", "parallel {id1,id2}". - ---- +**Standalone:** Works on its own. Takes any goal, produces spec + tasks. +**Produces:** spec.md + tasks.json (in `.taskmaster/`). +**Then:** hand off to an execution mode (Verified Loop / Auto-Execute / Plan & Drive), or +**Atlas Fleet** for parallel multi-session execution with Atlas Pro. -## Tips +## Critical Rules -- More detail in discovery = better PRD -- Quantify goals: not "improve UX" but "increase NPS from 45 to 60" -- USER-TEST checkpoints catch issues early -- Git checkpoints allow easy rollback -- Use `script.py validate-prd` at any time to re-check PRD quality +1. Zero setup questions — detect everything, ask only discovery questions +2. Discovery via superpowers:brainstorming — one question at a time, adaptive +3. Domain-agnostic — works for any goal (app, pentest, business, anything) +4. Validate PRDs catch placeholders — mustache, TBD, TODO patterns fail validation +5. Manual flag means "do the TaskMaster mechanics manually", not "skip validation" +6. Handoff recommends ONE mode — present best fit, not equal choices +7. Phase files must be Read explicitly — they are not auto-loaded +8. Native/free provider defaults are enforced by `configure-providers`; do not drift back to paid Anthropic/Perplexity APIs unless native/free routes are unavailable +9. Perplexity API Free research must be normalized through `parallel-apply`; native TaskMaster research is only acceptable when it returns valid structured output and validation passes diff --git a/SKILL.md.pre-codify b/SKILL.md.pre-codify deleted file mode 100644 index f14e7bb..0000000 --- a/SKILL.md.pre-codify +++ /dev/null @@ -1,1342 +0,0 @@ ---- -name: prd-taskmaster -description: >- - Smart PRD generator with TaskMaster integration. Detects existing PRDs and offers execute/update/replace options. - Generates comprehensive technical PRDs optimized for task breakdown, validates with 13 automated checks, - and optionally executes tasks autonomously with datetime tracking and rollback support. Use when user requests - "PRD", "product requirements", or mentions task-driven development. Defaults to PRD generation with - handoff to TaskMaster. Optionally supports autonomous execution with 4 modes. -allowed-tools: - - Read - - Write - - Edit - - Grep - - Glob - - Bash - - AskUserQuestion ---- - -# PRD Generator for TaskMaster v2.0 - -Smart PRD generation system that detects existing PRDs and offers flexible workflows. Primarily generates comprehensive, engineer-focused product requirements documents optimized for TaskMaster task breakdown. Optionally executes tasks autonomously with quality control, datetime tracking, and validation checkpoints. - -## When to Use This Skill - -Activate when user: -- Requests a PRD or product requirements document -- Says "I want a PRD", "create requirements", "write requirements" -- Mentions taskmaster or task-driven development -- Asks to document product/feature requirements for engineering - -Do NOT activate for: -- Code documentation (API docs, technical reference) -- Test specifications or QA documentation -- Project management timelines without product context -- PDF document creation - -## Core Principles - -**Quality Over Speed**: Planning is 95% of the work. Take time to create comprehensive PRDs that enable successful implementation. - -**Taskmaster Required**: This skill REQUIRES taskmaster (MCP or CLI). Will block and prompt for installation if not detected. - -**Engineer-Focused**: Primary audience is engineering teams. Include technical depth, code examples, architecture considerations, and implementation details. - -**Validation-Driven**: Automated checks ensure PRD quality before delivery. 13 validation checks run automatically. - -**User Testing Checkpoints**: Every 5 tasks, insert user validation checkpoint to catch issues early. - -**Complete Automation**: Provides 4 autonomous execution modes with git policies, progress logging, and datetime tracking. - -## Workflow Overview (13 Steps) - -1. **Enable Plan Mode & Check State** - Resume detection + interactive prompts -2. **Detect Existing PRD** - Smart detection with execute/update/replace options -3. **Detect Taskmaster** - MCP > CLI > Block if missing -4. **Discovery Questions** - 12+ detailed questions (if generating new PRD) -5. **Initialize Taskmaster** - Via MCP/CLI (if not already initialized) -6. **Generate PRD** - Load template and fill with discovery answers -7. **Validate Quality** - 13 automated checks -8. **Parse & Expand** - Combined operation with research -9. **Insert User Tests** - Checkpoint every 5 tasks -10. **Setup Tracking** - DateTime, rollback, accuracy scripts -10.5. **Generate CLAUDE.md** - TDD workflow guide from template -11. **Choose Next Action** - Handoff to TaskMaster OR autonomous execution -12. **Summary & Start** - Present overview and begin work - ---- - -## Detailed Implementation - -### Step 1: Enable Plan Mode & Check for Incomplete Work - -**FIRST ACTION** when skill activates: - -``` -1. Check for incomplete work from previous session: - - Run: python3 .taskmaster/scripts/execution-state.py (if exists) - - If incomplete work found: Offer to resume - -2. Enable plan mode (if not already enabled): - - This ensures AskUserQuestion tool works properly - - Inform user: "Enabling plan mode for interactive prompts..." - -3. Proceed to Step 2 -``` - -**Auto-Resume if Crash Detected:** - -``` -If execution-state.py reports incomplete work: - -🔍 Detected incomplete work from previous session - -📍 Last Activity: - - Mode: {mode} - - Task: {task_id} - - Subtask: {subtask_id} - - Last updated: {timestamp} - - Completed: Tasks {completed_list} - -Resume from: - 1. Last subtask (continue where crashed) - 2. Restart current task - 3. Last checkpoint - 4. Start fresh - -Type: 1, 2, 3, or 4 -``` - ---- - -### Step 2: Detect Existing PRD (Smart Detection) - -**SECOND ACTION**: Check if PRD already exists before generating new one. - -**Detection Logic:** - -``` -1. Check if .taskmaster/ exists: - - Use Glob: .taskmaster/ - - NO → New project, proceed to Step 3 (detect taskmaster) - - YES → Continue to check for PRD - -2. Check for existing PRD: - - Use Glob: .taskmaster/docs/*.md or .taskmaster/docs/prd.* - - Read .taskmaster/tasks/tasks.json to get task count and status - - Found PRD → Show existing PRD options (below) - - No PRD → Proceed to Step 3 (detect taskmaster) -``` - -**If Existing PRD Found:** - -``` -🔍 Found existing PRD: {filename} - -📄 PRD Details: - - Created: {date from file} - - Location: .taskmaster/docs/{filename} - - Tasks: {X total} ({Y completed}, {Z pending}) - - Status: {% complete} - -What would you like to do? - - 1. Execute tasks from existing PRD - → Skip to Step 11 (choose execution mode) - → Start working on pending tasks - - 2. Update/refine existing PRD - → Edit current PRD with new requirements - → Re-parse to update tasks - → Merge with existing task list - - 3. Create new PRD (replace current) - → Confirm: "This will replace existing PRD. Continue?" - → Proceed to Step 3 (full PRD generation workflow) - - 4. Review existing PRD - → Show summary of current PRD - → Display task breakdown - → Exit skill - -Type: 1, 2, 3, or 4 -``` - -**Option 1: Execute Existing Tasks** -``` -User selects: 1 - -✅ Using existing PRD: {filename} -📋 Remaining tasks: {Z} pending tasks -📍 Next checkpoint: {next USER-TEST} - -→ Skip to Step 11 (choose execution mode) -``` - -**Option 2: Update Existing PRD** -``` -User selects: 2 - -📝 What changes would you like to make? - -[User provides changes] - -✅ Updating PRD... -→ Edit existing PRD file with changes -→ Re-run parse-prd with --append flag -→ Continue to Step 8 (insert user tests for new tasks only) -→ Proceed to Step 11 -``` - -**Option 3: Replace PRD** -``` -User selects: 3 - -⚠️ WARNING: This will replace your existing PRD and task list. - -Current work: - - {Y} tasks completed - - {Z} tasks pending - - All completed work will be preserved in git history - - Task files will be regenerated - -Type "yes" to confirm replacement, or "no" to cancel. - -[If yes] -✅ Creating new PRD (old PRD backed up to .taskmaster/docs/prd-backup-{timestamp}.md) -→ Proceed to Step 3 (full workflow) -``` - -**Option 4: Review** -``` -User selects: 4 - -📄 Current PRD Summary: - - Title: {title} - - Created: {date} - - Goals: {list top 3 goals} - - Requirements: {count functional requirements} - - Tasks: {X total} ({Y completed}, {Z pending}) - -[Display executive summary and key sections] - -✅ Review complete. Skill exiting. -``` - -**If No PRD Found (but .taskmaster/ exists):** -``` -🔍 Found .taskmaster/ directory (initialized) -📄 No PRD found - -Creating PRD for TaskMaster... -→ Proceed to Step 3 (detect taskmaster, already initialized) -``` - ---- - -### Step 3: Detect Taskmaster (MANDATORY - Blocks if Missing) - -**CRITICAL**: Must detect taskmaster BEFORE proceeding with PRD generation. - -**Detection Priority:** - -``` -1. Check for MCP Task-Master-AI (PREFERRED): - - Attempt: mcp__task-master-ai__get_tasks with projectRoot - - If successful: TASKMASTER_METHOD="MCP" ✅ - - Note: MCP provides superior integration (direct function calls) - -2. Fallback to CLI: - - Run: which taskmaster - - Run: taskmaster --version - - If found: TASKMASTER_METHOD="CLI" ✅ - -3. Neither Available: - - TASKMASTER_METHOD="NONE" ❌ - - BLOCK and display installation instructions -``` - -**If Taskmaster Not Found:** - -``` -⚠️ Taskmaster Required - -This skill REQUIRES taskmaster (MCP or CLI) to function properly. - -I didn't detect taskmaster in your environment. - -Option 1 (RECOMMENDED): Install MCP Task-Master-AI - • Add to Claude Code MCP settings - • Provides seamless integration with direct function calls - • See: https://github.com/cyanheads/task-master-ai - • Installation: Add to your MCP config, restart Claude Code - -Option 2: Install CLI - • Run: npm install -g task-master-ai - • Use command-line interface - • See: https://github.com/cyanheads/task-master-ai - -Please install taskmaster and type 'done' when ready. - -[WAIT for user to install and confirm] -[RE-DETECT after user confirms] -[PROCEED only if detection successful] -``` - -**NO PROCEEDING WITHOUT TASKMASTER DETECTED** - ---- - -### Step 4: Discovery (Comprehensive Requirements Gathering) - -Ask detailed questions to ensure comprehensive PRD: - -**Essential Questions (5):** -1. What problem does this solve? (user pain point, business impact) -2. Who is the target user/audience? -3. What is the proposed solution or feature? -4. What are the key success metrics? (how we measure success) -5. What constraints exist? (technical, timeline, resources) - -**Technical Context (4):** -6. Is this for an existing codebase or greenfield project? -7. What tech stack? (if known) -8. Any integration requirements? (third-party services, internal systems) -9. Performance/scale requirements? (users, data volume, latency) - -**Taskmaster Specific (3):** -10. Have you used taskmaster before? (helps calibrate detail level) -11. What's the estimated complexity? (simple feature, typical project, complex system) -12. Timeline expectations? (helps with phasing and task breakdown) - -**Open-Ended (1):** -13. Anything else I should know? (edge cases, constraints, context) - -**Smart Defaults:** -- If user provides minimal answers, use best guesses and document assumptions -- Default to comprehensive detail -- Assume engineer audience unless specified otherwise - ---- - -### Step 5: Initialize Taskmaster Project - -**CRITICAL**: Initialize ONLY via taskmaster MCP/CLI. NEVER create .taskmaster/ manually. - -#### If TASKMASTER_METHOD="MCP": - -``` -Use mcp__task-master-ai__initialize_project: - - projectRoot: ${PWD} (absolute path) - - yes: true (skip prompts) - - storeTasksInGit: true - - initGit: false (if git already exists) - - skipInstall: false - - rules: ["claude"] - -Result: .taskmaster/ structure created by taskmaster -``` - -#### If TASKMASTER_METHOD="CLI": - -```bash -taskmaster init --yes --store-tasks-in-git --rules=claude -``` - -**Output:** -``` -✅ Taskmaster Initialized - Created: .taskmaster/docs/ - Created: .taskmaster/tasks/ - Created: .taskmaster/reports/ - Created: .taskmaster/state/ - Updated: .gitignore -``` - ---- - -### Step 6: Generate Comprehensive PRD - -**IMPORTANT**: Use the PRD template from `templates/` directory as the base structure. - -**Template Selection:** -``` -1. Read PRD template based on project complexity: - - Complex/Standard projects: Use Read tool to load templates/taskmaster-prd-comprehensive.md - - Simple features: Use Read tool to load templates/taskmaster-prd-minimal.md - -2. Template provides: - - Complete section structure with placeholders - - Examples for each section - - Taskmaster-specific hints and formatting -``` - -**Generation Process:** -``` -1. Load template: - - Use Read tool: templates/taskmaster-prd-comprehensive.md (default) - - Or: templates/taskmaster-prd-minimal.md (if user requested minimal) - -2. Fill template with user's answers from Step 4: - - Replace [placeholders] with actual content - - Expand examples with project-specific details - - Add technical depth based on discovery answers - -3. Write completed PRD: - - Output to: .taskmaster/docs/prd.md - - Preserve template structure and formatting -``` - -**Template contains 12 sections:** -1. Executive Summary -2. Problem Statement -3. Goals & Success Metrics -4. User Stories -5. Functional Requirements -6. Non-Functional Requirements -7. Technical Considerations -8. Implementation Roadmap -9. Out of Scope -10. Open Questions & Risks -11. Validation Checkpoints -12. Appendix: Task Breakdown Hints - -**Output:** -``` -✅ PRD Generated from template - Template: taskmaster-prd-comprehensive.md - Output: .taskmaster/docs/prd.md - Sections: 12 (all populated) -``` - ---- - -### Step 7: Validate PRD Quality (13 Automated Checks) - -**Required Elements (5 checks):** -1. ✅ Executive summary exists (2-3 sentences) -2. ✅ Problem statement includes user AND business impact -3. ✅ All goals have SMART metrics (Specific, Measurable, Achievable, Relevant, Time-bound) -4. ✅ User stories have acceptance criteria (minimum 3 per story) -5. ✅ Out of scope explicitly defined - -**Functional Requirements (3 checks):** -6. ✅ All functional requirements are testable (not vague) -7. ✅ Each requirement has priority (Must/Should/Could) -8. ✅ Requirements are numbered (REQ-001, REQ-002, etc.) - -**Technical Considerations (2 checks):** -9. ✅ Technical considerations address architecture -10. ✅ Non-functional requirements include specific targets (not "fast" but "< 200ms p95") - -**Taskmaster-Specific (3 checks):** -11. ✅ Requirements have task breakdown hints -12. ✅ Dependencies identified for task sequencing -13. ✅ Acceptance criteria are concrete (can become task completion checks) - -**Validation Output:** -``` -✅ PRD Quality Validation: 13/13 PASSED - ✅ All required elements present - ✅ All functional requirements testable - ✅ Taskmaster optimization complete - -Quality Score: 60/60 (EXCELLENT) -Safe to proceed with task generation. - -OR - -⚠️ PRD Quality Validation: 11/13 (2 warnings) - ✅ Required elements: 5/5 - ✅ Functional requirements: 3/3 - ✅ Technical considerations: 2/2 - ⚠️ Taskmaster optimization: 1/3 - - Warnings: - - REQ-007: "should be performant" is vague - Suggestion: Replace with "< 200ms response time for 95th percentile" - - User Story 2: Only 2 acceptance criteria (minimum 3 recommended) - Suggestion: Add edge case criterion - -Quality Score: 55/60 (GOOD - minor improvements suggested) -``` - ---- - -### Step 8: Parse PRD & Expand Tasks (Single Combined Operation) - -**IMPORTANT**: Combine parse-prd + expand-all with research into ONE operation. - -**Auto-Calculate Task Count:** -``` -numTasks = (count of functional requirements × 1.5) rounded -Minimum: 10 tasks -Maximum: 40 tasks -``` - -#### If TASKMASTER_METHOD="MCP": - -``` -1. Parse PRD with research: - Use mcp__task-master-ai__parse_prd: - - projectRoot: ${PWD} - - input: ".taskmaster/docs/prd.md" - - numTasks: Auto-calculated based on requirements count - - research: true (ALWAYS use research for better quality) - - append: false - -2. Immediately expand ALL tasks with research: - Use mcp__task-master-ai__expand_all: - - projectRoot: ${PWD} - - research: true (ALWAYS use research) - - force: false -``` - -#### If TASKMASTER_METHOD="CLI": - -```bash -# Combined operation: -taskmaster parse-prd --input .taskmaster/docs/prd.md --research --num-tasks auto && \ -taskmaster expand-all --research -``` - -**Result:** -``` -✅ Tasks Generated: 22 tasks from PRD -✅ Tasks Expanded: 94 subtasks created - - Phase 1: 5 tasks, 18 subtasks - - Phase 2: 6 tasks, 28 subtasks - - Phase 3: 5 tasks, 22 subtasks - - Phase 4: 4 tasks, 18 subtasks - - Phase 5: 2 tasks, 8 subtasks -``` - ---- - -### Step 9: Insert User Testing Tasks (Every 5 Tasks) - -**CRITICAL NEW REQUIREMENT**: After tasks are expanded, insert user-facing testing tasks. - -**Logic:** -``` -For every 5 tasks: - - Insert a new task: "USER-TEST-{N}" - - Title: "User Validation Checkpoint {N}" - - Description: "Manually test functionality from Tasks {start} to {end}" - -Example: - - TASK-001 to TASK-005 → Insert USER-TEST-1 after TASK-005 - - TASK-006 to TASK-010 → Insert USER-TEST-2 after TASK-010 -``` - -**User Testing Task Template:** - -```markdown -# USER-TEST-{N}: User Validation Checkpoint {N} - -## Purpose -Manual testing of functionality implemented in Tasks {start}-{end} - -## Prerequisites -All subtasks in Tasks {start}-{end} must be completed and merged to main branch. - -## Testing Checklist - -### Functionality Tests -[For each requirement covered in these 5 tasks, add:] -- [ ] Test Requirement REQ-{X}: [Description] - - Expected: [What should happen] - - Actual: [User fills in] - -### Integration Tests -- [ ] Test integration between components -- [ ] Verify no regressions in existing features - -### Performance Tests -- [ ] Measure response time (should be < [target]) -- [ ] Test under expected load - -## Acceptance Criteria -- ✅ All functionality tests pass -- ✅ No critical bugs found -- ✅ Performance meets targets - -## If Tests Fail -1. Document issue in .taskmaster/docs/progress.md -2. Type "issues" and describe what failed -3. I'll create fix tasks before proceeding -4. Do NOT continue to next tasks until fixed - -## When Complete -Type "passed" to continue to next tasks. -``` - -**Implementation:** - -#### If TASKMASTER_METHOD="MCP": -``` -Use mcp__task-master-ai__add_task for each checkpoint: - - projectRoot: ${PWD} - - title: "User Validation Checkpoint {N}" - - description: "Manually test functionality from Tasks {start} to {end}" - - details: [Full testing checklist template above] - - dependencies: "{end}" (depends on last task in group) - - priority: "high" -``` - -#### If TASKMASTER_METHOD="CLI": -```bash -taskmaster add-task \ - --title="User Validation Checkpoint {N}" \ - --description="Manual testing checkpoint" \ - --dependencies="{end}" \ - --priority=high -``` - -**Result:** -``` -✨ Inserted user validation checkpoints: - ✅ USER-TEST-1 (after Task 5) - ✅ USER-TEST-2 (after Task 10) - ✅ USER-TEST-3 (after Task 15) - ✅ USER-TEST-4 (after Task 20) - -📊 Updated task count: 26 tasks (22 implementation + 4 user tests) -``` - ---- - -### Step 10: Setup Tracking Scripts - -Create automation scripts in `.taskmaster/scripts/`: - -**Scripts to Create:** - -1. **track-time.py** - Real datetime tracking with UTC timestamps -2. **rollback.sh** - Git rollback to any task checkpoint -3. **learn-accuracy.py** - Estimation accuracy learning -4. **security-audit.py** - Auto-generate security checklist -5. **execution-state.py** - Crash recovery state management - -**All scripts are included in this skill's implementation and will be auto-generated.** - -See sections below for complete script implementations. - ---- - -### Step 10.5: Generate CLAUDE.md (TDD Workflow Guide) - -**IMPORTANT**: Generate a TDD workflow guide for the project using the template, but only if one doesn't already exist. - -**Pre-Check (REQUIRED):** -``` -1. Check if CLAUDE.md already exists: - - Use Glob tool: ./CLAUDE.md - - If EXISTS → Skip generation, show message: - "ℹ️ CLAUDE.md already exists. Skipping generation to preserve your configuration." - - If NOT EXISTS → Proceed with generation - -2. Check if codex.md already exists (for later): - - Use Glob tool: ./codex.md - - Store result for Step 4 below -``` - -**Template Loading (only if CLAUDE.md doesn't exist):** -``` -1. Read the CLAUDE.md template: - - Use Read tool: templates/CLAUDE.md.template - -2. Template contains: - - TDD workflow instructions (RED → GREEN → REFACTOR) - - Taskmaster integration commands - - Agent usage guidelines - - Validation & quality gates - - Project-specific placeholders -``` - -**Placeholder Replacement:** -``` -Replace these placeholders with project-specific values from Step 4 discovery: - -{{PROJECT_NAME}} → User's project/feature name -{{TECH_STACK}} → Tech stack from discovery (e.g., "React, Node.js, PostgreSQL") -{{ARCHITECTURE_OVERVIEW}} → Brief architecture description -{{KEY_DEPENDENCIES}} → Main dependencies identified -{{TESTING_FRAMEWORK}} → Testing framework (e.g., "Jest", "Vitest", "pytest") -{{DEV_ENVIRONMENT}} → Development environment setup -{{TEST_COMMAND}} → Test command (e.g., "npm test", "pytest") -``` - -**Generation Process:** -``` -1. Pre-check: Skip if CLAUDE.md exists (see Pre-Check above) - -2. Load template: - - Use Read tool: templates/CLAUDE.md.template - -3. Fill placeholders with discovery answers: - - If value unknown, use sensible default or "[To be configured]" - -4. Write to project root: - - Output to: ./CLAUDE.md (project root) - -5. Ask about Codex (only if codex.md doesn't exist): - - Use AskUserQuestion: "Are you using Codex in addition to Claude Code?" - - If yes AND codex.md doesn't exist: Also write to ./codex.md (identical content) - - If codex.md exists: Skip, show "ℹ️ codex.md already exists." -``` - -**Output:** -``` -[If CLAUDE.md doesn't exist] -✅ CLAUDE.md Generated - Template: templates/CLAUDE.md.template - Output: ./CLAUDE.md - Contains: TDD workflow, taskmaster integration, quality gates - -[If CLAUDE.md exists] -ℹ️ CLAUDE.md already exists - skipped generation - -[If Codex selected AND codex.md doesn't exist] -✅ codex.md Generated (identical to CLAUDE.md) - Output: ./codex.md - -[If codex.md exists] -ℹ️ codex.md already exists - skipped generation -``` - ---- - ---- - -### Step 11: Choose Next Action (Handoff vs Autonomous Execution) - -**CRITICAL CHOICE**: After PRD and tasks are ready, ask user how they want to proceed. - -**Use AskUserQuestion tool** to present two main options: - -``` -Use AskUserQuestion: - -questions: - - question: "Your PRD and tasks are ready. How would you like to proceed?" - header: "Next Action" - multiSelect: false - options: - - label: "Show TaskMaster Commands" - description: "Hand off to TaskMaster. I'll show you the commands to run tasks manually. Full control." - - - label: "Autonomous Execution" - description: "I'll execute tasks for you autonomously with 4 execution modes to choose from." -``` - -**Option A: Handoff to TaskMaster (DEFAULT)** - -If user selects "Show TaskMaster Commands": - -``` -✅ PRD Complete: .taskmaster/docs/prd.md -📋 Tasks Ready: {X} tasks ({Y} subtasks) -📍 Next checkpoint: {next USER-TEST} - -🚀 TaskMaster Commands (Use these to work on tasks): - -Sequential Execution: - taskmaster next-task # Get next available task - taskmaster get-task {id} # Work on specific task - taskmaster set-task-status {id} --status=done - -Parallel Execution (for independent tasks): - taskmaster get-tasks --status=pending --with-subtasks - # Work on multiple tasks simultaneously - -Progress Tracking: - taskmaster get-tasks --with-subtasks # See all tasks - python3 .taskmaster/scripts/track-time.py start {task_id} - python3 .taskmaster/scripts/track-time.py complete {task_id} - -Quality & Safety: - bash .taskmaster/scripts/rollback.sh {task_id} # Rollback to checkpoint - python3 .taskmaster/scripts/security-audit.py # Security check - -📚 Documentation: - - PRD: .taskmaster/docs/prd.md - - Tasks: .taskmaster/tasks/ - - Progress: .taskmaster/docs/progress.md - -💡 Tip: Use TaskMaster MCP tools for the best experience! - -Ready to start? Run: taskmaster next-task -``` - -Then EXIT the skill (skill complete). - ---- - -**Option B: Autonomous Execution** - -If user selects "Autonomous Execution": - -Ask follow-up question with 4 execution modes: - -``` -Use AskUserQuestion: - -questions: - - question: "Select autonomous execution mode:" - header: "Execution Mode" - multiSelect: false - options: - - label: "Sequential to Checkpoint" - description: "Work autonomously until next user test. Stops for validation. RECOMMENDED for quality control." - - - label: "Parallel to Checkpoint" - description: "Run independent tasks in parallel until next user test. Faster execution." - - - label: "Full Autonomous" - description: "Run ALL tasks in parallel without stopping. ⚠️ Skips user validation." - - - label: "Manual Control" - description: "You decide which tasks to run. Full control over execution." -``` - -**Execution Mode Details:** - -**Mode 1: Sequential to Checkpoint** -- Executes tasks one-by-one until next USER-TEST -- Stops for user validation -- Best for quality control -- RECOMMENDED for first-time features - -**Mode 2: Parallel to Checkpoint** -- Executes independent tasks in parallel -- Stops at next USER-TEST -- Faster execution -- Good for experienced users - -**Mode 3: Full Autonomous** -- Runs ALL tasks in parallel without stopping -- Auto-completes USER-TEST tasks -- Fastest execution -- WARNING: Skips manual validation - -**Mode 4: Manual Control** -- User decides which tasks to run -- Full control over execution -- No autonomous execution - -**All execution modes include:** -- Strict git branching policies (branch per task, sub-branch per subtask) -- Commit checkpoints after every task -- Progress logging to .taskmaster/docs/progress.md -- Datetime tracking with track-time.py -- Autonomous execution (no user input except at checkpoints) - -**After mode selected:** - -1. Store their selection -2. Output the corresponding full execution prompt -3. Recognize if user types "begin" or "start" -4. Begin autonomous execution - -**Claude Recommendation:** - -Based on context, recommend the best option: -- First-time feature OR critical system → Mode 1 (Sequential) -- Experienced user OR non-critical → Mode 2 (Parallel) -- Trusted implementation OR time-critical → Mode 3 (Full) -- Complex requirements OR learning → Mode 4 (Manual) - -Display recommendation: "⭐ Claude Recommends: Mode {N}" - -See full execution prompt templates in "EXECUTION PROMPT TEMPLATES" section below. - ---- - -### Step 12: Present Final Summary - -**Summary depends on the path taken:** - ---- - -**If Handoff to TaskMaster (Step 11 Option A):** - -``` -✅ PRD COMPLETE - -📄 PRD Created: .taskmaster/docs/prd.md -✅ Taskmaster Initialized: Using {MCP/CLI} -📋 Tasks Generated: {X} tasks ({Y} implementation + {Z} user tests) -🔄 Tasks Expanded: {N} subtasks -📊 Scripts Created: .taskmaster/scripts/ (5 automation scripts) - -📊 Overview: - - Feature: {name} - - Complexity: {Simple/Medium/Complex} - - Estimated Effort: {X} tasks, ~{Y} hours - - Key Goal: {primary success metric} - -🎯 Key Requirements: - 1. {Top functional requirement} - 2. {Second functional requirement} - 3. {Third functional requirement} - -⚠️ Quality Validation: {score}/60 - ✅ All 13 validation checks passed - -📋 Task Breakdown: - - Phase 1: {X} tasks - - Phase 2: {X} tasks - - Phase 3: {X} tasks - - Phase 4: {X} tasks - -👤 User Testing Checkpoints: - - USER-TEST-1: After Task {N} - - USER-TEST-2: After Task {N} - - USER-TEST-3: After Task {N} - -🚀 Next Steps: - -Run: taskmaster next-task - -Or use TaskMaster MCP tools to manage tasks. - -See commands above for full TaskMaster usage. - -✨ PRD generation complete! Ready to build. -``` - -Then EXIT skill. - ---- - -**If Autonomous Execution (Step 11 Option B):** - -``` -✅ PRD COMPLETE - STARTING AUTONOMOUS EXECUTION - -📄 PRD: .taskmaster/docs/prd.md -✅ Taskmaster: Using {MCP/CLI} -📋 Tasks: {X} tasks ({Y} implementation + {Z} user tests) -🔄 Subtasks: {N} subtasks -📊 Scripts: Ready (.taskmaster/scripts/) - -📊 Overview: - - Feature: {name} - - Complexity: {Simple/Medium/Complex} - - Mode: {Selected execution mode} - - Next Checkpoint: {next USER-TEST} - -⚠️ Quality: {score}/60 ✅ - -🚀 Execution Starting! - -Selected: {execution mode name} - -[If Sequential/Parallel:] -Next: Tasks {start}-{end} → USER-TEST-{N} -Working autonomously until checkpoint... - -[If Full Autonomous:] -All {X} tasks will execute in parallel -Working autonomously until complete... - -[If Manual:] -Awaiting your commands... - -Type "begin" or "start" to execute! -``` - -Then PROCEED to autonomous execution using the selected mode's prompt template (see EXECUTION PROMPT TEMPLATES section). - ---- - -## TOP 5 ENHANCEMENTS - -### Enhancement 1: Real DateTime Tracking (B2) - -**Script: `.taskmaster/scripts/track-time.py`** - -Created automatically during Step 8. - -**Features:** -- UTC timestamp tracking (start/end) -- Precise duration calculation -- JSON state persistence -- CLI interface for easy use - -**Usage in Execution:** -```bash -# Before task starts: -python3 .taskmaster/scripts/track-time.py start {task_id} - -# Before subtask starts: -python3 .taskmaster/scripts/track-time.py start {task_id} {subtask_id} - -# After subtask completes: -python3 .taskmaster/scripts/track-time.py complete {task_id} {subtask_id} - -# After task completes: -python3 .taskmaster/scripts/track-time.py complete {task_id} -``` - -**Output:** -``` -📅 Started: 2025-01-29 14:23:15 UTC -[... work happens ...] -✅ COMPLETED -📅 Ended: 2025-01-29 14:41:32 UTC -⏱️ Actual: 18 min (18.3 min) -📊 vs Estimate: 18 min - Within 2% ✅ -``` - -See TOP_5_ADDITIONS.md for complete script code. - ---- - -### Enhancement 2: Instant Rollback Command (A1) - -**Script: `.taskmaster/scripts/rollback.sh`** - -**Trigger:** User types "rollback to task X" at ANY time during execution - -**Features:** -- Revert to any checkpoint tag -- Backup current state before rollback -- Update progress.md with rollback note -- Safety confirmation before destructive action - -**Usage:** -``` -USER: rollback to task 3 - -CLAUDE: -🔄 Initiating rollback to Task 3... - -⚠️ This will: - - Discard all changes after Task 3 - - Reset to checkpoint-task-003 - - Preserve current work in rollback-backup-{timestamp} - -Type "yes" to confirm. - -USER: yes - -CLAUDE: -💾 Backed up to: rollback-backup-20250129-143045 -✅ Rolled back to Task 3 -📝 Updated progress.md - -What next? - 1. Resume from here - 2. Redo Task 3 - 3. Skip to different task - 4. Manual control - -Type: 1, 2, 3, or 4 -``` - -See TOP_5_ADDITIONS.md for complete script code. - ---- - -### Enhancement 3: Accuracy Learning System (B3) - -**Script: `.taskmaster/scripts/learn-accuracy.py`** - -**Trigger:** After every 10 completed tasks (at USER-TEST checkpoints) - -**Features:** -- Tracks estimated vs actual time -- Calculates average accuracy percentage -- Generates adjustment factor -- Recommends estimate adjustments - -**Usage:** -``` -After USER-TEST-2 passes: - -📊 Running estimation accuracy analysis... - -Tasks Analyzed: 10 -Average Accuracy: 123% -Adjustment Factor: 1.23x - -Recent Tasks: - Task 6: 45min (est: 30min) - 150% ⚠️ - Task 7: 22min (est: 25min) - 88% ✅ - Task 8: 38min (est: 35min) - 109% ✅ - Task 9: 67min (est: 45min) - 149% ⚠️ - Task 10: 28min (est: 20min) - 140% ⚠️ - -⚠️ You're taking 23% longer than estimated. - -Apply adjustment to remaining tasks? - 1. Yes, increase all future estimates by 23% - 2. No, keep current estimates - 3. Manually review - -Type: 1, 2, or 3 -``` - -See TOP_5_ADDITIONS.md for complete script code. - ---- - -### Enhancement 4: Security Audit Checklist (D2) - -**Script: `.taskmaster/scripts/security-audit.py`** - -**Trigger:** Before final USER-TEST checkpoint - -**Features:** -- Scans codebase for security-relevant patterns -- Auto-generates checklist based on detected features -- Includes standard security checks -- Offers automated security scans - -**Usage:** -``` -Before USER-TEST-4 (final checkpoint): - -🔒 Generating security audit checklist... - -### Authentication -- [ ] Passwords hashed with bcrypt (cost ≥ 10) -- [ ] Session tokens cryptographically secure - -### OAuth -- [ ] OAuth tokens encrypted at rest -- [ ] OAuth state parameter prevents CSRF - -### Database -- [ ] All queries use parameterized statements -- [ ] No SQL injection vulnerabilities - -### General Security -- [ ] HTTPS enforced in production -- [ ] CSRF protection enabled -- [ ] Rate limiting on auth endpoints -- [ ] Security headers set (CSP, X-Frame-Options) - -Run automated scans? - 1. Yes, run npm audit + recommended scans - 2. Manual review only - 3. Skip for now - -Type: 1, 2, or 3 -``` - -See TOP_5_ADDITIONS.md for complete script code. - ---- - -### Enhancement 5: Auto-Resume After Crash (A2) - -**Script: `.taskmaster/scripts/execution-state.py`** - -**Trigger:** At Step 0 (before anything else) if previous session crashed - -**Features:** -- Persistent execution state tracking -- Detects incomplete work -- Offers resume points -- Continues where crashed - -**Usage:** -``` -[Claude crashes mid-task] -[User restarts 10 minutes later] - -USER: continue - -CLAUDE: -🔍 Checking for incomplete work... - -Found it! - -📍 Last Activity: - - Mode: Sequential to Checkpoint - - Task: TASK-007 (OAuth callback handler) - - Subtask: 3/5 (Handle error responses) - - Last updated: 2025-01-29 16:45:23 UTC (10 min ago) - - Completed: Tasks 1-6 ✅ - -Resume from: - 1. Last subtask (continue TASK-007 subtask 3) - 2. Restart current task (redo TASK-007) - 3. Last checkpoint (restart from TASK-006) - 4. Start fresh - -Type: 1, 2, 3, or 4 -``` - -See TOP_5_ADDITIONS.md for complete script code. - ---- - -## EXECUTION PROMPT TEMPLATES - -### Template 1: Sequential to Checkpoint - -``` -Execute tasks sequentially from {current_task} until USER-TEST-{N}. - -DATETIME TRACKING: -Before each task: - python3 .taskmaster/scripts/track-time.py start {task_id} -Before each subtask: - python3 .taskmaster/scripts/track-time.py start {task_id} {subtask_id} -After each subtask: - python3 .taskmaster/scripts/track-time.py complete {task_id} {subtask_id} -After each task: - python3 .taskmaster/scripts/track-time.py complete {task_id} - -STRICT GIT POLICY: -1. When starting a task: - - Create feature branch: git checkout -b task-{id}-{slug} - -2. For each subtask: - - Create sub-branch: git checkout -b task-{id}-subtask-{n} - - Complete subtask implementation - - Run tests - - Commit: git commit -m "Complete task-{id} subtask-{n}: [description] - -🤖 Generated with [Claude Code](https://claude.com/claude-code) - -Co-Authored-By: Claude " - - Merge to task branch: git checkout task-{id}-{slug} && git merge task-{id}-subtask-{n} - - Delete sub-branch: git branch -d task-{id}-subtask-{n} - -3. When task complete: - - Verify ALL subtasks merged - - Merge to main: git checkout main && git merge task-{id}-{slug} - - Create checkpoint tag: git tag -a checkpoint-task-{id} -m "Completed task {id}" - - Delete feature branch: git branch -d task-{id}-{slug} - -PROGRESS LOGGING: -After EVERY task completion, append to .taskmaster/docs/progress.md: - -## Task {id}: {title} - COMPLETED -**Completed**: {timestamp} -**Duration**: {actual_minutes} min (estimated: {estimated_minutes} min) -**Subtasks**: {n} completed -**Tests**: {pass/fail} -**Issues**: {any issues encountered} -**Git**: Merged to main, tagged as checkpoint-task-{id} - -AUTONOMOUS EXECUTION RULES: -- Do NOT ask user for input unless critical error -- If task fails tests: retry once, then log and skip -- If dependency missing: log and skip -- Continue until USER-TEST-{N} reached -- Update execution state after each task - -ROLLBACK SUPPORT: -If user types "rollback to task X" at ANY TIME: - 1. STOP current work immediately - 2. Run: bash .taskmaster/scripts/rollback.sh X - 3. Wait for user confirmation - 4. Resume as user directs - -WHEN COMPLETE: -Stop and report: -"✅ Tasks {start}-{end} completed -📋 Next: USER-TEST-{N} - User validation required -📝 Progress: .taskmaster/docs/progress.md -🌳 Git: All tasks merged with checkpoints" -``` - ---- - -### Template 2: Parallel to Checkpoint - -Similar to Template 1 but with: -- Launch up to 3 concurrent tasks -- Handle merge conflicts automatically -- Wait for dependencies before starting dependent tasks -- All tasks still follow git policy (branch per task) - ---- - -### Template 3: Full Autonomous - -Similar to Template 2 but: -- No stops for USER-TEST (auto-completes them) -- Maximum parallelization (up to 5 concurrent) -- Runs automated tests for USER-TEST tasks -- Only stops when ALL tasks complete - ---- - -### Template 4: Manual Control - -``` -Awaiting user commands: -- "next task" - Get next task -- "task {id}" - Work on specific task -- "status" - Show progress -- "parallel {id1,id2}" - Run specific tasks in parallel - -Git policies enforced. -Progress logged to progress.md. -Datetime tracking active. -``` - ---- - -## Reference - -For complete script implementations, see: -- TOP_5_ADDITIONS.md (all 5 automation scripts) -- USER_EXPERIENCE_EXAMPLE.md (user flow walkthrough) -- CHANGES_SUMMARY.md (what changed from v1) - -## Tips for Best Results - -**Understand the Workflow:** -- This skill PRIMARILY generates PRDs (with optional execution) -- If you already have a PRD, the skill will detect it and offer options -- Default behavior: Generate PRD → Hand off to TaskMaster -- Optional: Choose autonomous execution after PRD is ready - -**Provide Context Upfront:** -- More detail in discovery → Better PRD -- Share constraints, dependencies, assumptions -- Mention existing systems to integrate with - -**Be Specific About Success:** -- Quantify goals (not "improve UX" but "increase NPS from 45 to 60") -- Define what "done" looks like -- Specify how you'll measure success - -**Choose the Right Path:** -- **Handoff to TaskMaster** (Default): Best if you want full control -- **Autonomous Execution**: Best if you trust the process and want speed - - Sequential to Checkpoint: Quality-focused, stops for validation - - Parallel to Checkpoint: Faster, stops at checkpoints - - Full Autonomous: Maximum speed, skips validation - - Manual Control: You decide every step - -**If Using Existing PRD:** -- Option 1: Execute tasks (pick up where you left off) -- Option 2: Update PRD (add new requirements) -- Option 3: Replace PRD (start fresh, old PRD backed up) -- Option 4: Review PRD (just read it) - -**Leverage Automation (if using autonomous execution):** -- Use datetime tracking to improve estimates -- Use rollback if you need to undo work -- Let accuracy learning adjust your estimates -- Review security audit before deployment - -**Trust the Process:** -- USER-TEST checkpoints catch issues early -- Git checkpoints allow easy rollback -- Progress.md shows exactly what happened -- TaskMaster integration ensures smooth workflow - ---- - -**Remember**: A comprehensive PRD with automated quality control and TaskMaster integration is the foundation of successful implementation. This skill focuses on creating that foundation. Execution is optional - you can always hand off to TaskMaster for more control. - diff --git a/agents/research-expander.md b/agents/research-expander.md new file mode 100644 index 0000000..870c903 --- /dev/null +++ b/agents/research-expander.md @@ -0,0 +1,70 @@ +--- +name: research-expander +description: >- + Task-specific research subagent for the prd-taskmaster expand-tasks skill. + Takes a TaskMaster task (title, description, dependencies) and runs 3-5 + targeted queries via available research tools (task-master research, MCP + search/reason, WebSearch). Returns structured summary (~25-40 lines) with + citations suitable for writing back to tasks.json via write-research. +tools: + - Read + - WebSearch + - WebFetch + - Bash +--- + +# research-expander + +You research a single TaskMaster task and return a concise, cited summary. + +## Input + +The skill invoking you passes task context (JSON from `task-master show`) plus +the skill's default research prompt template. Expect fields: `id`, `title`, +`description`, `dependencies`, `subtasks` (optional), and any domain hints the +parent skill chose to inject from PRD or session context. + +## Procedure + +1. Read the task context carefully. Identify the task's domain (backend, + frontend, infra, security, data, etc.) and the 2-3 highest-risk decisions + the implementer will face. +2. Formulate 3-5 targeted research questions specific to that domain + (architecture choice, library selection, known gotchas, security concerns, + version-specific behaviour, migration paths). +3. Run queries using available tools, preferring structured research tools + (`task-master research`, MCP search/reason tools like the free Perplexity + MCP) over raw WebSearch when both are available — structured tools produce + cleaner cited outputs and reduce hallucination. +4. Distill findings into a 25-40 line summary. Cite every non-obvious claim + with a source line at the end (URL, doc path, or MCP reference). +5. Return the summary as your final message, nothing more. + +## Constraints + +- Do NOT modify files. You are read/query-only. The parent skill handles + writeback via `script.py write-research`. +- Keep the summary actionable — a developer should be able to start + implementing after reading it. +- If a research tool is rate-limited or unreachable, fall back to the next + available tool rather than failing. Report the fallback explicitly in the + summary (e.g., "Perplexity unreachable; fell back to WebSearch"). +- Never invent citations. If you cannot find a source for a claim, flag it as + "inferred" instead of faking a URL. + +## Output format + +``` +## Task : + +### Research summary +<25-40 lines of distilled findings with inline citations> + +### Sources +- [source 1] +- [source 2] +... + +### Open questions +<anything the research couldn't resolve; flagged for the implementer> +``` diff --git a/docs/dogfood-fleet-run-2026-06-11.md b/docs/dogfood-fleet-run-2026-06-11.md new file mode 100644 index 0000000..bfc27ce --- /dev/null +++ b/docs/dogfood-fleet-run-2026-06-11.md @@ -0,0 +1,60 @@ +# Dogfood Report — first live Atlas Fleet run (2026-06-11) + +Phase B's acceptance gate (B-Task 12): the execute-fleet contract driven end-to-end against a +real 8-task sample project (`wordkit`, stdlib word utilities) with autonomous Claude worker +sessions spawned via atlas-launcher. **Result: `SHIP_CHECK_OK` emitted exactly once, exit 0, +on the `fleet-integration` branch.** + +## Acceptance criteria — all met + +| Criterion | Evidence | +|---|---| +| fleet-integration branch with all worker branches merged | head `3c9c05f`; 8 `--no-ff` merges, sequential, orchestrator-only | +| Every task done | 8/8 `status: done` in tasks.json (sole-writer updates) | +| CDD card per task | `.atlas-ai/cdd/task-{1..8}.json` on the integration branch | +| One inbox completion message per task | durable inbox messages (ids 107–112 + tasks 7, 8), all `task_handoff` with payload JSON per contract v1 | +| Negative test | worker `fleet-w2-task4` killed mid-task 13:13:15Z (no CDD card) → re-queued ONCE → re-queue verified DONE and merged; never falsely done | +| SHIP_CHECK_OK exactly once | ship-check exit 0 after the Gate-2 fix below | +| main never auto-touched | all fleet work on `fleet-integration` + worker branches | + +Every completion claim was independently verified before merging (CDD card present, evidence +exit-0, tests re-run in the orchestrator's shell, commit-scope audit) — no worker's word was +ever counted directly. + +## Contract mechanisms exercised live + +- `prompt_injected: false` detection + single re-kick (workers 1, 7) — both recovered. +- Worker self-start from the handoff file (worker 2) — faster than the kick path. +- `DONE_WITH_CONCERNS` honesty (worker 5: swept a tracked `.pyc`, recommended the fix — accepted). +- Worker push-back on stale context (worker 7: detected a stale wave-1 handoff file, executed + the authoritative inline contract, reported the discrepancy). +- Explicit model routing (worker 8 spawned with `model="sonnet"`, standard tier). + +## Findings → fixes (all landed during the run) + +1. **ship-check Gate 2 rejected the engine's own flat tasks.json format** (only read tagged + `master.tasks`). The engine's first SHIP_CHECK_OK attempt failed on its own output. Fixed in + both `skel/ship-check.py` and `prd_taskmaster/shipcheck.py` + regression test (`6db8179`). +2. **Workers' `git add -A` swept launcher spawn metadata** (`.claude/handshake.json`, + `spawn-meta.md`) → add/add merge conflicts AND a stale handoff file propagating to later + waves. Worker contract now forbids committing `.claude/`/`__pycache__`; fleet projects + gitignore them. +3. **TDD red-phase output in evidence files trips strict Gate 5.** Worker contract now mandates: + evidence = final verification run only; intermediate red runs go to `.atlas-ai/logs/` + (`2be055c`). +4. **Default-model spawns waste capacity** (first waves ran on the default model). Directive + encoded: every spawn passes an explicit tier-matched model (`fleet.json` routing / + `resolve_backend`). + +## Honest caveats + +- All workers were Claude. The codex/gemini experimental wave was NOT run: the RUNNING + launcher predates `d757905` (the `backend` param lives on the `feat/session-spawn-backend` + worktree branch, not yet merged/restarted into the live server). Deferred to the launcher's + next release; `experimental_backends` remains false by default, matching the documented + posture ("Claude today, Codex/Gemini experimental"). +- Wave scheduling came from `compute_fleet_waves`; merges were conflict-free after the + metadata fix, so the conflict→fix-task path was exercised only by the benign metadata case. +- The orchestrator was this session (Fable 5) following `skills/execute-fleet/SKILL.md` + manually as its first executor; the skill text held up — every situation that arose was + covered by a written rule. diff --git a/docs/dogfood-phase-b-2026-06-11.md b/docs/dogfood-phase-b-2026-06-11.md new file mode 100644 index 0000000..82ccdb8 --- /dev/null +++ b/docs/dogfood-phase-b-2026-06-11.md @@ -0,0 +1,57 @@ +# Dogfood Report — v4 engine on the Phase B goal (2026-06-11) + +The merged v4 engine ran its own 4-phase pipeline on a real goal: planning **Phase B (Atlas +Fleet)**. Full pipeline completed: Preflight → Discovery (approved) → Generate → Handoff. + +## Run evidence + +| Artifact | Result | +|---|---| +| Preflight | TaskMaster CLI 0.43.1 detected; fresh state; providers auto-configured (codex-cli main, sonar research via local proxy) | +| PRD | `.taskmaster/docs/prd.md` — first validate **NEEDS_WORK (59.6%)** → fix loop (one iteration) → **EXCELLENT 57/57 (100%)**, 0 placeholders | +| Task graph | 12 tasks / 29 subtasks, dependencies forming a real DAG; `validate-tasks` caught an empty subtask description (authoring error) before accepting; enrich idempotent; phaseConfig coverage 1.0 (11 MEDIUM, 1 COMPLEX) | +| Handoff | Mode A (Plan & Drive) recommended for this machine-state; Atlas Fleet correctly locked | + +The validation fix loop (UC-4) worked exactly as designed — the engine refused its own +author's first draft and every demanded fix (user/business impact, measurable goals, P0/P1 +priorities, ~Nh effort hints) genuinely improved the spec. + +## Findings (fix before v4.0.0 ships) + +1. **P0 — `task-master init` clobbers an existing `.mcp.json`.** Running the engine's setup in + a project that already has `.mcp.json` (e.g. any Claude plugin repo — including this one) + overwrites it with TaskMaster's template full of placeholder API keys. The SETUP phase must + extend the DETECT-FIRST principle (FR-6) to `.mcp.json`: back up before init and re-merge + the user's entries after, or pass init flags that skip MCP config writing. +2. **P1 — `configure-providers` sequencing gap.** SKILL.md Phase 1 says to run it right after + preflight, but it hard-fails with ".taskmaster/config.json not found; run task-master init + first". Either the skill flow inits first, or the command should auto-init / return a + recommended-action instead of an error. +3. **P2 — `calc-tasks` ignores the scale classification.** Discovery classified the project + Solo (8–12 tasks per the product spec); the CLI heuristic recommended 21 from + requirement-count alone. The MCP variant takes team/scope params; the CLI should accept + (or infer) scale. +4. **P2 — recommendation legibility.** `detect-capabilities` (CLI) returns an empty + recommendation reason and no `tier` field ("None"), violating the UX-SPEC rule that every + recommendation carries a reason. The plugin's mode_recommend emits both; the CLI path + should too. + +## Execution-layer comparison (the dogfood's second purpose) + +Three candidates for executing a task graph in parallel were compared: + +- **The /atlas engine** owns the front of the pipeline (goal → graded PRD → dependency DAG) + and the verification spine (CDD cards, ship-check). It does not execute in parallel — by + design it hands off. +- **orchestrating-peer-cli-agents** is a manual discipline for driving peer CLI agents in + tmux: decompose by hand, review plans, independently verify every claim, own the scoreboard. + No planner, no deterministic artifacts — but its verification doctrine is the strongest part. +- **atlas-dispatch** (personal skill) already automates PRD → launcher fleet → dependency + waves → checker → integration branch → one PR. It is the working prototype of Phase B. + +**Verdict: combine, don't choose.** They are three layers of one stack, and the Phase B PRD +encodes exactly that: the engine plans and verifies; `execute-fleet` (productized +atlas-dispatch) executes; and the peer-cli skill's rules are written into the worker contract +as hard requirements — REQ-006..REQ-009 are its "never trust the peer's word", "orchestrator +owns the scoreboard", and "self-contained goal with an invitation to push back" doctrines made +machine-enforceable. The manual skill remains the documented no-launcher fallback. diff --git a/docs/evidence/release-e2e.md b/docs/evidence/release-e2e.md new file mode 100644 index 0000000..6bd487f --- /dev/null +++ b/docs/evidence/release-e2e.md @@ -0,0 +1,94 @@ +# Release E2E Evidence + +Commit under validation: `8c993765c854e398796a08269d697ba6d0da7b50` + +## KPI Snapshot + +Command: + +```bash +bash scripts/ops/kpi-snapshot.sh +``` + +Exit status: 0 + +Summary: + +```text +A1 installs: not configured +A3 weekly actives: not configured +AC1 reach_execute: not configured +AC2 ship_check_ok: not configured +C1 conversion: n/a (license telemetry not deployed) +``` + +## Full Test Suite + +Command: + +```bash +python3 -m pytest tests/ -q +``` + +Exit status: 0 + +Output: + +```text +248 passed in 61.33s (0:01:01) +``` + +## UX Lint + +Command: + +```bash +python3 -m pytest tests/test_ux_conformance.py -q +``` + +Exit status: 0 + +Output: + +```text +6 passed in 0.25s +``` + +## Ship Check + +Command: + +```bash +python3 skel/ship-check.py --cwd /home/anombyte/Shade_Gen/Projects/.fleet-wt/w4-uxrel +``` + +Exit status: 1 + +Output: + +```text +FAIL: pipeline.json missing at .atlas-ai/state/pipeline.json +FAIL: tasks.json missing at .taskmaster/tasks/tasks.json +FAIL: no plan file at .taskmaster/docs/plan.md or docs/superpowers/plans/*.md +``` + +Status: blocked. The mission explicitly forbids creating or modifying +`.taskmaster/`, so this worker cannot fabricate the release project state +required by ship-check. + +## Live Purchase E2E + +Status: blocked. A real Stripe live-mode purchase, Resend email delivery, +license activation, deployed Worker telemetry, and production Atlas Fleet +unlock require live credentials and deployed services. The mission +pre-declared those as skip-not-fabricate blockers. + +## Tag Checklist + +- [x] Tests green: `python3 -m pytest tests/ -q`, exit 0. +- [x] UX lint zero violations: `python3 -m pytest tests/test_ux_conformance.py -q`, exit 0. +- [ ] Live purchase verified: blocked, no live Stripe/Resend/deployed backend credentials. +- [ ] `SHIP_CHECK_OK`: blocked, required release `.taskmaster/` state is absent and cannot be fabricated under this mission. + +Do not tag the release until the blocked items are completed by an authorized +operator in a deployed release environment. diff --git a/docs/ops/kpi-runbook.md b/docs/ops/kpi-runbook.md new file mode 100644 index 0000000..b2057a3 --- /dev/null +++ b/docs/ops/kpi-runbook.md @@ -0,0 +1,74 @@ +# KPI Snapshot Runbook + +## Purpose + +`scripts/ops/kpi-snapshot.sh` prints the release KPI snapshot for: + +| KPI | Event | Meaning | +|---|---|---| +| A1 installs | `install` | installer completed | +| A3 weekly actives | `atlas_invoked` | user started Atlas in the last 7 days | +| AC1 reach_execute | `reach_execute` | pipeline reached execution | +| AC2 ship_check_ok | `ship_check_ok` | release gate emitted `SHIP_CHECK_OK` | +| C1 conversion | `license_activate` | free-to-Pro conversion | + +Rows are reported for `last-7d`, `last-30d`, and `all-time`. + +## Run Locally + +```bash +bash scripts/ops/kpi-snapshot.sh +``` + +Without analytics credentials the script exits 0 and prints `not configured` +for A1/A3/AC1/AC2. C1 prints `n/a (license telemetry not deployed)` until the +license-activation telemetry path is deployed. + +## Fixture Mode + +Use fixture mode for tests or release dry-runs: + +```bash +KPI_FIXTURE_JSON='{"A1":{"last_7d":7,"last_30d":30,"all_time":300}}' \ +LICENSE_TELEMETRY_DEPLOYED=1 \ +bash scripts/ops/kpi-snapshot.sh +``` + +## Query Command Hook + +Operators can provide a command that accepts `<metric> <bucket>` and prints one +value: + +```bash +KPI_QUERY_CMD='scripts/ops/query-worker-kpi.sh' bash scripts/ops/kpi-snapshot.sh +``` + +The hook receives metric IDs (`A1`, `A3`, `AC1`, `AC2`, `C1`) and buckets +(`last_7d`, `last_30d`, `all_time`). This keeps the snapshot script stable +while Worker Analytics Engine or D1 query details evolve. + +## Cloudflare Setup + +Expected environment for a Worker Analytics implementation: + +```bash +export CF_ACCOUNT_ID=... +export CF_API_TOKEN=... +export ATLAS_D1_DB=... # optional D1 fallback +``` + +Recommended event filters: + +- A1: count `install` +- A3: count distinct install IDs with `atlas_invoked` +- AC1: count `reach_execute` +- AC2: count `ship_check_ok` +- C1: count `license_activate` divided by A1 installs for the same range + +## Weekly Review + +1. Run `bash scripts/ops/kpi-snapshot.sh`. +2. Save the output in the release notes or evidence doc. +3. Check A1 and A3 for install and retention movement. +4. Check AC1 and AC2 for funnel drop-off. +5. Treat C1 as `n/a` until license activation telemetry is deployed. diff --git a/docs/ops/telemetry-queries.md b/docs/ops/telemetry-queries.md new file mode 100644 index 0000000..b620fdb --- /dev/null +++ b/docs/ops/telemetry-queries.md @@ -0,0 +1,103 @@ +# Atlas Telemetry Queries + +Telemetry is accepted at `POST https://api.atlas-ai.au/telemetry` with: + +```json +{"install_id":"install_123","event":"install","version":"1.2.3","os":"linux"} +``` + +Allowed events: + +- `install` +- `atlas_invoked` +- `reach_execute` +- `ship_check_ok` + +The Worker writes every valid event to D1 table `telemetry_events` and also emits +cheap counters to Workers Analytics Engine with: + +- `blobs = [event, version, os]` +- `doubles = [1]` +- `indexes = [install_id]` + +Use D1 for unique-install KPIs. Analytics Engine SQL is useful for sampled count +queries, but it is not the source of truth for exact unique-install metrics. + +## D1 KPI Queries + +A1 unique installs: + +```sql +SELECT COUNT(DISTINCT install_id) AS unique_installs +FROM telemetry_events +WHERE event = 'install'; +``` + +A3 weekly active installs: + +```sql +SELECT COUNT(DISTINCT install_id) AS weekly_active_installs +FROM telemetry_events +WHERE event != 'install' + AND timestamp > datetime('now', '-7 days'); +``` + +AC1 reach executions: + +```sql +SELECT COUNT(*) AS reach_execute_count +FROM telemetry_events +WHERE event = 'reach_execute'; +``` + +AC2 successful ship checks: + +```sql +SELECT COUNT(*) AS ship_check_ok_count +FROM telemetry_events +WHERE event = 'ship_check_ok'; +``` + +C1 free-to-pro conversion is forward-looking until license activation telemetry +exists. The intended query needs an activation/pro event joined or correlated by +`install_id` after that event is added. + +## Wrangler Examples + +```sh +cd workers +npx wrangler d1 execute LICENSE_DB --remote --command \ + "SELECT COUNT(DISTINCT install_id) AS unique_installs FROM telemetry_events WHERE event = 'install'" + +npx wrangler d1 execute LICENSE_DB --remote --command \ + "SELECT COUNT(DISTINCT install_id) AS weekly_active_installs FROM telemetry_events WHERE event != 'install' AND timestamp > datetime('now', '-7 days')" +``` + +## Analytics Engine Count Query + +Analytics Engine can answer count-style checks through the Cloudflare SQL API. +Use it for inexpensive directional counters, not exact unique-install KPIs: + +```sh +curl -sS "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/analytics_engine/sql" \ + -H "authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + --data-binary "SELECT blob1 AS event, SUM(double1) AS events FROM telemetry_events WHERE timestamp > NOW() - INTERVAL '7' DAY GROUP BY blob1" +``` + +## Smoke Test + +After deploy, a valid event should return `204`: + +```sh +curl -i -X POST https://api.atlas-ai.au/telemetry \ + -H 'content-type: application/json' \ + --data '{"install_id":"smoke_install","event":"install","version":"1.0.0","os":"linux"}' +``` + +An invalid event should return `400`: + +```sh +curl -i -X POST https://api.atlas-ai.au/telemetry \ + -H 'content-type: application/json' \ + --data '{"install_id":"smoke_install","event":"unknown","version":"1.0.0","os":"linux"}' +``` diff --git a/docs/ops/worker-runbook.md b/docs/ops/worker-runbook.md new file mode 100644 index 0000000..40729c4 --- /dev/null +++ b/docs/ops/worker-runbook.md @@ -0,0 +1,243 @@ +# Atlas Pro Worker Runbook + +## Local Setup + +From the repository root: + +```sh +cd workers +npm install +cp .dev.vars.example .dev.vars +``` + +Fill `.dev.vars` with local-only values. Never commit `.dev.vars`. + +## Secrets + +Production secrets are managed with Wrangler and must not be committed: + +```sh +cd workers +npx wrangler secret put STRIPE_WEBHOOK_SECRET +npx wrangler secret put STRIPE_API_KEY +npx wrangler secret put ED25519_PRIVATE_KEY +npx wrangler secret put RESEND_API_KEY +``` + +Use the Stripe CLI webhook secret for local replay, the Dashboard webhook secret +for the production endpoint, a Stripe API key scoped to subscription reads, a +32-byte Ed25519 seed encoded as lowercase hex, and a Resend API key scoped to +the verified `atlas-ai.au` sending domain. + +## Stripe Webhook Replay + +Local replay needs real Stripe test credentials in `workers/.dev.vars`: + +- `STRIPE_WEBHOOK_SECRET` from `stripe listen` +- `STRIPE_API_KEY` for subscription retrieval during checkout and refresh flows +- `ED25519_PRIVATE_KEY` as a 32-byte lowercase hex seed + +Run the Worker locally: + +```sh +cd workers +npm run d1:migrate:local +npm run dev -- --port 8787 +``` + +In another shell, forward Stripe events: + +```sh +stripe listen --forward-to localhost:8787/stripe/webhook +``` + +Use the printed `whsec_...` value as `STRIPE_WEBHOOK_SECRET`, restart the +Worker, then trigger the local events: + +```sh +stripe trigger checkout.session.completed +stripe trigger invoice.paid +stripe trigger customer.subscription.deleted +``` + +Verify local D1 state after replay: + +```sh +cd workers +npx wrangler d1 execute LICENSE_DB --local --command "SELECT lid, plan, stripe_subscription_id, expires_at, cancelled_at FROM licenses ORDER BY created_at DESC LIMIT 5" +npx wrangler d1 execute LICENSE_DB --local --command "SELECT event_id, processed_at FROM processed_events ORDER BY processed_at DESC LIMIT 5" +``` + +Duplicate replay should return `200` with no additional license mutation because +Stripe event ids are stored in `processed_events`. A request without a Stripe +signature should return `400`: + +```sh +curl -i -X POST http://localhost:8787/stripe/webhook \ + -H 'content-type: application/json' \ + --data '{"id":"evt_unsigned","object":"event","type":"customer.created"}' +``` + +This environment has no Stripe API key or webhook secret, so the live Stripe CLI +replay was skipped and recorded as blocked in `FLEET-LOG-BACKEND.md`. The local +Vitest suite verifies signed payloads, forged signatures, idempotency, license +issuance, invoice renewal, and cancellation against local D1. + +## Resend License Email Delivery + +The Worker sends license emails directly with `fetch` to Resend's Email API: +`POST https://api.resend.com/emails`. The payload includes the full license key, +CLI activation command, Claude Code `/license-activate` command, activation docs, +and `support@atlas-ai.au`. + +Production setup: + +```sh +cd workers +npx wrangler secret put RESEND_API_KEY +``` + +Resend must have the `atlas-ai.au` sending domain verified before production +traffic uses `Atlas AI <licenses@atlas-ai.au>`. Keep the API key scoped to email +sending for that domain. + +Manual resend procedure: + +1. Look up the license row by `lid` in D1. +2. Reconstruct or refresh a signed key through the Stripe webhook replay or the + `/license/refresh` endpoint. +3. Send to the customer address from Stripe, not from D1, because D1 stores only + `sub_hash` and never the raw customer email. +4. Confirm Resend delivery in the dashboard and record the `lid`, Resend message + id, and operator initials in the incident notes. Do not log raw license keys. + +Safe test-mode delivery, when a real `RESEND_API_KEY` exists locally: + +```sh +cd workers +# Trigger a checkout webhook through Stripe CLI with the customer email set to delivered@resend.dev. +npm run dev -- --port 8787 +``` + +Then confirm the Resend dashboard shows delivery comfortably inside the +2-minute acceptance window. This environment has no `RESEND_API_KEY`, so the live +Resend test-mode send was skipped and recorded as blocked in +`FLEET-LOG-BACKEND.md`. The Vitest suite mocks Resend success, 5xx retries, +retry exhaustion, 4xx fail-fast behavior, and webhook failure isolation. + +## License Refresh Endpoint + +`POST https://api.atlas-ai.au/license/refresh` accepts: + +```json +{"lid":"lic_example"} +``` + +Responses: + +- `200 {"ok":true,"key":"ATLAS-..."}` when the D1 license exists and Stripe + reports the subscription as `active` or `trialing`. +- `400 {"ok":false}` for invalid JSON or a missing/non-string `lid`. +- `403 {"ok":false,"reason":"license_not_found"}` for unknown lids. +- `403 {"ok":false,"reason":"subscription_cancelled"}` for D1-cancelled rows or + Stripe subscriptions outside `active`/`trialing`. +- `429 {"ok":false,"reason":"rate_limited"}` after 10 refresh requests per lid + per UTC day. + +CORS is intentionally narrow: + +- `Access-Control-Allow-Origin: https://atlas-ai.au` +- `Access-Control-Allow-Methods: POST, OPTIONS` +- `Access-Control-Allow-Headers: content-type` + +Local seeded check: + +```sh +cd workers +npm run d1:migrate:local +npx wrangler d1 execute LICENSE_DB --local --command \ + "INSERT OR REPLACE INTO licenses (lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, issued_at, expires_at, cancelled_at) VALUES ('lic_local_refresh', 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', 'pro-monthly', 'cus_local', 'sub_test_from_stripe', unixepoch(), unixepoch() + 3600, NULL)" +npm run dev -- --port 8787 +curl -i -X POST http://localhost:8787/license/refresh \ + -H 'content-type: application/json' \ + --data '{"lid":"lic_local_refresh"}' +``` + +The local seeded check requires `STRIPE_API_KEY` to retrieve the test-mode +subscription. This environment has no Stripe API key, so the seeded live Stripe +integration was skipped and recorded as blocked in `FLEET-LOG-BACKEND.md`. The +Vitest suite verifies active, trialing, cancelled, unknown, rate-limited, CORS, +and method-guard behavior against local D1 with mocked Stripe responses. + +## Telemetry Endpoint + +`POST https://api.atlas-ai.au/telemetry` accepts exact JSON with +`install_id`, `event`, `version`, and `os`. Valid events are `install`, +`atlas_invoked`, `reach_execute`, and `ship_check_ok`. Valid requests return +`204`; invalid schema returns `400`; non-POST methods return `405`. + +Telemetry storage has two sinks: + +- D1 table `telemetry_events`, the primary store for exact KPI queries. +- Workers Analytics Engine binding `TELEMETRY`, used for cheap event counters. + +Run local migrations and tests: + +```sh +cd workers +npm run d1:migrate:local +npm test -- test/telemetry.test.ts +``` + +KPI SQL and smoke-test curls are documented in +`docs/ops/telemetry-queries.md`. Production deployment and public smoke testing +were intentionally skipped in this environment per the mission guardrail; the +blocked item is recorded in `FLEET-LOG-BACKEND.md`. + +## D1 + +The Worker binds D1 as `LICENSE_DB`. Local migrations are safe to run with: + +```sh +cd workers +npm run d1:migrate:local +``` + +Production deployment applies remote D1 migrations before deploying the Worker: + +```sh +./scripts/deploy-worker.sh +``` + +The script refuses to run when `workers/.dev.vars` exists so local secrets cannot +accidentally sit in the deployment context. For build validation without a live +deploy, run: + +```sh +DRY_RUN=1 ./scripts/deploy-worker.sh +``` + +The public custom-domain routes are: + +- `https://api.atlas-ai.au/stripe/webhook` +- `https://api.atlas-ai.au/license/refresh` +- `https://api.atlas-ai.au/telemetry` + +## Secret Scanning + +Worker-specific secret scanning lives in `.github/workflows/worker-secret-scan.yml` +and checks `workers/**`, `scripts/deploy-worker.sh`, and the workflow file for +Stripe, Resend, and Ed25519 private-key material. Run the same local check with: + +```sh +stripe_key='sk_(live|test)_' +stripe_webhook='wh''sec_[A-Za-z0-9]+' +resend_key='re_[A-Za-z0-9]{20,}' +pem_key='-----BEGIN [A-Z ]*PRIVATE KEY-----' +ed_key='ED25519_PRIVATE_KEY=[0-9a-fA-F]{64}' +secret_pattern="(${stripe_key}|${stripe_webhook}|${resend_key}|${pem_key}|${ed_key})" +git ls-files workers scripts/deploy-worker.sh .github/workflows/worker-secret-scan.yml \ + | xargs grep -nE "${secret_pattern}" +``` + +The command should exit non-zero with no matches. diff --git a/docs/product/LICENSE-KEY-SPEC.md b/docs/product/LICENSE-KEY-SPEC.md new file mode 100644 index 0000000..16e6830 --- /dev/null +++ b/docs/product/LICENSE-KEY-SPEC.md @@ -0,0 +1,98 @@ +# Atlas License Key Specification v1 + +Atlas Pro license keys are offline-verifiable Ed25519 signatures over a small +canonical JSON payload. The private signing key must live only in Worker secrets +or a local signing environment outside the repository. The runtime client ships +only the embedded public key used to verify keys. + +## Grammar + +``` +license-key = "ATLAS-" base64url(payload) "." base64url(sig) +payload = canonical JSON object bytes +sig = 64-byte Ed25519 signature over payload +``` + +The literal wire format is: + +``` +ATLAS-<base64url(payload)>.<base64url(sig)> +``` + +Both `payload` and `sig` use RFC 4648 URL-safe base64url with `=` padding +stripped. Decoders must reject padded segments and non-base64url characters. + +## Payload Schema + +Payloads are JSON objects with exactly these fields: + +| Field | Type | Meaning | +| --- | --- | --- | +| `lid` | string | License id issued by Atlas billing. | +| `sub` | string | Lowercase SHA-256 hex digest of the UTF-8 email argument. | +| `plan` | string | One of `pro-monthly` or `pro-annual`. | +| `iat` | integer | Issued-at Unix timestamp in seconds. | +| `exp` | integer | Expiry Unix timestamp in seconds, including grace. | +| `v` | integer | Format version. Must be `1`. | + +`exp` is not the raw billing period end. It is the billing period end plus 14 days +of grace, so offline clients only compare `now <= exp`. No second grace window is +applied during verification. + +## Canonical JSON + +The signed payload bytes are canonical JSON: + +- sort object keys lexicographically; +- use compact separators, with no insignificant whitespace; +- encode as UTF-8; +- include only `lid`, `sub`, `plan`, `iat`, `exp`, and `v`. + +For Python this is equivalent to: + +```python +json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") +``` + +Every implementation signs and verifies those canonical JSON bytes, not a +pretty-printed or insertion-ordered representation. + +## Signing And Verification + +The signing algorithm is PureEdDSA Ed25519 as specified by RFC 8032. Private keys +are 32-byte Ed25519 seeds. Public keys are 32 bytes. Signatures are 64 bytes. + +Signing: + +1. Build the payload fields. +2. Serialize the payload as canonical JSON. +3. Sign those bytes with the private Ed25519 seed. +4. Emit `ATLAS-<base64url(payload)>.<base64url(sig)>`. + +Verification: + +1. Decode the prefix, payload segment, and signature segment. +2. Reject non-canonical payload JSON. +3. Validate every payload field and `v == 1`. +4. Verify the Ed25519 signature with the embedded public key. +5. Compare the verifier's current Unix time to `exp`. + +The distributed verifier must embed or otherwise pin the Atlas public key. The +private signing seed is never shipped with the CLI, client, docs, tests, or repo. + +## Cross-Implementation Contract + +The committed fixture at `tests/license/test_vectors.json` is the compatibility +contract for Python CLI signers and Worker TypeScript signers. Any signer that +claims v1 compatibility must reproduce every reproducible fixture key +byte-for-byte from the fixture payloads. + +The fixture classes are: + +- `valid`: signature verifies and `now <= exp`; +- `expired`: signature verifies but `now > exp`; +- `tampered-payload`: payload bytes were changed after signing; +- `signature-mismatch`: signature was produced by a different key. + +The deterministic fixture seed is test-only and non-secret. Production private +key material must be provisioned as Worker secrets or local signing env state. diff --git a/docs/product/PRODUCT-SPEC.md b/docs/product/PRODUCT-SPEC.md new file mode 100644 index 0000000..0b8f890 --- /dev/null +++ b/docs/product/PRODUCT-SPEC.md @@ -0,0 +1,435 @@ +# Product Specification — `prd-taskmaster` v4.0.0 + +> Open-core "goal → validated PRD → executable task graph → CDD-gated execution" engine for +> Claude Code. Repo `anombyte93/prd-taskmaster` (508★, name retained). Free MIT engine + +> commercial **Atlas Pro**. This document is the contract the dogfood `ship-check` verifies +> against — it is a living doc, updated as the product evolves. + +**Codebase basis:** 5-phase state machine `SETUP→DISCOVER→GENERATE→HANDOFF→EXECUTE` +(`prd_taskmaster/pipeline.py`), FastMCP tools (`mcp-server/server.py`), deterministic PRD +checks with EXCELLENT/GOOD/ACCEPTABLE/NEEDS_WORK grading (`prd_taskmaster/validation.py`), +capability/mode detection (`prd_taskmaster/capabilities.py`), atomic flock-guarded state +(`prd_taskmaster/lib.py`), a CDD execute-task loop (`skills/execute-task/SKILL.md`), and a +deterministic 5-gate ship-check emitting `SHIP_CHECK_OK` (`skel/ship-check.py`). The paid +fleet is the `atlas-launcher` MCP (worktree-isolated tmux workers, dependency-wave dispatch, +SQLite inbox collection, integration-branch merge). + +--- + +## 1. Personas + +### P1 — Indie Ivy (Solo Indie Developer) +- **Goals:** Ship side-projects/MVPs fast; turn a rough idea into shippable code without + writing a spec by hand; avoid ad-hoc prompting that drifts. +- **Pains today:** Claude Code sessions lose the thread; no structure between "idea" and + "code"; verification is vibes-based; re-prompts cost time and money. +- **Lands on:** FREE. Solo single-session execution + free research is enough. +- **Willingness to pay:** Low ($0–9/mo). Converts only when builds are big enough to want + overnight parallelism. + +### P2 — Lead Theo (Small-Team Tech Lead, 2–6 devs) +- **Goals:** Standardize idea→PRD→tasks; enforce quality gates so junior output is verifiable; + ship features overnight; reduce review thrash. +- **Pains today:** Inconsistent specs; no shared "definition of done"; can't parallelize agent + work safely (tasks.json corruption, merge conflicts); no audit trail. +- **Lands on:** PAID Atlas Pro. Dependency-wave fleet + git-worktree isolation + durable inbox + audit trail directly solve his parallelism and accountability pain. +- **Willingness to pay:** High ($29–99/mo per seat). + +### P3 — Orchestrator Olu (AI Power-User / Agent Orchestrator) +- **Goals:** Run fleets of parallel agents across many sessions; walk away and return to proof; + squeeze cost via model-agnostic dispatch (claude/codex/gemini). +- **Pains today:** DIY tmux orchestration is brittle; spawned sessions silently fail to receive + their task; no durable wave-state; no deterministic completion token they can trust. +- **Lands on:** PAID Atlas Pro (heaviest user). Buys for `atlas-launcher`, wave scheduling, + bundled MCPs. +- **Willingness to pay:** Highest ($29–149/mo); evangelizes. Primary churn risk if fleet + reliability slips. + +### P4 — Contributor Cam (OSS Contributor / Evaluator) +- **Goals:** Evaluate, file issues/PRs, fork, learn the architecture. +- **Pains today:** Closed black-box AI tools; abandoned repos; zero-test plugins. +- **Lands on:** FREE (MIT). Drives stars, credibility, top-of-funnel. +- **Willingness to pay:** Near-zero, high strategic value. + +--- + +## 2. KPIs / Success Metrics + +Targets: **[v4.0.0 launch] → [+6 months]**. + +### Adoption (FREE) +| KPI | Definition | Target | +|---|---|---| +| A1 Installs | Cumulative npm/plugin + curl installs (postinstall ping, opt-out) | 2,000 → 12,000 | +| A2 Stars growth | Net new GitHub stars | +250 (758★) → +1,500 (≈2,000★) | +| A3 WAI | Distinct installs invoking `/atlas` ≥1×/week | 400 → 2,500 | +| A4 Skill-vs-plugin split | % invocations via zero-dep skill vs FastMCP plugin | ≥40% plugin | + +### Activation +| KPI | Definition | Target | +|---|---|---| +| AC1 Full-run completion | % of pipelines reaching EXECUTE with ≥1 task done | 35% → 55% | +| AC2 Reach-execution | % advancing past HANDOFF | 50% → 70% | +| AC3 PRD validation pass | % of PRDs grade ≥ GOOD on first validate | 60% → 80% | +| AC4 Time-to-first-tasks.json | Median wall-clock /atlas → parsed tasks.json | ≤15 min → ≤8 min | + +### Conversion (PAID) +| KPI | Definition | Target | +|---|---|---| +| C1 Free→Pro | % monthly active free users starting a subscription | 1.5% → 4% | +| C2 Trial→paid | % trials converting to a paying month | 25% → 40% | +| C3 MRR | Monthly recurring revenue at ~$29/mo | $1.5k → $12k | +| C4 Monthly churn | % paid cancellations / month | <8% → <5% | +| C5 Fleet runs/user | Mean fleet runs per paid user / month | 4 → 12 | + +### Quality / Trust +| KPI | Definition | Target | +|---|---|---| +| Q1 Task completion | % parsed tasks reaching done within a run | 70% → 88% | +| Q2 SHIP_CHECK_OK rate | % EXECUTE runs emitting the deterministic token (5 gates pass) | 45% → 70% | +| Q3 Rework rate | % done tasks reopened/blocked within same run (false-done proxy) | <20% → <10% | +| Q4 NPS | 0–10 promoter score, in-CLI + post-run | +20 → +40 | + +> Q2/Q3 are the trust differentiators — the deterministic `SHIP_CHECK_OK` (Gate 5 forbids any +> non-zero `Exit status N` in evidence) is the marketing-grade proof that completion is real, +> not narrated. Instrument it as the headline reliability metric. + +--- + +## 3. User Stories (by Epic) + +### Epic: Discovery +- **US-D1 [FREE]** As Indie Ivy, I want adaptive one-question-at-a-time discovery, so that I + don't write a spec from a blank page. + - AC: `discover` invokes `superpowers:brainstorming` (interactive) or self-brainstorms + (autonomous); emits `CONSTRAINTS CAPTURED`; classifies scale Solo/Team/Enterprise; gate + requires `user_approved` OR `auto_classification=CLEAR` + `assumptions_documented`. +- **US-D2 [FREE]** As Orchestrator Olu, I want autonomous discovery in unattended sessions, so + that fleet/cron runs don't stall on a modal prompt. + - AC: autonomous mode never blocks; writes + commits a discovery file as audit trail. + +### Epic: PRD Generation +- **US-G1 [FREE]** As any user, I want my PRD scored against deterministic checks with a letter + grade, so that I know it's testable before I build. + - AC: `validate_prd` runs the checks; grades EXCELLENT≥91 / GOOD≥83 / ACCEPTABLE≥75 / + NEEDS_WORK<75; bare placeholders = hard fail; vague-language penalty. +- **US-G2 [FREE]** As Lead Theo, I want deferred decisions allowed when attributed (`reason:`), + so that a thin spec is honest rather than blocked. +- **US-G3 [FREE]** As any user, I want every task expanded into subtasks before handoff. + - AC: GENERATE gate requires `task_count>0` AND `subtask_coverage==1.0`; serial + `task-master expand --all` (never parallel per-id — data-loss race). + +### Epic: Task Planning +- **US-T1 [FREE]** As any user, I want task count calibrated to project scale. + - AC: `calc_tasks` → Solo 8–12 / Team 12–20 / Enterprise 20–30. +- **US-T2 [FREE]** As any user, I want TaskMaster-native complexity scoring so dispatch can tier + the model per task. + - AC: complexity read from `.taskmaster/reports/task-complexity-report.json`, not stdout. + +### Epic: Solo Execution +- **US-E1 [FREE]** As Indie Ivy, I want a CDD-gated single-session loop where each task is + verified by evidence before done. + - AC: 13-step `execute-task` cycle; complexity-tiered subagent dispatch; triple-verify + hard + exit-code gate; one task pending→done per iteration. +- **US-E2 [FREE]** As any user, I want a deterministic completion token, so "done" is provable. + - AC: `SHIP_CHECK_OK` emitted only when all 5 gates pass; `/sync` first; appears exactly once. +- **US-E3 [FREE]** As any user, I want a crashed pipeline to resume. + - AC: `preflight` recommends resume/parse/generate/setup from on-disk state; every phase skill + re-reads `current_phase()` on entry. + +### Epic: Parallel Fleet (PRO) +- **US-F1 [PRO]** As Lead Theo, I want my task graph dispatched as dependency waves of + file-disjoint chunks across many sessions, so the build finishes overnight. +- **US-F2 [PRO]** As Orchestrator Olu, I want durable result collection + verified handoff, so a + worker that silently failed to receive its task is caught. + - AC: every spawn verifies `prompt_injected`; results collected via SQLite inbox, not the + worker's word. +- **US-F3 [PRO]** As Lead Theo, I want git-worktree isolation + sequential integration so + parallel writers never corrupt `tasks.json` or hide merge conflicts. +- **US-F4 [PRO]** As Orchestrator Olu, I want model-agnostic backends (claude/codex/gemini). + +### Epic: Monetization / Licensing +- **US-M1 [PRO]** As a paying user, I want a license key that unlocks the fleet installer. +- **US-M2 [PRO]** As a paying user offline, I want a grace period so a flaky network doesn't + brick my fleet mid-run. FREE engine never degrades. +- **US-M3 [FREE]** As a free user, I want a non-intrusive Pro teaser at handoff. + - AC: HANDOFF shows "Atlas Fleet (Pro)" as a non-selectable teaser with a checkout URL; + selecting it returns the upgrade path and re-prompts free modes. + +### Epic: Onboarding +- **US-O1 [FREE]** As Indie Ivy, I want a one-line install + first run. + - AC: curl-install or `npm i -g`; `setup` detects task-master, DETECT-FIRST provider config + (never overwrites working config), scaffolds `.atlas-ai/`. +- **US-O2 [FREE]** As any user, I want the engine to recommend solo vs fleet from what I have + installed. + +--- + +## 4. Use Cases + +### UC-1 — First-time install & first goal *(FREE)* +Primary: Indie Ivy. Pre: Claude Code installed; Node/Python present. +Main: Install → `/atlas` → preflight+current_phase route to setup → SETUP validates/configures, +scaffolds `.atlas-ai/` → DISCOVER brainstorm → GENERATE writes+validates PRD (≥ACCEPTABLE) → +parses tasks.json, expands subtasks → HANDOFF recommends a solo mode → user picks → EXECUTE +loop to `SHIP_CHECK_OK`. +Alt: task-master missing → report install cmd + halt (never auto-install). Config present → +DETECT-FIRST skips. Grade NEEDS_WORK → fix-loop (UC-4). + +### UC-2 — Resuming a crashed pipeline *(FREE)* +Pre: `.atlas-ai/state/pipeline.json` + `.taskmaster/` exist. +Main: `/atlas` → preflight reads state → recommends resume → routes to recorded phase → +execute-task resumes at next ready task. +Alt: PRD exists, 0 tasks → parse_prd. Deadlock → report + halt. Bookkeeping drift >5 tasks → +halt, do not silently reconcile. + +### UC-3 — Parallel fleet build *(PRO)* +Pre: valid Atlas Pro license; `atlas-launcher` alive; git repo with committed PRD+tasks. +Main: provision deps → recon → file-disjoint chunks → checker approves → commit base, +`session_spawn` per chunk, verify+kick → monitor (inbox-first) → merge wave branches +sequentially into integration branch, checker green-gates → next wave → final single PR → +`SHIP_CHECK_OK`. +Alt: `prompt_injected:false` → kick. Not file-disjoint → re-partition. Worker blocked → logged +via inbox, never faked. No launcher → fall back to FREE solo EXECUTE. + +### UC-4 — PRD validation failure → fix loop *(FREE)* +Main: `validate_prd` → NEEDS_WORK or bare_placeholders>0 → surface failed checks + vague terms → +revise → re-validate → on ≥GOOD + 0 bare placeholders, gate passes. +Alt: ACCEPTABLE → offer proceed-with-risk vs auto-fix (never silently advance). + +### UC-5 — Subscribing & license activation *(PRO)* +Main: teaser at HANDOFF → checkout URL → pay (Stripe) → license key → run installer with key → +validate online → provision atlas-launcher + bundled MCPs → next `/atlas` HANDOFF offers fleet +as selectable. +Alt: invalid/expired key → installer refuses, gated artifacts never fetched. Network failure → +retry with backoff; FREE engine remains functional throughout. + +### UC-6 — License lapse / offline grace *(PRO)* +Main: fleet dispatch checks cached token → within grace → proceeds → background re-validation +when network returns. +Alt: grace expired → fleet refuses new dispatch with renewal prompt; in-flight run drains to +completion (no mid-run brick); FREE solo unaffected. Cancelled → locks at next dispatch. + +### UC-7 — Handoff mode recommendation *(FREE)* +Main: `detect_capabilities` → recommends ONE mode → AskUserQuestion picker (recommended default) +→ dispatch. +Alt: AskUserQuestion hook-blocked → prose fallback table + `[AI]` insight block. Fleet teaser +shown but unlicensed → returns upgrade path, re-prompts free modes. + +--- + +## 5. User Flows + +### (a) Free quickstart: curl-install → first tasks.json +1. Run curl one-liner (or `npm i -g prd-taskmaster`); postinstall installs Python MCP deps + (warns, never hard-fails). +2. `/atlas` → preflight + current_phase → null → route setup. +3. SETUP: `which task-master-ai` (missing → print install cmd + halt) → `.taskmaster/` init → + DETECT-FIRST provider config → scaffold `.atlas-ai/` + ship-check → advance SETUP→DISCOVER. +4. DISCOVER: interactive brainstorm → CONSTRAINTS CAPTURED + scale → approve → advance. +5. GENERATE: load template → fill PRD → CONSTRAINT + SCOPE check → validate_prd (NEEDS_WORK → + fix-loop) → calc_tasks → parse_prd → analyze-complexity → expand --all → coverage 1.0 → + tasks.json exists → advance GENERATE→HANDOFF. +6. Outcome: `.taskmaster/tasks/tasks.json` populated. *(median target ≤8 min)* + +### (b) Handoff mode selection (solo vs fleet) +1. `detect_capabilities()` → per-tool flags + tier + recommended mode + alternatives. +2. Decision (first match): superpowers+ralph-loop → Verified Loop; superpowers only → Plan & + Drive; task-master only → Auto-Execute; fallback → Plan & Drive. +3. License branch: valid license + launcher → Atlas Fleet selectable; else non-selectable teaser + with checkout URL. +4. Append idempotent task-workflow block to CLAUDE.md (HTML-comment sentinels, timestamped + backup). +5. AskUserQuestion picker, recommended = default (hook-blocked → prose fallback + `[AI]`). +6. Dispatch chosen mode. Selecting teaser unlicensed → upgrade path + re-prompt. + +### (c) Upgrade-to-Pro +1. Free user hits Fleet teaser at HANDOFF (or an inline "fleet would finish 4× faster" hint when + task count/complexity is high). +2. Checkout URL → Stripe → pay → license key. +3. Run installer with key → online validation (invalid → refuse, fetch nothing) → cache signed + offline-grace token → provision launcher + bundled MCPs. +4. Next `/atlas` → tier=premium → HANDOFF offers Atlas Fleet as selectable. +5. Outcome: fleet unlocked; free engine unchanged. + +### (d) Fleet execution run *(PRO)* +1. Wave dispatch: provision (PRD+tasks, git base committed, launcher alive) → partition into + file-disjoint chunks → topo-order into waves → checker approves (overlap → re-partition). +2. Spawn: commit base → `session_spawn(isolation="worktree")` (or `session_create` for + codex/gemini) per chunk → verify `prompt_injected` (false → kick + confirm). +3. Worker CDD loop: each worker runs the 13-step execute-task cycle in its worktree on its own + branch. +4. Result collection: monitor (inbox-first) → done/blocked (blocked → log, skip-not-fabricate; + dead worker → bounded retry then escalate). +5. Merge: integrate wave branches sequentially into integration branch → checker green-gates + (red → halt, surface) → advance until current_wave > total. +6. Ship: final single PR; ship-check 5 gates → `SHIP_CHECK_OK`; main never auto-touched. + +--- + +## 6. Functional Requirements + +Tags: **[FREE]/[PRO]**, phase. + +**Engine / Orchestration** +- **FR-1 [FREE]** The orchestrator MUST route purely on `current_phase()`+`preflight()` and + re-check after each phase, holding no procedure itself. +- **FR-2 [FREE]** Phase transitions MUST be atomic CAS (`advance_phase(expected,target,evidence)`) + over flock-guarded `pipeline.json`; a stale `expected` MUST error, not write. +- **FR-3 [FREE]** Only `None→SETUP→DISCOVER→GENERATE→HANDOFF→EXECUTE` MUST be permitted; illegal + transitions rejected with the legal set. +- **FR-4 [FREE]** Every phase skill MUST be resumable by re-reading `current_phase()` on entry. + +**SETUP** +- **FR-5 [FREE]** SETUP MUST run 6 checks (binary, version ≥0.43.0, project, config, main + provider, research provider) with per-check pass/fail + fix command; version/research are + warnings. +- **FR-6 [FREE]** Provider config MUST be DETECT-FIRST: never overwrite a working config; only + fill empty roles; ask before mutating an unsupported provider. +- **FR-7 [FREE]** SETUP MUST scaffold `.atlas-ai/customizations/` + `.atlas-ai/ship-check.py` + before EXECUTE. MUST NOT auto-install task-master. + +**DISCOVER** +- **FR-8 [FREE]** Interactive MUST use `superpowers:brainstorming` and intercept before it chains + to `writing-plans`; autonomous MUST NOT block and MUST write a committed discovery file. +- **FR-9 [FREE]** DISCOVER MUST emit `CONSTRAINTS CAPTURED` + scale; gate requires `user_approved` + OR (`auto_classification=CLEAR` + `assumptions_documented`). + +**GENERATE / Validation** +- **FR-10 [FREE]** `validate_prd` MUST run the deterministic checks and grade EXCELLENT≥91 / + GOOD≥83 / ACCEPTABLE≥75 / NEEDS_WORK<75 with the vague-language penalty. +- **FR-11 [FREE]** Bare placeholders (`{{}}`,`[TBD]`,`[TODO]`,`[FIXME]`,`[PLACEHOLDER]` without + `reason:`/`defer:`) MUST be a hard fail; attributed placeholders recorded as + `deferred_decisions`. +- **FR-12 [FREE]** GENERATE gate MUST require grade ∈ {EXCELLENT,GOOD}, `task_count>0`, + `subtask_coverage==1.0`. Subtask expansion MUST be serial `task-master expand --all`. +- **FR-13 [FREE]** Complexity MUST be read from the report JSON, never the stdout table. + `calc_tasks` MUST calibrate count to scale. + +**Capability detection / HANDOFF** +- **FR-14 [FREE]** `detect_capabilities` MUST scan Claude plugins, skills, external CLIs, and + taskmaster (MCP>CLI>none), and recommend ONE mode with a reason. +- **FR-15 [FREE]** HANDOFF MUST present mode choice via AskUserQuestion with recommended default; + hook-blocked → prose table + `[AI]` block. MUST append the task-workflow block to CLAUDE.md + idempotently. +- **FR-16 [PRO]** With a valid license + launcher detected, Atlas Fleet MUST be selectable; + otherwise a non-selectable teaser with a checkout URL that MUST NOT execute even if selected. + +**EXECUTE / Ship gate** +- **FR-17 [FREE]** `execute-task` MUST run the 13-step CDD cycle, dispatching an implementer + subagent (never in-session) tiered by complexity, accepting only DONE / DONE_WITH_CONCERNS / + NEEDS_CONTEXT / BLOCKED. +- **FR-18 [FREE]** A hard exit-code gate (`ship-check --dry-run`) MUST fail any task whose + evidence contains a non-zero `Exit status N`; override only via `SHIP_CHECK_OVERRIDE_ADMIN`, + audit-logged. +- **FR-19 [FREE]** On done, parent task + evidenced subtasks + pipeline.json per-task state MUST + be written consistently (no divergence). +- **FR-20 [FREE]** Termination MUST be strict: ship-check exit 0 → `/sync` → emit + `SHIP_CHECK_OK` exactly once. 5 gates: phase==EXECUTE; all tasks done; CDD card per task; plan + file present; no non-zero exit in evidence. + +**Parallel fleet (PRO)** +- **FR-21 [PRO]** The fleet MUST partition tasks into file-disjoint chunks, topo-order into + waves, and gate the split via a partition checker before dispatch. +- **FR-22 [PRO]** Each chunk MUST run in an isolated worktree on a committed base; spawns MUST + verify `prompt_injected` and kick on false; results MUST be collected via the durable inbox. +- **FR-23 [PRO]** Waves MUST integrate sequentially into an integration branch, each green-gated; + main MUST never be auto-modified; completion produces one final PR + `SHIP_CHECK_OK`. +- **FR-24 [PRO]** Fleet backends MUST be model-agnostic (claude via `session_spawn`; + codex/gemini via `session_create`). + +**Licensing / Installer** +- **FR-25 [PRO]** A license key MUST be validated online before any gated artifact is fetched; + non-subscribers MUST never receive the gated payload or private-repo access. +- **FR-26 [PRO]** The installer MUST provision atlas-launcher + bundled MCPs only on valid + license. +- **FR-27 [PRO]** A validated license MUST cache a signed offline-grace token; after grace, fleet + dispatch MUST refuse new runs but allow in-flight runs to drain; the FREE engine MUST remain + fully functional regardless of license state. + +**Cross-cutting** +- **FR-28 [FREE]** All MCP tools MUST return dicts and MUST NEVER call `sys.exit` / terminate the + host process. + +--- + +## 7. Non-Functional Requirements + +**Performance** +- **NFR-1** `validate_prd` MUST complete <500 ms for a ≤5k-word PRD (pure regex, no network). +- **NFR-2** Deterministic state ops MUST complete <100 ms. +- **NFR-3 [PRO]** Wave scheduling MUST complete <60 s for a ≤30-task graph. +- **NFR-4** Capability detection MUST complete <2 s (5 s subprocess timeout cap per probe). + +**Reliability** +- **NFR-5** Phase transitions MUST be atomic via flock + tmp-write + `os.replace`; concurrent + writers MUST NOT corrupt pipeline.json. +- **NFR-6 [PRO]** Under parallel fleet, tasks.json MUST NOT be corrupted — file-disjoint chunking + + serial integration + sole-writer orchestrator. +- **NFR-7** `SHIP_CHECK_OK` MUST be emitted only when all 5 gates pass and exactly once. +- **NFR-8 [PRO]** A dead/silent worker MUST be detected (bounded retries) and surface a blocker. + +**Security** +- **NFR-9 [PRO]** License keys MUST be stored securely (chmod 600 / keychain), never logged or + committed. +- **NFR-10 [PRO]** Bundled-MCP secrets MUST never be logged or committed. +- **NFR-11 [PRO]** Private fleet assets MUST NOT leak to non-subscribers; gated artifacts fetched + only after server-side validation. +- **NFR-12 [FREE]** The FREE tier MUST require no paid API key. + +**Portability** +- **NFR-13** MUST run on Linux, macOS, WSL. `fcntl.flock` is POSIX-only — native Windows + unsupported for the atomic-state path; document and detect, do not silently misbehave. + +**Compatibility** +- **NFR-14** MUST support `task-master-ai >= 0.43.0` and degrade gracefully (warning) on + older/unparseable versions. +- **NFR-15** MUST conform to the Claude Code plugin API (FastMCP, hooks.json, skills, + marketplace/plugin.json) and tolerate AskUserQuestion being hook-blocked. + +**Maintainability** +- **NFR-16** Deterministic logic MUST have a single source of truth shared by both the zero-dep + skill and the FastMCP plugin — no divergent re-implementations. +- **NFR-17** Test coverage MUST gate releases; string-shape assertions MUST be backed by + runtime-resolution checks. + +**Cost** +- **NFR-18 [FREE]** A complete free run MUST be achievable at $0 marginal API cost using the + default free provider stack. +- **NFR-19 [PRO]** Complexity-tiered dispatch MUST keep dollars-per-task sane. + +--- + +## 8. Narrative — "What the final product looks like" + +`prd-taskmaster` is the open-core engine that turns *"I want to build X"* into shipped, +**verified** code inside Claude Code — replacing ad-hoc prompting with a structured, auditable +pipeline. + +A developer installs it free (MIT) from the 508★ repo and types `/atlas`. The engine walks a +five-phase state machine with atomic, flock-guarded transitions: **SETUP** detects and configures +the environment without overwriting working config; **DISCOVER** runs adaptive brainstorming (or +self-brainstorms unattended) and captures constraints + scale; **GENERATE** writes a PRD, scores +it against deterministic quality checks with a letter grade — bare placeholders are a hard fail, +attributed deferrals are honest — then parses it into a TaskMaster task graph with complexity +scores and full subtask coverage; **HANDOFF** detects what the user has installed and recommends +one execution mode through a structured picker; **EXECUTE** runs a CDD-gated loop where every task +is implemented by a complexity-tiered subagent and proven by evidence, culminating in a +deterministic `SHIP_CHECK_OK` token that is structurally impossible to fake (a non-zero exit code +in any evidence file blocks it). The whole free run costs $0 in API spend and works on +Linux/macOS/WSL. + +The free engine is genuinely complete and useful on its own — and it is the funnel. At the +handoff, power users see **Atlas Pro** ($29/mo): **Atlas Fleet**, which lifts the same engine from +one session to many. A license-gated installer provisions the `atlas-launcher` daemon and bundled +MCPs. Fleet partitions the task graph into file-disjoint chunks, schedules them in dependency +waves, spawns model-agnostic tmux workers (claude/codex/gemini) in isolated git worktrees, +collects results through a durable inbox rather than trusting an agent's word, and merges waves +sequentially into an integration branch behind a checker gate — never touching `main` until one +final green PR. Licenses validate online with an offline grace window so a flaky network never +bricks a run, secrets never hit logs, and nothing private leaks to non-subscribers. + +The result is a single coherent promise across both tiers: **you can answer, at every moment, "is +what I built what I wanted?"** — free for solo builders, and at fleet scale for teams and +orchestrators who want to walk away and come back to proof. diff --git a/docs/product/UX-SPEC.md b/docs/product/UX-SPEC.md new file mode 100644 index 0000000..8f34c87 --- /dev/null +++ b/docs/product/UX-SPEC.md @@ -0,0 +1,389 @@ +# UX & Product-Presentation Specification — Atlas / `prd-taskmaster` + +> Surfaces: terminal interaction (Claude Code skill), GitHub README storefront, +> atlas-ai.au/pro pricing page, install/upgrade flows. Living doc — the canonical vocabulary +> here (§6) governs every user-facing string in the codebase. + +## 0. Design principles + +1. **One screen, one decision.** Every phase ends in exactly one structured choice + (AskUserQuestion). Never present >4 options. +2. **Status blocks have a fixed grammar.** Same box style, symbol set, and order + (what happened → evidence → next step) in every phase. +3. **Evidence over assertion.** Never print "validated ✓" without the number, grade, and what + failed. The product's pitch is verification — its own output must model it. +4. **The paywall is a fact, not a trap.** Locked features show real value computed from *this* + project, state the price inline, never default-select, never re-prompt. +5. **MIT-free-forever is the trust anchor.** Every commercial surface repeats: the engine is + free, open, model-agnostic; Pro adds orchestration + bundled MCPs. +6. **Junior-comprehensible, zero system jargon.** No "phoenix", "CDD", "ralph-loop", or "Mode D" + as a primary label in anything a user reads. +7. **Monochrome-first.** Every state = symbol + word. Color and emoji are enhancement only. + +--- + +## 1. Terminal interaction design + +### Phase shape (all phases) +``` +┌─ atlas ── PHASE n/4: NAME ─────────────────────────┐ ← phase header bar (always n/4) +│ ...status lines / question / result │ ← body +└─────────────────────────────────────────────────────┘ + Next: <one sentence> ← single next step +``` +Gates surface as `Gate passed:` / `Gate blocked:` lines inside the body — never raw JSON. + +### Moment 1 — Preflight (zero-config but legible) +``` +┌─ atlas ── PHASE 1/4: PREFLIGHT ────────────────────────────┐ +│ ✓ TaskMaster MCP │ +│ ✓ AI provider Claude Code (sonnet) │ +│ ✓ Research Perplexity (local, free) │ +│ ○ Browser verify not installed — optional │ +│ │ +│ Gate passed: environment ready, nothing to configure. │ +└────────────────────────────────────────────────────────────┘ + Next: a short interview about your goal — one question at a time. +``` +Missing *optional* capabilities use `○ … — optional`, never `✗`. `✗` is reserved for blockers: +``` +│ ✗ TaskMaster not found — required │ +│ Gate blocked: TaskMaster is the task engine. │ +│ Fix: npm install -g task-master-ai (~30s) │ +│ Or: say "manual" and I'll do its job by hand. │ +``` + +### Moment 2 — Discovery (the interview) +``` + Discovery 3 of ~6 — scope + ───────────────────────── + So far: CLI tool · for your own team · must stay Python-only + + Should v1 handle concurrent runs, or is single-user fine? + (Single-user is the simpler bet — say "simpler" to take defaults + for the rest and jump to the spec.) +``` +`3 of ~6` (the `~` is honest about adaptivity); live "So far:" constraint accumulation; a +standing **"simpler" exit ramp**. Approval gate ends with a structured picker: +**Approve / Refine / Start over**. + +### Moment 3 — Generate + Validate (the scorecard, the signature moment) +``` +┌─ atlas ── PHASE 3/4: GENERATE ─────────────────────────────┐ +│ Spec written .taskmaster/docs/prd.md (2,140 words) │ +│ │ +│ VALIDATION ─────────────────────────────────────────── │ +│ Grade: GOOD ▰▰▰▰▰▰▰▰▱▱ 49/57 (86%) │ +│ │ +│ ✓ 11 checks passed structure · testability · metrics │ +│ ⚠ 2 warnings │ +│ W1 REQ-007 says "fast" — no measurable threshold │ +│ W2 success metric has no baseline number │ +│ ✓ 0 placeholders (TBD/TODO/{{...}} scan clean) │ +│ │ +│ Gate: GOOD or better required — passed. │ +│ │ +│ Tasks: 14 parsed · 52 subtasks · dependencies mapped │ +│ Complexity: 3 complex · 8 medium · 3 simple │ +└────────────────────────────────────────────────────────────┘ + Fix the 2 warnings (≈1 min) or continue to handoff? + ▸ Fix warnings, then continue (recommended) + ▸ Continue as-is + ▸ Show me the spec first +``` +Grade word + bar + score + percent; the four-grade scale prints with its thresholds the first +time it appears. Warnings are *quoted and located*, never just counted. The placeholder scan is +named explicitly (it's a differentiator). + +### Fleet wave dispatch status (Pro, the walk-away view) +``` +┌─ atlas fleet ── wave 2 of 3 ──────────────── ▶ running 12m ┐ +│ wave 1 ✓ merged 3 tasks · 18m · integration green │ +│ wave 2 ▶ running │ +│ claude-1 task 6 API endpoints ▰▰▰▱ 3/4 │ +│ codex-1 task 7 UI components ▰▰▱▱ 2/4 │ +│ claude-2 task 9 DB migrations ✓ done — waiting │ +│ wave 3 ○ queued 4 tasks · starts when wave 2 merges │ +│ │ +│ Gate: a wave merges only after the checker approves it │ +│ and the integration branch builds green. │ +│ │ +│ watch: atlas fleet status logs: .atlas-ai/fleet/ │ +└────────────────────────────────────────────────────────────┘ +``` +Workers named by model + index (`claude-1`, `codex-1`) — the model-agnostic story is visible in +the UI. Never "phoenix" in user-facing output. The gate is restated in plain English on every +render — the trust line that justifies "walk away". + +--- + +## 2. The paywall / upgrade moment + +### Anti-dark-pattern contract +1. Fleet appears once per handoff, in the mode list, never as an interstitial. +2. The recommended free mode is the default selection. Fleet is never default. +3. Price is inline at the lock — no "find out" click. +4. The teaser shows a personalized projection computed from this project's real task graph + (wave count, time estimate), labelled an estimate. +5. Selecting Fleet while locked = one informative response + immediate re-prompt with free + modes. Never re-teased in the same session. +6. The free path is never degraded to make Fleet look better, and the copy says so. + +### Locked handoff screen +``` +┌─ atlas ── PHASE 4/4: HANDOFF ──────────────────────────────┐ +│ Spec: GOOD (86%) · 14 tasks · 52 subtasks │ +│ │ +│ How should these tasks get executed? │ +│ │ +│ ▸ Verified Loop ✓ recommended │ +│ One session works the graph task-by-task. Every task │ +│ must show execution evidence before it counts as done. │ +│ Est. ~6h 30m supervised-ish. │ +│ │ +│ Auto-Execute TaskMaster's native loop — faster, │ +│ lighter verification. │ +│ Plan & Drive Get the plan, implement it yourself.│ +│ │ +│ ────────────────────────────────────────────────────── │ +│ Atlas Fleet 🔒 Pro $29/mo │ +│ Your 14 tasks split into 3 parallel waves across │ +│ isolated worktrees (claude ×2, codex ×1), checker- │ +│ gated merges, one final PR. │ +│ Est. ~2h 05m wall-clock, walk-away. (estimate from │ +│ your dependency graph — actual time varies) │ +│ Unlock: atlas-ai.au/pro — resumes this exact handoff. │ +└────────────────────────────────────────────────────────────┘ +``` +Free mode's estimate is stated first so the comparison is symmetric. Lock = `🔒` + "Pro" + price, +visually separated below a rule. The honesty parenthetical is mandatory copy. + +**Selecting Fleet while locked:** +``` + Atlas Fleet is part of Atlas Pro ($29/mo). + What you'd get on this project: 3 waves · 3 parallel workers + · checker-gated merges · est. ~2h 05m instead of ~6h 30m. + Unlock at atlas-ai.au/pro — your spec and tasks are saved, + and the installer resumes this handoff. + + Meanwhile, everything below is free forever: + ▸ Verified Loop ✓ recommended + ▸ Auto-Execute + ▸ Plan & Drive +``` + +### Unlocked state +``` +│ ▸ Atlas Fleet ★ Pro · ✓ recommended │ +│ 3 waves · claude ×2, codex ×1 · est. ~2h 05m │ +│ License: ✓ active (renews 2026-07-11) │ +``` +Once licensed, Fleet *becomes* the recommendation when the graph parallelizes (≥2 independent +chains). If the graph is serial, Fleet is **not** recommended and says why — recommending against +the paid feature when it's wrong is the cheapest trust you'll ever buy. + +--- + +## 3. GitHub README storefront + +| # | Section | Contents | +|---|---------|----------| +| 1 | Hero | H1 tagline, value prop, badges (stars · MIT · works-with Claude/Codex/Gemini) | +| 2 | Animated demo | One GIF/asciinema ≤45s above the fold: goal → scorecard → handoff → fleet | +| 3 | How it works | `goal → discovery → graded PRD → task graph → verified execution` + 4 one-liners | +| 4 | Quickstart | Two install paths, 90s to first run | +| 5 | What "verified" means | evidence gates, placeholder scan, grades, scorecard screenshot | +| 6 | Free vs Pro table | (below) | +| 7 | Atlas Fleet (Pro) | 1 paragraph + fleet screenshot + /pro link | +| 8 | Social proof | star history, "508+ developers", quotes when available | +| 9 | FAQ | free forever? open vs closed? need TaskMaster? which models? | +| 10 | Contributing / License | MIT restated; "engine will always be MIT" | + +**Hero copy:** +> # Turn any goal into shipped code. +> **prd-taskmaster** by [Atlas AI](https://atlas-ai.au) is an open-source engine for Claude Code +> that takes a one-line goal, interviews you like a senior PM, writes a **graded, +> placeholder-proof PRD**, compiles it into a **dependency-ordered task graph**, and executes +> every task with **verification evidence** — so "done" means proven, not claimed. +> +> Free and MIT, forever. Works with Claude, Codex, and Gemini. +> ``` +> Grade: GOOD ▰▰▰▰▰▰▰▰▱▱ 49/57 (86%) · 0 placeholders · 14 tasks parsed +> ``` + +**Free vs Atlas Pro:** +| | Free (MIT) | Atlas Pro — $29/mo | +|---|:---:|:---:| +| Discovery interview (adaptive) | ✓ | ✓ | +| Graded PRD validation + placeholder scan | ✓ | ✓ | +| Dependency-ordered task graph (TaskMaster) | ✓ | ✓ | +| Verified solo execution — evidence per task | ✓ | ✓ | +| Model-agnostic: Claude / Codex / Gemini | ✓ | ✓ | +| **Atlas Fleet** — parallel waves, checker-gated merges, one final PR | — | ✓ | +| Browser-verification MCP | — | ✓ | +| Secrets-vault MCP | — | ✓ | +| License & priority support | community | ✓ | + +> The engine is the whole engine — the free tier is not a demo. Pro adds the fleet orchestrator +> and bundled MCPs. [Get Atlas Pro →](https://atlas-ai.au/pro) + +--- + +## 4. Pricing page — atlas-ai.au/pro + +Single column, dev-tool register. One tier, one CTA. +``` + Ship the whole task graph at once. + The prd-taskmaster engine is free and MIT, forever. + Atlas Pro adds the fleet that executes it in parallel. + + ┌───────────────── ATLAS PRO ─────────────────┐ + │ $29 / month per developer │ + │ 14-day refund, cancel anytime │ + │ Everything in Free, plus: │ + │ ✓ Atlas Fleet — parallel multi-session │ + │ execution across Claude, Codex, Gemini │ + │ ✓ Browser-verification MCP │ + │ ✓ Secrets-vault MCP │ + │ ✓ License key + priority support │ + │ [ Get your license key ] │ + │ then: curl …/install-pro | bash │ + └─────────────────────────────────────────────┘ + + WHAT'S OPEN, WHAT'S NOT + Open (MIT, free forever): discovery, PRD validation, task graph, + solo verified execution. 508★ on GitHub — read every line. + Closed (Pro): atlas-launcher fleet orchestrator + bundled MCPs. + Your code never touches our servers — the fleet runs entirely on + your machine; the license key is the only thing that phones home. + + FAQ + ▸ Will the free engine stay free? Yes — MIT, and the engine repo + will never require a key. In writing. + ▸ What happens if I cancel? Fleet locks again; everything free + keeps working. Your specs/tasks are plain files in your repo — + nothing is held hostage. + ▸ Does it need my API keys? It uses the model CLIs you already + have. No new keys. + ▸ Team licenses? pro@atlas-ai.au (volume from 5). +``` +Checkout → Stripe → success page shows the license key **and** the install one-liner together. + +--- + +## 5. Onboarding & install UX + +### Free — `curl | bash` +``` +$ curl -fsSL https://atlas-ai.au/install | bash + atlas — turn any goal into shipped code + ✓ Claude Code detected (v2.4) + ✓ Plugin installed: prd-taskmaster + ▸ Peer dependency: task-master-ai … not found + installing (npm install -g task-master-ai) … ✓ 12s + ✓ Self-test: skill resolves, script.py preflight OK + Done in 24s. + ┌─ first run ───────────────────────────────────┐ + │ Open any project in Claude Code and type: │ + │ /atlas │ + │ …or just say "I want to build <anything>". │ + └───────────────────────────────────────────────┘ +``` +Failure states each name the problem, give ONE copy-pasteable fix, and exit nonzero. + +### Paid — license-key installer +``` +$ curl -fsSL https://get.atlas-ai.au | bash -s -- --key ATLAS-7Q2M-XXXX-XXXX + atlas pro installer + ✓ License valid — seat: hayden@…, renews 2026-07-11 + ✓ Free engine present (prd-taskmaster) + ✓ Installed: atlas-launcher (fleet orchestrator) + ✓ Installed MCPs: atlas-browser · atlas-vault + ✓ tmux 3.4 detected (Fleet requirement) + ▸ Fleet self-test: spawning throwaway worker … ✓ killed clean, 4s + Atlas Pro is active. + Fleet now appears in every /atlas handoff — and you have a + handoff waiting in ~/projects/myapp (14 tasks, 3 waves). + Resume it: open that project and type /atlas +``` +Offline grace: validates online at install + re-checks weekly with a **14-day offline grace**; +degraded state prints once per session, never blocks mid-run; never kill a running fleet over a +license check. + +--- + +## 6. Canonical vocabulary (resolves the live naming collision) + +| Concept | Canonical name | Kills / demotes | +|---|---|---| +| Brand / company | **Atlas AI** (atlas-ai.au) | — | +| Public repo | **prd-taskmaster** (keep — 508★ + SEO) | — | +| Free product / plugin / npm | **prd-taskmaster** ("the engine") | `atlas-go` | +| User command | **`/atlas`** (alias of the orchestrator skill) | `/prd-taskmaster-v2` user-facing | +| Subscription | **Atlas Pro** ($29/mo) | "premium tier" | +| Flagship paid feature | **Atlas Fleet** | "Atlas-Auto", "atlas-loop", **"Mode D"** user-facing | +| Pipeline phases | **Preflight → Discovery → Generate → Handoff** (1/4…4/4) | — | +| Execution modes | **Verified Loop** (=C, recommended free) · **Auto-Execute** (=B) · **Plan & Drive** (=A) · **Atlas Fleet** (=D, Pro) | letters survive only in code/tests | +| External tools | **"Use another tool…"** single sub-option | a ten-option flat list | +| Manual path | a *flag* ("manual"), not a mode | "Mode M" | +| Fleet units | **worker** (a session), **wave** (parallel batch), **checker** (gate agent) | "phoenix" | +| Quality output | **grade**, **gate**, **evidence** | raw JSON gate dumps | + +Rules: one URL per intent — every upgrade path points to `atlas-ai.au/pro` (the +`#waitlist` anchor dies). "Mode D" never reaches a user's eyes. The vocabulary users must learn +is exactly seven words: *goal, discovery, spec, grade, tasks, handoff, fleet*. + +--- + +## 7. Visual / brand direction (terminal + markdown) + +| Symbol | Meaning | ASCII fallback | +|---|---|---| +| `✓` | done / passed | `[ok]` | +| `✗` | blocked / failed (blockers only) | `[x]` | +| `⚠` | warning, non-blocking | `[!]` | +| `○` | optional / not present / queued | `[ ]` | +| `▶` | running now | `[>]` | +| `▸` | selectable option / next action | `>` | +| `▰▱` | progress bar | `[###-]` | +| `🔒` | Pro-locked (the only emoji in terminal output) | `[PRO]` | +| `★` | Pro-active / recommendation accent | `*` | + +No other emoji in terminal output. Color (green/yellow/red) only reinforces ✓/⚠/✗; respect +`NO_COLOR`; every screen reads in monochrome. Light box-drawing for phase frames only, ≤64 cols +inside. Tone: calm, factual, first-person-tool, zero exclamation marks in the terminal; the +README may be warmer, the pricing page plainspoken-bold. Accessibility: every state = symbol + +word; progress bars carry numerals; links printed as full URLs. + +--- + +## 8. Prioritized UX gaps the redesign fixes + +**P0 — blocks selling** +1. **Naming chaos** across live surfaces (prd-taskmaster / atlas-go / Atlas-Auto / Mode D / two + waitlist URLs). → §6 vocabulary + single `/pro` URL. +2. **Storefront sells the wrong product** — the live README describes the v1 question-asker; none + of the v2/v4 engine is visible to the 508★ audience. Highest-leverage fix. → §3 replacement. +3. **Dead-end paywall** — the Mode D teaser ends at "watch the repo"; must become a purchasable + path that resumes the handoff. → §2 + installer resume. + +**P1 — damages first-run trust** +4. **Handoff gate fails on first entry by design** (gate demands evidence HANDOFF produces). Fix + before launch; UI must never print gate JSON. +5. **Contradictory mode-recommendation logic** in two shipped docs. One source of truth. +6. **Choice overload** at handoff (up to 11 flat options). → 3 free names + 1 locked Fleet + + collapsed "use another tool…". +7. **Illegible validation output** (grades with no scale, warnings counted not quoted, JSON + gates). → §1 Moment 3 scorecard. + +**P2 — polish / conversion friction** +8. **Three competing install paths** with no canonical. → curl one-liner primary, marketplace + secondary; git-clone path deleted from README. +9. **Unspecified failure states** surface as raw tool errors. → fixed `✗ + Fix:` grammar. +10. **Inconsistent output register** (v1 emoji-heavy vs v2 minimal). → §7 symbol system. + +**P3 — nice to have** +11. Debrief scaffold noise leaks an internal ritual into customer projects — make it opt-in/silent + outside the dev repo. +12. No documented `atlas fleet status` re-entry command — the walk-away promise needs a + walk-back-in command. diff --git a/hooks/evidence_gate.py b/hooks/evidence_gate.py new file mode 100755 index 0000000..220ffd0 --- /dev/null +++ b/hooks/evidence_gate.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Evidence gate Stop hook. Blocks session end when the EXECUTE phase has +in-progress tasks with incomplete CDD evidence (fewer evidence_files than +subtasks). + +Reads Stop JSON from stdin, returns a decision on stdout. +Never crashes — all parsing wrapped in try/except. +Short-circuits when stop_hook_active is True to avoid infinite block loops +when Claude Code re-invokes Stop hooks after a block decision. +No explicit process termination — main() returns and the process ends naturally. +""" +import json +import sys +from pathlib import Path + + +def main(): + try: + payload = json.loads(sys.stdin.read()) + except Exception: + # never crash the hook — bad stdin is a no-op (implicit allow) + print(json.dumps({})) + return + + try: + # Short-circuit: if Claude Code is re-invoking Stop hooks after a block, + # always allow to prevent infinite loops. + if payload.get("stop_hook_active") is True: + print(json.dumps({})) + return + + pipeline_path = Path(".atlas-ai/state/pipeline.json") + if not pipeline_path.is_file(): + print(json.dumps({})) + return + + try: + pipeline = json.loads(pipeline_path.read_text()) + except Exception: + print(json.dumps({})) + return + + if pipeline.get("current_phase") != "EXECUTE": + print(json.dumps({})) + return + + tasks_path = Path(".atlas-ai/taskmaster/tasks/tasks.json") + if not tasks_path.is_file(): + print(json.dumps({})) + return + + try: + tasks_data = json.loads(tasks_path.read_text()) + except Exception: + print(json.dumps({})) + return + + tasks = tasks_data.get("master", {}).get("tasks", []) or [] + offenders = [] + for t in tasks: + if not isinstance(t, dict): + continue + if t.get("status") != "in-progress": + continue + subtasks = t.get("subtasks") or [] + evidence_files = t.get("evidence_files") or [] + if len(subtasks) > 0 and len(evidence_files) < len(subtasks): + offenders.append( + f"task {t.get('id')}: {len(evidence_files)}/{len(subtasks)} evidence" + ) + + if offenders: + reason = ( + f"Incomplete CDD evidence — {len(offenders)} tasks have subtasks " + f"without matching evidence: {'; '.join(offenders)}. " + f"Run execute-task loop to completion or mark blocked." + ) + print(json.dumps({"decision": "block", "reason": reason})) + else: + print(json.dumps({})) + except Exception: + # any unexpected failure — allow, never crash the hook + print(json.dumps({})) + return + + +if __name__ == "__main__": + main() diff --git a/hooks/gate_enforcer.py b/hooks/gate_enforcer.py new file mode 100644 index 0000000..4486f83 --- /dev/null +++ b/hooks/gate_enforcer.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Gate enforcer hook. Blocks mcp__...__advance_phase calls without gate evidence. +Reads PreToolUse JSON from stdin, returns permissionDecision on stdout. +Never crashes — all JSON parsing wrapped in try/except. +No explicit process termination — main() returns and the process ends naturally. +""" +import json +import sys + + +def main(): + try: + payload = json.loads(sys.stdin.read()) + except Exception: + # never crash the hook — bad stdin is a no-op (implicit allow) + print(json.dumps({})) + return + + tool_name = payload.get("tool_name", "") + if not tool_name.endswith("__advance_phase"): + print(json.dumps({})) + return + + tool_input = payload.get("tool_input", {}) + target = tool_input.get("target") + evidence = tool_input.get("evidence", {}) + + # Mirror the same gate logic as pipeline.check_gate (simplified for hook) + violations = [] + if target == "SETUP": + vs = evidence.get("validate_setup", {}) + if not vs.get("ready") or vs.get("critical_failures", 1) > 0: + violations.append("validate_setup must report ready=true and critical_failures==0; evidence must contain validate_setup block") + elif target == "DISCOVER": + if not ( + evidence.get("user_approved") + or ( + evidence.get("auto_classification") == "CLEAR" + and evidence.get("assumptions_documented") + ) + ): + violations.append( + "DISCOVER gate requires user_approved=true OR (auto_classification==CLEAR AND assumptions_documented=true); evidence missing" + ) + elif target == "GENERATE": + if evidence.get("validation_grade") not in ("EXCELLENT", "GOOD"): + violations.append("validation_grade must be EXCELLENT or GOOD; evidence insufficient") + if evidence.get("task_count", 0) == 0: + violations.append("task_count must be > 0; evidence shows no tasks parsed") + if evidence.get("subtask_coverage", 0) < 1.0: + violations.append("subtask_coverage must be >= 1.0; evidence shows subtasks incomplete") + elif target == "HANDOFF": + if not evidence.get("user_mode_choice"): + violations.append("user_mode_choice missing from evidence; user must select execution mode before HANDOFF") + if not evidence.get("plan_file_exists"): + violations.append("plan_file_exists missing from evidence; plan file must be written before HANDOFF") + elif target == "EXECUTE": + # EXECUTE is terminal — individual tasks track own status; no gate check needed + pass + + if violations: + reason = f"Gate for {target} not passed: {'; '.join(violations)}" + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + })) + else: + print(json.dumps({})) + + +if __name__ == "__main__": + main() diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..b7f98b6 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,27 @@ +{ + "description": "prd-taskmaster plugin hooks", + "hooks": { + "PreToolUse": [ + { + "matcher": "mcp__plugin_prd-taskmaster_go__advance_phase", + "hooks": [ + {"type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/gate_enforcer.py"} + ] + }, + { + "matcher": "mcp__atlas-cdd__.*|mcp__atlas-loop__.*", + "hooks": [ + {"type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/mode_d_blocker.py"} + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + {"type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/evidence_gate.py"} + ] + } + ] + } +} diff --git a/hooks/mode_d_blocker.py b/hooks/mode_d_blocker.py new file mode 100644 index 0000000..4d3923b --- /dev/null +++ b/hooks/mode_d_blocker.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +Atlas Fleet blocker hook. Blocks mcp__atlas-cdd and mcp__atlas-loop tool calls. +Atlas Fleet is part of Atlas Pro and requires a licensed launcher. +Reads PreToolUse JSON from stdin, returns permissionDecision on stdout. +Never crashes — all JSON parsing wrapped in try/except. +No explicit process termination — main() returns and the process ends naturally. +""" +import json +import sys + + +def main(): + try: + payload = json.loads(sys.stdin.read()) + except Exception: + # never crash the hook — bad stdin is a no-op (implicit allow) + print(json.dumps({})) + return + + tool_name = payload.get("tool_name", "") + + # Check if tool is from blocked Atlas Fleet namespaces + if tool_name.startswith("mcp__atlas-cdd__") or tool_name.startswith("mcp__atlas-loop__"): + reason = ( + "Atlas Fleet is part of Atlas Pro ($29/mo). " + "Unlock: https://atlas-ai.au/pro" + ) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + })) + else: + print(json.dumps({})) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh index d5d00d0..b1edab1 100755 --- a/install.sh +++ b/install.sh @@ -6,11 +6,13 @@ # curl -fsSL https://raw.githubusercontent.com/OWNER/REPO/main/install.sh | bash # bash install.sh # fresh install or upgrade # bash install.sh --check-update # check for newer version only +# bash install.sh --no-telemetry # install without anonymous install ping # # Customize the variables below for your skill repository. # ============================================================================ set -euo pipefail +INSTALL_STARTED_SECONDS=${SECONDS:-0} # --------------------------------------------------------------------------- # Skill Configuration (customize these per-repo) @@ -18,8 +20,10 @@ set -euo pipefail REPO_OWNER="anombyte93" REPO_NAME="prd-taskmaster" SKILL_NAME="prd-taskmaster" -VERSION="3.0.0" +VERSION="4.0.0" SKILL_DIR="${SKILL_DIR:-${HOME}/.claude/skills/${SKILL_NAME}}" +ALIAS_NAME="atlas" +ALIAS_DIR="${HOME}/.claude/skills/${ALIAS_NAME}" # --------------------------------------------------------------------------- # Internal constants @@ -52,6 +56,11 @@ ok() { printf "${GREEN}[ok]${RESET} %s\n" "$*"; } warn() { printf "${YELLOW}[warn]${RESET} %s\n" "$*"; } err() { printf "${RED}[error]${RESET} %s\n" "$*" >&2; } die() { err "$@"; exit 1; } +die_with_fix() { + err "$1" + printf "Fix: %s\n" "$2" >&2 + exit 1 +} cleanup() { if [[ -n "${TMPDIR_SKILL:-}" ]] && [[ -d "${TMPDIR_SKILL}" ]]; then @@ -64,6 +73,36 @@ require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } +ensure_taskmaster_peer() { + if ! command -v task-master-ai >/dev/null 2>&1; then + if ! command -v npm >/dev/null 2>&1; then + die_with_fix \ + "Peer dependency missing: task-master-ai requires npm to install." \ + "Install Node.js/npm, then rerun install.sh." + fi + info "Peer dependency: installing task-master-ai" + npm install -g task-master-ai >/dev/null 2>&1 || die_with_fix \ + "Peer dependency install failed: npm install -g task-master-ai." \ + "npm install -g task-master-ai" + fi + + if ! command -v task-master-ai >/dev/null 2>&1; then + die_with_fix \ + "Peer dependency missing after install: task-master-ai is still not on PATH." \ + "npm install -g task-master-ai" + fi + + local taskmaster_version + if ! taskmaster_version=$(task-master-ai --version 2>&1); then + die_with_fix \ + "Self-test failed: task-master-ai --version did not run." \ + "npm install -g task-master-ai" + fi + + printf "✓ Peer dependency: task-master-ai\n" + printf "✓ Self-test: task-master-ai --version -> %s\n" "${taskmaster_version}" +} + # --------------------------------------------------------------------------- # Update check (callable standalone) # --------------------------------------------------------------------------- @@ -170,6 +209,17 @@ with open(path, 'w') as f: fi } +send_install_telemetry() { + if [[ "${ATLAS_TELEMETRY:-}" == "0" ]]; then + return 0 + fi + if ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + + ATLAS_SKILL_DIR="${SKILL_DIR}" python3 -c 'import os, sys, time; sys.path.insert(0, os.environ["ATLAS_SKILL_DIR"]); from prd_taskmaster.telemetry import send_event; send_event("install"); time.sleep(1.1)' >/dev/null 2>&1 || true +} + # --------------------------------------------------------------------------- # Install logic # --------------------------------------------------------------------------- @@ -236,11 +286,25 @@ install_skill() { cp "${src_dir}/SKILL.md" "${SKILL_DIR}/SKILL.md" ok "Installed SKILL.md" - # Optional: script.py (or any .py entrypoint) + # Required: script.py shim + the prd_taskmaster package it imports if [[ -f "${src_dir}/script.py" ]]; then cp "${src_dir}/script.py" "${SKILL_DIR}/script.py" ok "Installed script.py" fi + if [[ -d "${src_dir}/prd_taskmaster" ]]; then + rm -rf "${SKILL_DIR}/prd_taskmaster" + cp -r "${src_dir}/prd_taskmaster" "${SKILL_DIR}/prd_taskmaster" + # drop bytecode caches so the copy is clean + rm -rf "${SKILL_DIR}/prd_taskmaster/__pycache__" + ok "Installed prd_taskmaster/ package" + fi + + # Optional: phases/ directory (the 4-phase runbooks) + if [[ -d "${src_dir}/phases" ]]; then + rm -rf "${SKILL_DIR}/phases" + cp -r "${src_dir}/phases" "${SKILL_DIR}/phases" + ok "Installed phases/" + fi # Optional: templates/ directory if [[ -d "${src_dir}/templates" ]]; then @@ -275,9 +339,58 @@ repo: ${REPO_OWNER}/${REPO_NAME} VEOF ok "Wrote .version (${VERSION}, ${timestamp})" + # ------------------------------------------------------------------ + # Install the /atlas alias skill (thin delegator to this skill) + # ------------------------------------------------------------------ + mkdir -p "${ALIAS_DIR}" + cat > "${ALIAS_DIR}/SKILL.md" <<ALIASEOF +--- +name: ${ALIAS_NAME} +description: >- + The Atlas engine — turn any goal into a validated PRD and an executable, + verified task graph. Alias for the prd-taskmaster skill. Use when the user + invokes /atlas, says "I want to build", or asks for a PRD / task-driven build. +allowed-tools: + - Read + - Skill + - Bash +--- + +# /atlas — the Atlas engine + +This is a thin alias. Immediately Read and follow the full engine skill: + +\`\`\` +Read ${SKILL_DIR}/SKILL.md +\`\`\` + +Then execute that skill's pipeline (Preflight -> Discovery -> Generate -> Handoff) +exactly as written, carrying over whatever goal or arguments the user provided. +ALIASEOF + ok "Installed /atlas alias skill -> ${ALIAS_DIR}" + + # ------------------------------------------------------------------ + # Deprecation: superseded standalone prd-taskmaster-v2 skill dir + # ------------------------------------------------------------------ + local legacy_dir="${HOME}/.claude/skills/prd-taskmaster-v2" + if [[ -d "${legacy_dir}" ]]; then + warn "Found legacy skill dir: ${legacy_dir}" + warn "It is superseded by prd-taskmaster v${VERSION}. Remove it with:" + printf " rm -rf %s\n" "${legacy_dir}" + fi + + ensure_taskmaster_peer + # ------------------------------------------------------------------ # Success # ------------------------------------------------------------------ + local elapsed=$(( SECONDS - INSTALL_STARTED_SECONDS )) + printf "\n" + printf "✓ Skill files: %s ${SKILL_NAME} v${VERSION}\n" \ + "$([ "${mode}" = "upgrade" ] && echo "upgraded" || echo "installed")" + printf "✓ Alias: /atlas -> %s\n" "${ALIAS_DIR}" + printf "✓ Package: prd_taskmaster copied into the skill directory\n" + printf "Done in %ss.\n" "${elapsed}" printf "\n" printf "${GREEN}${BOLD}Successfully %s ${SKILL_NAME} v${VERSION}${RESET}\n" \ "$([ "${mode}" = "upgrade" ] && echo "upgraded" || echo "installed")" @@ -288,9 +401,11 @@ VEOF fi printf "\n" - info "To use this skill in Claude Code, reference it with:" - printf " ${CYAN}/%s${RESET}\n" "${SKILL_NAME}" + printf "Installed. Try: /atlas or say \"I want to build...\"\n" + printf " Also available: /%s\n" "${SKILL_NAME}" printf "\n" + + send_install_telemetry } # --------------------------------------------------------------------------- @@ -304,11 +419,17 @@ main() { --version|-v) echo "${SKILL_NAME} v${VERSION}" ;; + --no-telemetry) + export ATLAS_TELEMETRY=0 + install_skill + check_update + ;; --help|-h) - printf "Usage: %s [--check-update | --version | --help]\n" "${0##*/}" + printf "Usage: %s [--check-update | --version | --no-telemetry | --help]\n" "${0##*/}" printf "\n" printf " (no args) Install or upgrade the skill\n" printf " --check-update Check GitHub for a newer release\n" + printf " --no-telemetry Install without anonymous install telemetry\n" printf " --version Print current version\n" printf " --help Show this help\n" ;; diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 0000000..a4e4db5 --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.0.0 +fastmcp>=0.2.0 diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..687ca1f --- /dev/null +++ b/mcp-server/server.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""FastMCP server for prd-taskmaster. + +Registers 20 tools wrapping the sibling modules (pipeline, capabilities, +taskmaster, validation, templates) plus server-native helpers +(calc_tasks, backup_prd, append_workflow, debrief, log_progress, +gen_test_tasks, read_state, gen_scripts, compute_fleet_waves). + +No explicit process termination — mcp.run() is the event loop and +returns naturally when the transport closes. +""" +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +# Ensure the prd_taskmaster package (repo root) imports when run as a script. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from mcp.server.fastmcp import FastMCP + +from prd_taskmaster import pipeline as P +from prd_taskmaster import validation as V +from prd_taskmaster import mode_recommend as C +from prd_taskmaster import taskmaster as TM +from prd_taskmaster import templates as TPL +from prd_taskmaster import lib as LIB +from prd_taskmaster import fleet as F +from prd_taskmaster import batch as B +from prd_taskmaster import license_activate as LA + +mcp = FastMCP("prd-taskmaster") + + +# ─── Delegation tools (11) ──────────────────────────────────────────────────── + +@mcp.tool() +def preflight(cwd: str | None = None) -> dict: + """Inspect project state and recommend the next pipeline action.""" + return P.preflight(cwd) + + +@mcp.tool() +def engine_preflight(configure: bool = True) -> dict: + """One-call Phase 1: preflight + taskmaster + provider config/detect + + capabilities, with a human-presentable summary. Prefer this over the + individual probes.""" + return B.run_engine_preflight(configure=configure) + + +@mcp.tool() +def current_phase() -> dict: + """Return the current pipeline phase plus completed-phase history.""" + return P.current_phase() + + +@mcp.tool() +def advance_phase(expected_current: str | None, target: str, evidence: dict) -> dict: + """Atomic compare-and-swap phase transition with evidence attached.""" + return P.advance_phase(expected_current, target, evidence) + + +@mcp.tool() +def check_gate(phase: str, evidence: dict) -> dict: + """Evaluate the gate rules for a phase against the supplied evidence.""" + return P.check_gate(phase, evidence) + + +@mcp.tool() +def detect_taskmaster() -> dict: + """Detect taskmaster availability (MCP > CLI > none).""" + return C.detect_taskmaster() + + +@mcp.tool() +def detect_capabilities() -> dict: + """Scan for plugins, skills, and external AI tools; recommend a mode.""" + return C.detect_capabilities() + + +@mcp.tool() +def license_activate(key: str) -> dict: + """Activate an Atlas Pro license key and return status plus resulting tier.""" + result = LA._activation_result(key) + capabilities = C.detect_capabilities() + return { + **result, + "tier": capabilities.get("tier", "free"), + "license_status": capabilities.get("license_status"), + } + + +@mcp.tool() +def validate_setup() -> dict: + """Run the 6 Phase-0 SETUP checks and return per-check pass/fail.""" + return C.validate_setup() + + +@mcp.tool() +def init_taskmaster(method: str = "cli") -> dict: + """Initialise TaskMaster in the current project via the CLI.""" + return TM.init_taskmaster(method) + + +@mcp.tool() +def validate_prd(input_path: str, ai: bool = False) -> dict: + """Run the deterministic PRD quality checks and return a graded report.""" + return V.run_validate_prd(input_path) + + +@mcp.tool() +def load_template(type: str = "comprehensive") -> dict: + """Load the named PRD template bundled with the plugin.""" + return TPL.run_load_template(type) + + +@mcp.tool() +def compute_fleet_waves(concurrency: int = 3, tag: str = "") -> dict: + """Compute Atlas Fleet dependency waves for the selected TaskMaster tag.""" + return F.run_fleet_waves(concurrency, tag) + + +# ─── Server-native tools (8) ────────────────────────────────────────────────── + +@mcp.tool() +def read_state() -> dict: + """Alias for current_phase — read-only pipeline state snapshot.""" + return P.current_phase() + + +@mcp.tool() +def calc_tasks( + requirements_count: int, + team_size: int = 1, + scope_phase: str = "greenfield", + thematic_groups: int = 0, +) -> dict: + """Recommend a task count from requirements, team, and scope phase.""" + base = max(8, requirements_count) + if scope_phase == "final_phase": + base = min(base, 12) + if team_size > 1: + base = min(base + (team_size - 1) * 2, 30) + return {"ok": True, "recommended": base} + + +@mcp.tool() +def gen_test_tasks(total: int) -> dict: + """Return a placeholder test-task projection (count only, no bodies).""" + return {"ok": True, "count": total // 5, "tasks": []} + + +@mcp.tool() +def backup_prd(input_path: str) -> dict: + """Copy a PRD to a timestamped prd-backup-YYYYMMDD-HHMMSS.md sibling.""" + src = Path(input_path) + if not src.exists(): + return {"ok": False, "error": f"source missing: {input_path}"} + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + dst = src.parent / f"prd-backup-{ts}.md" + dst.write_text(src.read_text()) + return {"ok": True, "backup_path": str(dst)} + + +@mcp.tool() +def append_workflow(target: str, content_file: str, dry_run: bool = False) -> dict: + """Append a plugin-managed workflow block (fenced by BEGIN/END markers).""" + BEGIN = "<!-- BEGIN atlas-ai plugin workflow -->" + END = "<!-- END atlas-ai plugin workflow -->" + + target_path = Path(target) + content_path = Path(content_file) + if not content_path.exists(): + return {"ok": False, "error": f"content_file missing: {content_file}"} + payload = content_path.read_text() + block = f"\n{BEGIN}\n{payload}\n{END}\n" + + if target_path.exists(): + existing = target_path.read_text() + if BEGIN in existing and END in existing: + return {"ok": True, "action": "skipped", "reason": "markers_present"} + if dry_run: + return {"ok": True, "action": "would_append"} + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = target_path.with_name( + target_path.name + f".prd-taskmaster-backup-{ts}" + ) + backup_path.write_text(existing) + LIB.atomic_write(target_path, existing + block) + return {"ok": True, "action": "appended", "backup_path": str(backup_path)} + else: + if dry_run: + return {"ok": True, "action": "would_create"} + LIB.atomic_write(target_path, block.lstrip()) + return {"ok": True, "action": "created"} + + +@mcp.tool() +def debrief( + slug: str, + grade: str = "", + output_dir: str = "docs/v4-release", + force: bool = False, +) -> dict: + """Write a dogfood-debrief markdown stub under output_dir.""" + d = Path(output_dir) + d.mkdir(parents=True, exist_ok=True) + out = d / f"dogfood-{slug}-{datetime.now().strftime('%Y%m%d')}.md" + if out.exists() and not force: + return { + "ok": False, + "error": "exists; pass force=true to overwrite", + "path": str(out), + } + body = ( + f"# Dogfood Debrief: {slug}\n\n" + f"- grade: {grade or 'unset'}\n" + f"- generated: {datetime.now().isoformat()}\n\n" + "## Notes\n\n" + "(placeholder — fill in after run)\n" + ) + out.write_text(body) + return {"ok": True, "output_path": str(out)} + + +@mcp.tool() +def log_progress(task_id: str, title: str) -> dict: + """Append an ISO-timestamped progress line to .atlas-ai/progress.md.""" + progress = Path(".atlas-ai") / "progress.md" + progress.parent.mkdir(parents=True, exist_ok=True) + line = f"- [{datetime.now().isoformat()}] task {task_id}: {title}\n" + with progress.open("a") as f: + f.write(line) + return {"ok": True, "progress_file": str(progress)} + + +@mcp.tool() +def gen_scripts(output_dir: str = ".atlas-ai/scripts") -> dict: + """Write stub scripts (ship-check.py, progress.sh, summary.py) if absent.""" + d = Path(output_dir) + d.mkdir(parents=True, exist_ok=True) + created: list[str] = [] + for name in ("ship-check.py", "progress.sh", "summary.py"): + p = d / name + if not p.exists(): + p.write_text(f"# {name} stub\n") + created.append(str(p)) + return {"ok": True, "created": created} + + +if __name__ == "__main__": + mcp.run() diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa168f2 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "prd-taskmaster", + "version": "4.0.0", + "description": "Zero-config goal-to-tasks engine for Claude Code (the Atlas engine)", + "peerDependencies": { + "task-master-ai": ">=0.43.0" + }, + "scripts": { + "postinstall": "pip install -r mcp-server/requirements.txt 2>/dev/null || echo 'WARN: Python MCP deps not installed. MCP tools will not start. Run: pip install -r node_modules/prd-taskmaster/mcp-server/requirements.txt'; if [ \"${ATLAS_TELEMETRY:-}\" != \"0\" ] && command -v python3 >/dev/null 2>&1; then ATLAS_SKILL_DIR=\"${PWD}\" python3 -c 'import os, sys, time; sys.path.insert(0, os.environ.get(\"ATLAS_SKILL_DIR\", os.getcwd())); from prd_taskmaster.telemetry import send_event; send_event(\"install\"); time.sleep(1.1)' 2>/dev/null || true; fi", + "test": "pytest tests/", + "test:fast": "pytest tests/ -k 'not integration'", + "test:integration": "pytest tests/mcp/test_integration.py" + }, + "files": [ + ".claude-plugin/", + ".mcp.json", + "skills/", + "hooks/", + "agents/", + "mcp-server/", + "prd_taskmaster/", + "script.py", + "SKILL.md", + "phases/", + "templates/", + "reference/", + "skel/", + "CHANGELOG.md", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/anombyte93/prd-taskmaster.git" + }, + "license": "MIT" +} diff --git a/phases/DISCOVER.md b/phases/DISCOVER.md new file mode 100644 index 0000000..2c73cd5 --- /dev/null +++ b/phases/DISCOVER.md @@ -0,0 +1,103 @@ +# Phase: Discovery --- Brainstorming-Driven + +## The One Rule + +**Invoke superpowers:brainstorming for discovery. Intercept before it chains to writing-plans --- we control the exit.** + +## Checklist + +Copy into your response: +``` +DISCOVERY CHECKLIST: +- [ ] Brainstorming invoked with user's goal +- [ ] Discovery questions completed (adaptive, one at a time) +- [ ] Design approved by user +- [ ] Summary captured for GENERATE phase +``` + +## How Discovery Works + +1. Take the user's goal/description from skill invocation +2. Invoke `superpowers:brainstorming` with the goal as input +3. Brainstorming runs its adaptive question flow (one Q at a time, domain-agnostic) +4. **INTERCEPT POINT**: When brainstorming is ready to chain to writing-plans, STOP. + - Do NOT let it invoke writing-plans + - Capture the brainstorm output (design, requirements, decisions) + - Present the summary to the user for approval + +## User Approval Gate + +After brainstorming completes, present: + +``` +Discovery Complete: + Goal: [one sentence] + Audience: [who it's for] + Approach: [proposed solution] + Key decisions: [list] + Constraints: [known limitations] +``` + +Then ask the gate with the **AskUserQuestion tool** — never as a trailing prose question: +- Option 1: "Approve — generate spec" (proceed to GENERATE) +- Option 2: "Refine" (ask what to change, update, re-present this gate) + +This applies to EVERY decision point in the pipeline: if a message would end with +"Proceed? / or refine / which option?", it MUST be an AskUserQuestion call instead +(one screen, one decision). + +If user picks refine --- ask what to change, update, re-present. +If user approves --- capture this as the discovery output and proceed to GENERATE. + +## Smart Defaults + +If brainstorming produces thin answers, fill gaps with reasonable assumptions: +- Target: small team (< 10 users) unless specified +- Timeline: MVP in 4-6 weeks +- Tech stack: determined by requirements +- Scale: moderate (hundreds, not millions) + +Document assumptions in the spec during GENERATE. + +## Constraint Extraction (MANDATORY before proceeding) + +Before moving to GENERATE, explicitly extract and list all constraints mentioned during discovery: + +``` +CONSTRAINTS CAPTURED: +- Tech stack: [e.g., "must use Python", "React frontend", "no new dependencies"] +- Timeline: [e.g., "MVP in 2 weeks", "no deadline"] +- Team: [e.g., "solo developer", "3-person team"] +- Budget: [e.g., "free tier only", "$500/month max"] +- Integration: [e.g., "must work with existing Postgres DB", "connects to Stripe"] +- Regulatory: [e.g., "HIPAA compliant", "GDPR", "none specified"] +- Domain-specific: [e.g., "authorized pentest scope: 10.0.0.0/24 only", "learning goal: intermediate level"] +``` + +Present this list to the user alongside the discovery summary. These constraints MUST be passed to GENERATE — they inform spec content, task decomposition depth, and acceptance criteria. If a constraint is mentioned in discovery but missing from the spec, it's a bug. + +## Scope Calibration + +Infer project scale from discovery answers and set decomposition guidance: + +| Scale | Signal | Task Cap | Subtask Depth | +|-------|--------|----------|---------------| +| Solo/hobby | "just me", "side project", "learning" | 8-12 tasks | 2-3 subtasks each | +| Team/startup | "small team", "MVP", "product" | 12-20 tasks | 3-5 subtasks each | +| Enterprise | "compliance", "multiple teams", "platform" | 20-30 tasks | 5-8 subtasks each | + +Pass the scale classification to GENERATE so task count is calibrated, not arbitrary. + +## Evidence Gate + +**Gate: User has approved the discovery output. Constraints extracted and listed. Scale classified. Proceed to GENERATE phase.** + +## After approval: go autonomous + +The approval at the end of discovery is the LAST interactive gate before handoff. +Once the user approves the design: +1. Tell the user: "Building the spec and task graph now — no more questions until + handoff. If permission prompts appear, shift+tab switches to auto-accept." +2. Run GENERATE end-to-end without asking anything further (zero-question rule). + Validation failures are fixed silently and re-validated; only a NEEDS_WORK + grade that survives two fix attempts comes back to the user. diff --git a/phases/GENERATE.md b/phases/GENERATE.md new file mode 100644 index 0000000..d110a5b --- /dev/null +++ b/phases/GENERATE.md @@ -0,0 +1,204 @@ +# Phase: Generate & Validate — Spec Creation and Task Parsing + +## Tool preference (applies to every step below) + +The engine's deterministic operations are available as MCP tools when ANY engine +server is registered — tool ids look like `mcp__atlas-engine__<name>` (user-scoped +server) or `mcp__plugin_prd-taskmaster_go__<name>` (plugin install). **Always +prefer the MCP tool over the script.py equivalent** (same names: load_template, +validate_prd, calc_tasks, engine_preflight, compute_fleet_waves, ...). The +script.py commands below are the zero-dependency fallback only. + + +## The One Rule + +**Generate the spec, validate it catches placeholders, parse it into tasks. Quality over speed.** + +## Checklist + +Copy into your response: +``` +┌─ atlas ── PHASE 3/4: GENERATE ─────────────────────────────┐ +What happened: The PRD is being generated and checked. +Evidence: + ○ Template loaded: waiting + ○ Spec written: waiting + ○ Validation grade: waiting + ○ Placeholder scan: waiting + ○ Tasks parsed: waiting + ○ Subtasks expanded: waiting +Next: write the PRD, then render the validation scorecard. +``` + +## Step 1: Choose and Load Template + +Decide based on discovery depth: +- **Comprehensive**: 4+ detailed answers, complex project +- **Minimal**: Quick project, thin answers, user wants speed + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py load-template --type comprehensive +``` + +## Step 2: Generate Spec + +Fill the template with discovery answers. AI judgment required: +- Replace ALL placeholders with actual content +- Expand with project-specific details +- Add technical depth proportional to what user provided +- Generate domain-appropriate sections (pentest = threat model, app = user stories, business = success metrics, learning = assessment criteria) +- Document assumptions where discovery was thin +- **CONSTRAINT CHECK**: Verify EVERY constraint from the DISCOVER phase constraint list appears in the spec. If "must use Python" was a constraint, the spec must reference Python. Missing constraints = spec bug. +- **SCOPE CHECK**: Use the scale classification from DISCOVER to set task count range. Solo = 8-12, Team = 12-20, Enterprise = 20-30. + +Write to `.taskmaster/docs/prd.md`. + +### Domain-Neutral Vocabulary + +When writing the spec, use domain-neutral terms unless the domain is known: + +| Software term | Neutral equivalent | When to use neutral | +|--------------|-------------------|-------------------| +| tests | verification criteria | pentest, business, learning | +| code | deliverable | business, learning | +| deploy | execute / deliver | business, learning | +| repo | workspace | non-software | +| PR / pull request | output / submission | non-software | +| user stories | success scenarios | all domains | +| API endpoints | capabilities | non-software | +| CI/CD | delivery pipeline | non-software | + +If the domain IS software, use software terms. The neutral terms are for non-software goals. + +Key rule: Every `[placeholder]`, `{{variable}}`, `[TBD]`, `[TODO]` must be replaced or removed. + +## Step 3: Validate Spec Quality + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py validate-prd --input .taskmaster/docs/prd.md +``` + +Returns: `score`, `grade`, `checks`, `warnings`, `placeholders_found`. + +**Grading**: EXCELLENT (91%+), GOOD (83-90%), ACCEPTABLE (75-82%), NEEDS_WORK (<75%). + +Render the validation scorecard: + +```text +┌─ atlas ── PHASE 3/4: GENERATE ─────────────────────────────┐ +What happened: The PRD was scored and parsed into tasks. +Evidence: + ✓ Grade: GOOD ▰▰▰▰▰▰▰▰▱▱ 49/57 (86%) + ⚠ Warnings: "<warning text>" at <section or line> + ✓ Placeholder scan: 0 bare placeholders named by scan + ✓ Tasks: <count> parsed; subtasks expanded for every task +Gate passed: GOOD or better required — passed. +Next: choose the execution mode in handoff. +``` + +**If placeholders found**: Fix before proceeding. +**If NEEDS_WORK**: render `Gate blocked:` with one `Fix:` action before any retry. +**If GOOD+**: Proceed to task parsing. + +## Step 4: Parse Tasks via TaskMaster + +Calculate task count: +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py calc-tasks --requirements <count> +``` + +Parse (detect method from preflight): + +**MCP**: `parse_prd` tool with input=".taskmaster/docs/prd.md", numTasks=<recommended> +**CLI**: `task-master parse-prd --input .taskmaster/docs/prd.md --num-tasks <recommended>` + +### Manual Flag Path + +If the user passed `--manual`, `manual=true`, or explicitly asked to "do what TaskMaster does manually", skip MCP/CLI parsing and write `.taskmaster/tasks/tasks.json` yourself. + +Required shape: + +```json +{ + "tasks": [ + { + "id": 1, + "title": "Concrete task title", + "description": "One-sentence outcome", + "details": "Implementation notes, files, constraints, and acceptance expectations.", + "testStrategy": "Exact verification command or evidence requirement.", + "status": "pending", + "dependencies": [], + "priority": "high", + "subtasks": [ + { + "id": 1, + "title": "Concrete checkpoint", + "description": "Verifiable subtask outcome", + "status": "pending", + "dependencies": [] + } + ] + } + ] +} +``` + +Rules: +- Generate the same task count recommended by `calc-tasks`, unless the PRD is intentionally smaller. +- Every task must have at least 2 subtasks before execution begins. +- Dependencies must point only to existing task IDs. +- Do not include placeholders, generic "implement feature" tasks, or empty `testStrategy` fields. + +Validate the manually-authored file: + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py validate-tasks +``` + +## Step 5: Enrich Tasks + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py enrich-tasks +``` + +Adds complexity classification (SIMPLE/MEDIUM/COMPLEX/RESEARCH/VALIDATION) and lifecycle phases. + +In Manual Flag Path, immediately verify enrichment: + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py validate-tasks --require-phase-config +``` + +## Step 6: Expand Tasks into Subtasks (MANDATORY) + +Every task MUST be expanded into subtasks before execution begins. Subtasks are verifiable checkpoints — without them, tasks are black boxes that either pass or fail with no intermediate proof. + +**For each task:** +**MCP**: `expand_task` with id=<task_id> +**CLI**: `task-master expand --id=<task_id>` + +If research provider is available, add `--research` flag for richer expansion. +If no research provider, expand WITHOUT research — structural decomposition alone is valuable. + +**DO NOT skip this step.** A task with 0 subtasks cannot be verified incrementally. The execution loop (Mode C/D) relies on subtasks as checkpoints. + +Skip CLI/MCP expansion only in Manual Flag Path, where subtasks were written by hand and `validate-tasks` passed. + +After expansion, verify: +```bash +task-master list --format json | python3 -c " +import sys, json +data = json.load(sys.stdin) +tasks = data.get('tasks', data) if isinstance(data, dict) else data +no_subtasks = [t['id'] for t in tasks if not t.get('subtasks')] +if no_subtasks: + print(f'WARNING: Tasks {no_subtasks} have no subtasks. Expand them.') +else: + print(f'OK: All {len(tasks)} tasks have subtasks.') +" +``` + +## Evidence Gate + +**Gate passed: GOOD or better PRD, tasks created, validation passed, every task has subtasks, and enrichment has run.** diff --git a/phases/HANDOFF.md b/phases/HANDOFF.md new file mode 100644 index 0000000..15d93f0 --- /dev/null +++ b/phases/HANDOFF.md @@ -0,0 +1,150 @@ +# Phase: Handoff — Smart Mode Selection + +## The One Rule + +**Detect what the user has. Recommend one free mode by default. Present Atlas +Fleet only as selectable when licensed, or as a locked Atlas Pro ($29/mo) +teaser.** + +## Checklist + +Copy into your response: + +```text +┌─ atlas ── PHASE 4/4: HANDOFF ──────────────────────────────┐ +What happened: Execution options are being prepared. +Evidence: + ○ Capabilities detected: waiting + ○ Recommended free mode: waiting + ○ Summary displayed: waiting + ○ CLAUDE.md workflow: waiting + ○ Mode picker: waiting + ○ Handoff complete: waiting +Next: choose how the verified task graph should be executed. +``` + +## Step 1: Detect Capabilities + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py detect-capabilities +``` + +Key signals: + +| Capability | What It Enables | +|------------|----------------| +| superpowers plugin | Plan & Drive and Verified Loop | +| TaskMaster CLI/MCP | Auto-Execute | +| loop runner (`ralph-loop` capability key) | Verified Loop | +| atlas-launcher MCP with active license | Atlas Fleet | +| Perplexity MCP/API | Deep research per task | +| Playwright MCP | Browser verification | + +## Step 2: Recommend One Mode + +Decision logic: + +1. If superpowers + a loop runner are detected, recommend **Verified Loop**. +2. If superpowers only are detected, recommend **Plan & Drive**. +3. If TaskMaster CLI only is detected, recommend **Auto-Execute**. +4. Otherwise recommend **Plan & Drive**. +5. Recommend **Atlas Fleet** only when `tier == "premium"` and the task graph has at least two independent dependency chains. + +### Plan & Drive (internal A) + +```text +Recommended: Plan & Drive + /writing-plans creates your implementation plan + Plan references TaskMaster task IDs + You drive execution manually +``` + +### Auto-Execute (internal B) + +```text +Recommended: Auto-Execute + task-master next -> implement -> task-master set-status done -> repeat + Native TaskMaster execution loop +``` + +### Verified Loop (internal C, recommended free) + +```text +Recommended: Verified Loop + /writing-plans creates implementation plan + Evidence-gated loop wraps execution: + next_task -> expand subtasks if missing + -> set_task_status("in-progress") + -> research if confidence is low + -> superpowers:subagent-driven-development executes + -> execution gate: run it, capture output, cold-start check + -> log evidence + -> set_task_status("done") -> update TodoWrite + -> repeat +``` + +### Atlas Fleet (internal D; Atlas Pro) + +Locked free-tier card: + +```text +🔒 Atlas Fleet Atlas Pro · $29/mo + Parallel multi-session execution across Claude, Codex, and Gemini. + Estimate from your dependency graph (actual time varies). + Unlock: https://atlas-ai.au/pro +``` + +Unlocked card: + +```text +▸ Atlas Fleet ★ Pro · license active + <N> waves · estimate from your dependency graph (actual time varies) · walk-away +``` + +Fleet is never default while locked. The default must be Plan & Drive, +Auto-Execute, or Verified Loop unless tier=premium and the graph parallelizes. + +## Step 3: Append Task Workflow to CLAUDE.md + +Read the project's `./CLAUDE.md`. Append the TaskMaster workflow section if +normal TaskMaster parse was used. + +```markdown +## Task Execution Workflow (prd-taskmaster) + +When implementing tasks, use TaskMaster MCP/CLI tools: +1. `next_task` / `task-master next` -- get next dependency-ready task +2. `set_task_status(id, "in-progress")` -- mark started +3. Implement the task (follow the plan step linked to this task) +4. `set_task_status(id, "done")` -- mark complete +5. Update TodoWrite with progress +6. Repeat from step 1 +``` + +## Step 4: Display Summary + +```text +┌─ atlas ── PHASE 4/4: HANDOFF ──────────────────────────────┐ +What happened: The task graph is ready for execution. +Evidence: + ✓ Spec: .taskmaster/docs/prd.md + ✓ Validation: <GRADE> (<score>/<max>) + ✓ Tasks: <count> parsed with dependencies + ✓ Recommended free mode: <Plan & Drive|Auto-Execute|Verified Loop> + ○ Atlas Fleet: locked at Atlas Pro $29/mo, or selectable on tier=premium +Gate passed: handoff options are ready. +Next: choose one execution mode. +``` + +## Step 5: Confirm and Hand Off + +Use `AskUserQuestion` when available. The options must list Plan & Drive, +Auto-Execute, Verified Loop, Atlas Fleet only when selectable or locked, and +"Show me more detail". Selecting locked Fleet returns one informative reply +with the $29/mo price and https://atlas-ai.au/pro, then re-prompts with free +modes only. + +## Evidence Gate + +**Gate passed: user chose a mode, CLAUDE.md workflow was checked, and handoff +instructions were delivered.** diff --git a/prd_taskmaster/__init__.py b/prd_taskmaster/__init__.py new file mode 100644 index 0000000..ac5695d --- /dev/null +++ b/prd_taskmaster/__init__.py @@ -0,0 +1,3 @@ +"""prd-taskmaster: zero-config goal-to-tasks engine (deterministic CLI core).""" + +__version__ = "4.0.0" diff --git a/prd_taskmaster/_ed25519.py b/prd_taskmaster/_ed25519.py new file mode 100644 index 0000000..188de4e --- /dev/null +++ b/prd_taskmaster/_ed25519.py @@ -0,0 +1,118 @@ +"""Verify-only PureEdDSA Ed25519 implementation. + +Vendored from scripts/license/ed25519_ref.py in this repository, which is a +small stdlib-only RFC 8032 reference implementation for Atlas license keys. +Runtime client code keeps only the public-key verification path. +""" + +import hashlib + + +P = 2**255 - 19 +Q = 2**252 + 27742317777372353535851937790883648493 +D = (-121665 * pow(121666, P - 2, P)) % P +SQRT_M1 = pow(2, (P - 1) // 4, P) + + +def _inv(x: int) -> int: + return pow(x, P - 2, P) + + +def _recover_x(y: int, sign: int) -> int | None: + if y >= P: + return None + x2 = ((y * y - 1) * _inv(D * y * y + 1)) % P + if x2 == 0: + return None if sign else 0 + x = pow(x2, (P + 3) // 8, P) + if (x * x - x2) % P != 0: + x = (x * SQRT_M1) % P + if (x * x - x2) % P != 0: + return None + if (x & 1) != sign: + x = P - x + return x + + +BASE_Y = (4 * _inv(5)) % P +BASE_X = _recover_x(BASE_Y, 0) +if BASE_X is None: # pragma: no cover - impossible for the Ed25519 base point. + raise RuntimeError("failed to recover Ed25519 base point") +BASE = (BASE_X, BASE_Y, 1, (BASE_X * BASE_Y) % P) +IDENTITY = (0, 1, 1, 0) + + +def _sha512(data: bytes) -> bytes: + return hashlib.sha512(data).digest() + + +def _sha512_modq(data: bytes) -> int: + return int.from_bytes(_sha512(data), "little") % Q + + +def _point_add( + p1: tuple[int, int, int, int], + p2: tuple[int, int, int, int], +) -> tuple[int, int, int, int]: + x1, y1, z1, t1 = p1 + x2, y2, z2, t2 = p2 + a = ((y1 - x1) * (y2 - x2)) % P + b = ((y1 + x1) * (y2 + x2)) % P + c = (2 * D * t1 * t2) % P + d = (2 * z1 * z2) % P + e = b - a + f = d - c + g = d + c + h = b + a + return ((e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P) + + +def _point_mul(scalar: int, point: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + result = IDENTITY + addend = point + while scalar: + if scalar & 1: + result = _point_add(result, addend) + addend = _point_add(addend, addend) + scalar >>= 1 + return result + + +def _point_equal( + p1: tuple[int, int, int, int], + p2: tuple[int, int, int, int], +) -> bool: + return ( + (p1[0] * p2[2] - p2[0] * p1[2]) % P == 0 + and (p1[1] * p2[2] - p2[1] * p1[2]) % P == 0 + ) + + +def _point_decompress(encoded: bytes) -> tuple[int, int, int, int] | None: + if len(encoded) != 32: + return None + y = int.from_bytes(encoded, "little") + sign = y >> 255 + y &= (1 << 255) - 1 + x = _recover_x(y, sign) + if x is None: + return None + return (x, y, 1, (x * y) % P) + + +def verify(public: bytes, message: bytes, signature: bytes) -> bool: + """Return True when signature is valid for message under public.""" + if len(public) != 32 or len(signature) != 64: + return False + point_a = _point_decompress(public) + if point_a is None: + return False + encoded_r = signature[:32] + point_r = _point_decompress(encoded_r) + if point_r is None: + return False + s = int.from_bytes(signature[32:], "little") + if s >= Q: + return False + h = _sha512_modq(encoded_r + public + message) + return _point_equal(_point_mul(s, BASE), _point_add(point_r, _point_mul(h, point_a))) diff --git a/prd_taskmaster/batch.py b/prd_taskmaster/batch.py new file mode 100644 index 0000000..fe822fc --- /dev/null +++ b/prd_taskmaster/batch.py @@ -0,0 +1,73 @@ +"""Batched Phase-1 entry point. + +Dogfood finding (2026-06-11): the skill UX shelled out to script.py four +times for Phase 1 (preflight, detect-taskmaster, configure-providers, +detect-providers) spraying raw JSON each time. One call should cover the +whole phase and return a human-presentable summary. +""" + +from prd_taskmaster.capabilities import run_detect_capabilities +from prd_taskmaster.lib import CommandError, emit, fail +from prd_taskmaster.preflight import run_detect_taskmaster, run_preflight +from prd_taskmaster.providers import run_configure_providers, run_detect_providers + + +def run_engine_preflight(configure: bool = True) -> dict: + """Run every Phase-1 probe in one call. + + Read-only on a bare directory: provider configuration is attempted only + when a TaskMaster project already exists (configure_providers refuses + otherwise, and we swallow that refusal into the summary rather than + failing the batch). + """ + preflight = run_preflight() + taskmaster = run_detect_taskmaster() + + providers_configured = None + if configure and preflight.get("has_taskmaster"): + try: + providers_configured = run_configure_providers() + except CommandError as e: + providers_configured = {"ok": False, "error": e.message, **e.extra} + + providers = run_detect_providers() + capabilities = run_detect_capabilities() + + summary = [] + tm_state = taskmaster.get("method", "none") + summary.append( + f"TaskMaster: {tm_state}" + + (f" v{taskmaster['version']}" if taskmaster.get("version") else "") + ) + prov = providers.get("providers", {}) + main = prov.get("main", {}) + research = prov.get("research", {}) + summary.append(f"Provider: {main.get('provider', 'none')} ({main.get('source', '-')})") + summary.append(f"Research: {research.get('provider', 'none')}") + summary.append( + f"Tier: {capabilities.get('tier', 'free')} — {capabilities.get('recommended_reason', '')}" + ) + if preflight.get("has_taskmaster"): + summary.append( + f"Project: {preflight.get('task_count', 0)} tasks" + f" ({preflight.get('tasks_pending', 0)} pending)" + ) + else: + summary.append("Project: fresh (no .taskmaster yet)") + + return { + "ok": True, + "preflight": preflight, + "taskmaster": taskmaster, + "providers": providers, + "providers_configured": providers_configured, + "capabilities": capabilities, + "summary": summary, + } + + +def cmd_engine_preflight(args) -> None: + try: + emit(run_engine_preflight(configure=not getattr(args, "no_configure", False))) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/prd_taskmaster/capabilities.py b/prd_taskmaster/capabilities.py new file mode 100644 index 0000000..30c3373 --- /dev/null +++ b/prd_taskmaster/capabilities.py @@ -0,0 +1,88 @@ +"""Capability detection command.""" + +import argparse +from pathlib import Path + +from prd_taskmaster.lib import ( + CommandError, + emit, + fail, + _detect_taskmaster_method, +) +from prd_taskmaster import license +from prd_taskmaster.mode_recommend import ATLAS_FLEET_REASON, detect_atlas_launcher + + +def run_detect_capabilities() -> dict: + """Scan for available skills, tools, and plugins that enable execution modes.""" + capabilities = {} + + # Check superpowers plugin + superpowers_paths = [ + Path.home() / ".claude" / "plugins" / "cache" / "claude-plugins-official" / "superpowers", + Path.home() / ".claude" / "plugins" / "superpowers", + ] + capabilities["superpowers"] = any(p.is_dir() for p in superpowers_paths) + + # Check specific skills + skills_dir = Path.home() / ".claude" / "skills" + for skill_name in ["cdd", "ralph-loop", "atlas-user-test", "expand-tasks", "phase-executor", "org-tree"]: + skill_path = skills_dir / skill_name / "SKILL.md" + capabilities[skill_name] = skill_path.is_file() + + # Check TaskMaster + tm = _detect_taskmaster_method() + capabilities["taskmaster-mcp"] = tm["method"] == "mcp" + capabilities["taskmaster-cli"] = tm["method"] in ("mcp", "cli") + + atlas_launcher = detect_atlas_launcher() + license_status = license.get_status() + try: + saved_license = license.load_license() + if saved_license is not None and license.should_refresh(saved_license): + license.refresh_if_needed() + license_status = license.get_status() + except Exception: + pass + has_atlas_launcher_premium = ( + atlas_launcher["mcp_registered"] + and license_status["status"] in {"active", "grace"} + ) + + # Determine recommended mode — same decision logic as the handoff skill: + # atlas-launcher MCP -> Atlas Fleet (D); superpowers + loop runner -> + # Verified Loop (C); superpowers only -> Plan & Drive (A); + # taskmaster only -> Auto-Execute (B); fallback -> A. + if has_atlas_launcher_premium: + recommended = "D" + reason = ATLAS_FLEET_REASON + elif capabilities["superpowers"] and capabilities["ralph-loop"]: + recommended = "C" + reason = "Verified Loop — superpowers + loop runner detected (evidence-gated execution)" + elif capabilities["superpowers"]: + recommended = "A" + reason = "Plan & Drive — superpowers detected, no loop runner installed" + elif capabilities["taskmaster-cli"]: + recommended = "B" + reason = "Auto-Execute — TaskMaster CLI available, native loop" + else: + recommended = "A" + reason = "Plan & Drive — universal fallback, no execution tooling detected" + + tier = "premium" if has_atlas_launcher_premium else "free" + + return { + "ok": True, + "tier": tier, + "license_status": license_status, + "capabilities": capabilities, + "recommended_mode": recommended, + "recommended_reason": reason, + } + + +def cmd_detect_capabilities(args: argparse.Namespace) -> None: + try: + emit(run_detect_capabilities()) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/prd_taskmaster/cli.py b/prd_taskmaster/cli.py new file mode 100644 index 0000000..31df7f2 --- /dev/null +++ b/prd_taskmaster/cli.py @@ -0,0 +1,167 @@ +"""CLI: build_parser(), DISPATCH, main().""" + +import argparse + +from prd_taskmaster.lib import fail +from prd_taskmaster.preflight import cmd_preflight, cmd_detect_taskmaster +from prd_taskmaster.providers import cmd_configure_providers, cmd_detect_providers +from prd_taskmaster.capabilities import cmd_detect_capabilities +from prd_taskmaster.license_activate import cmd_license_activate +from prd_taskmaster.templates import cmd_load_template +from prd_taskmaster.validation import cmd_validate_prd, cmd_validate_tasks +from prd_taskmaster.tasks import cmd_calc_tasks, cmd_backup_prd, cmd_enrich_tasks +from prd_taskmaster.taskmaster import cmd_init_taskmaster +from prd_taskmaster.batch import cmd_engine_preflight +from prd_taskmaster import fleet, parallel + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="prd-taskmaster", + description="prd-taskmaster: deterministic operations for the Atlas engine (skill + plugin).", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # preflight + sub.add_parser("preflight", help="Detect environment state") + + # engine-preflight (batched Phase 1: preflight + taskmaster + providers + capabilities) + p = sub.add_parser("engine-preflight", help="One-call Phase 1: all probes + summary") + p.add_argument("--no-configure", action="store_true") + + # detect-taskmaster + sub.add_parser("detect-taskmaster", help="Find MCP or CLI taskmaster") + + # configure-providers + sub.add_parser("configure-providers", help="Configure native TaskMaster providers and local Perplexity API Free") + + # detect-providers + sub.add_parser("detect-providers", help="Auto-detect AI providers") + + # detect-capabilities + sub.add_parser("detect-capabilities", help="Scan for available skills and tools") + + # license-activate + p = sub.add_parser("license-activate", help="Activate an Atlas Pro license key") + p.add_argument("--json", action="store_true", help="Emit machine-readable JSON") + p.add_argument("license_key", help="Atlas license key") + + # load-template + p = sub.add_parser("load-template", help="Load PRD template") + p.add_argument("--type", required=True, choices=["comprehensive", "minimal"]) + + # validate-prd + p = sub.add_parser("validate-prd", help="Run 13 quality checks on a PRD") + p.add_argument("--input", required=True, help="Path to PRD file") + + # init-taskmaster + sub.add_parser( + "init-taskmaster", + help="Run task-master init with .mcp.json protection (raw init overwrites it)", + ) + + # calc-tasks + p = sub.add_parser("calc-tasks", help="Calculate recommended task count") + p.add_argument("--requirements", required=True, type=int, help="Number of functional requirements") + p.add_argument( + "--scale", + choices=["solo", "team", "enterprise"], + default=None, + help="Discovery scale classification; clamps to its band (solo 8-12, team 12-20, enterprise 20-30)", + ) + + # backup-prd + p = sub.add_parser("backup-prd", help="Timestamped PRD backup") + p.add_argument("--input", required=True, help="Path to PRD file") + + # validate-tasks + p = sub.add_parser("validate-tasks", help="Validate manually-authored tasks.json") + p.add_argument( + "--input", + default=None, + help="Path to tasks.json (default: .taskmaster/tasks/tasks.json)", + ) + p.add_argument( + "--allow-empty-subtasks", + action="store_true", + help="Allow tasks without subtasks (manual mode normally requires subtasks)", + ) + p.add_argument( + "--require-phase-config", + action="store_true", + help="Require every task to include phaseConfig metadata after enrich-tasks", + ) + + # enrich-tasks + p = sub.add_parser("enrich-tasks", help="Add phaseConfig metadata to tasks.json") + p.add_argument( + "--input", + default=None, + help="Path to tasks.json (default: .taskmaster/tasks/tasks.json)", + ) + + # ─── parallel research bridge (agent-parallel research fan-out) ─────────── + # parallel-plan + p = sub.add_parser("parallel-plan", help="Emit per-task research packets for parallel subagents") + p.add_argument("--tag") + p.add_argument("--missing-only", action="store_true") + + # parallel-apply + p = sub.add_parser("parallel-apply", help="Merge parallel research results into tasks.json atomically") + p.add_argument("--tag") + p.add_argument("--input", required=True) + p.add_argument("--threshold", type=int, default=7) + + # parallel-extract + p = sub.add_parser("parallel-extract", help="Flatten a tagged tasks.json for validate/enrich") + p.add_argument("--tag") + p.add_argument("--output", required=True) + + # parallel-inject + p = sub.add_parser("parallel-inject", help="Write a validated flat tasks file back into a tag") + p.add_argument("--tag") + p.add_argument("--input", required=True) + + # fleet-waves + p = sub.add_parser("fleet-waves", help="Compute Atlas Fleet dependency waves") + p.add_argument("--concurrency", type=int, default=3) + p.add_argument("--tag", default="") + + return parser + + +DISPATCH = { + "preflight": cmd_preflight, + "engine-preflight": cmd_engine_preflight, + "detect-taskmaster": cmd_detect_taskmaster, + "configure-providers": cmd_configure_providers, + "detect-providers": cmd_detect_providers, + "detect-capabilities": cmd_detect_capabilities, + "license-activate": cmd_license_activate, + "load-template": cmd_load_template, + "validate-prd": cmd_validate_prd, + "calc-tasks": cmd_calc_tasks, + "backup-prd": cmd_backup_prd, + "validate-tasks": cmd_validate_tasks, + "enrich-tasks": cmd_enrich_tasks, + "init-taskmaster": cmd_init_taskmaster, + "parallel-plan": parallel.cmd_plan, + "parallel-apply": parallel.cmd_apply, + "parallel-extract": parallel.cmd_extract, + "parallel-inject": parallel.cmd_inject, + "fleet-waves": fleet.cmd_fleet_waves, +} + + +def main(): + parser = build_parser() + args = parser.parse_args() + handler = DISPATCH.get(args.command) + if handler: + handler(args) + else: + fail(f"Unknown command: {args.command}") + + +if __name__ == "__main__": + main() diff --git a/prd_taskmaster/fleet.py b/prd_taskmaster/fleet.py new file mode 100644 index 0000000..93d9e1b --- /dev/null +++ b/prd_taskmaster/fleet.py @@ -0,0 +1,204 @@ +"""Atlas Fleet dependency scheduler and routing config. + +The scheduler core operates on a plain list of task dicts. The run_* and +cmd_* wrappers resolve TaskMaster tags for CLI/MCP callers. +""" + +import argparse +import json +from pathlib import Path + +from prd_taskmaster import parallel +from prd_taskmaster.lib import CommandError, emit, fail + +# ─── REQ-010: optional .atlas-ai/fleet.json routing config ─────────────────── + +FLEET_CONFIG_PATH = Path(".atlas-ai") / "fleet.json" + +DEFAULT_ROUTING = { + "fast": "claude:haiku", + "standard": "claude:sonnet", + "capable": "claude:opus", +} + +DEFAULT_FLEET_CONFIG = { + "max_concurrency": 3, + "routing": DEFAULT_ROUTING, + "experimental_backends": False, +} + + +def load_fleet_config(path=None): + """Load .atlas-ai/fleet.json merged over defaults. + + Malformed files and invalid values fall back to defaults silently — + a broken optional config must never block a fleet run. + """ + cfg = { + "max_concurrency": DEFAULT_FLEET_CONFIG["max_concurrency"], + "routing": dict(DEFAULT_ROUTING), + "experimental_backends": DEFAULT_FLEET_CONFIG["experimental_backends"], + } + p = Path(path) if path else FLEET_CONFIG_PATH + if not p.is_file(): + return cfg + try: + raw = json.loads(p.read_text()) + except (json.JSONDecodeError, OSError): + return cfg + if not isinstance(raw, dict): + return cfg + + mc = raw.get("max_concurrency") + if isinstance(mc, int) and mc >= 1: + cfg["max_concurrency"] = mc + + routing = raw.get("routing") + if isinstance(routing, dict): + for tier, target in routing.items(): + if isinstance(tier, str) and isinstance(target, str) and ":" in target: + cfg["routing"][tier] = target + + if isinstance(raw.get("experimental_backends"), bool): + cfg["experimental_backends"] = raw["experimental_backends"] + + return cfg + + +def resolve_backend(tier, config): + """Resolve a complexity tier to a backend:model string. + + With experimental_backends false, every tier is forced to Claude + (only Claude has the full session_spawn prompt-injection path). + Unknown tiers resolve to the standard tier. + """ + routing = config.get("routing") or {} + target = routing.get(tier) or routing.get("standard") or DEFAULT_ROUTING["standard"] + if not config.get("experimental_backends", False): + if not target.startswith("claude:"): + default = DEFAULT_ROUTING.get(tier, DEFAULT_ROUTING["standard"]) + target = default if default.startswith("claude:") else DEFAULT_ROUTING["standard"] + return target + + +def _task_id(task): + return task.get("id") + + +def _status(task): + return str(task.get("status", "pending")) + + +def _is_done(task): + return _status(task) == "done" + + +def _is_pending(task): + return _status(task) == "pending" + + +def _dependencies(task): + return task.get("dependencies") or [] + + +def _chunk(items, size): + return [items[index:index + size] for index in range(0, len(items), size)] + + +def _load_tagged_or_raise(tag): + if not parallel.TASKS.is_file(): + raise CommandError(f"{parallel.TASKS} not found") + try: + raw = json.loads(parallel.TASKS.read_text()) + except json.JSONDecodeError as exc: + raise CommandError(f"Failed to parse {parallel.TASKS}: {exc}") from exc + + if tag not in raw or not isinstance(raw.get(tag), dict): + if "tasks" in raw and isinstance(raw["tasks"], list): + return raw, None + raise CommandError(f"tag '{tag}' not found in {parallel.TASKS}") + return raw, tag + + +def ready_set(tasks): + """Return pending task IDs whose dependencies are all done.""" + done_ids = {_task_id(task) for task in tasks if _is_done(task)} + return [ + _task_id(task) + for task in tasks + if _is_pending(task) + and all(dep_id in done_ids for dep_id in _dependencies(task)) + ] + + +def compute_waves(tasks, max_concurrency=3): + """Return dependency-ordered execution waves for pending tasks.""" + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + + completed = {_task_id(task) for task in tasks if _is_done(task)} + remaining = [task for task in tasks if _is_pending(task)] + waves = [] + + while remaining: + frontier = [ + task + for task in remaining + if all(dep_id in completed for dep_id in _dependencies(task)) + ] + if not frontier: + return { + "waves": waves, + "blocked": [_task_id(task) for task in remaining], + "deadlocked": True, + } + + frontier_ids = [_task_id(task) for task in frontier] + waves.extend(_chunk(frontier_ids, max_concurrency)) + completed.update(frontier_ids) + + frontier_objects = {id(task) for task in frontier} + remaining = [ + task for task in remaining + if id(task) not in frontier_objects + ] + + return { + "waves": waves, + "blocked": [], + "deadlocked": False, + } + + +def run_fleet_waves(concurrency=3, tag=""): + """Resolve TaskMaster tasks and return fleet wave scheduling JSON.""" + if concurrency < 1: + raise CommandError("concurrency must be >= 1") + + resolved_tag = parallel.current_tag(argparse.Namespace(tag=tag or None)) + raw, tag_key = _load_tagged_or_raise(resolved_tag) + try: + tasks = parallel.get_tasks(raw, tag_key) + except (KeyError, TypeError) as exc: + raise CommandError( + f"tasks missing for tag '{resolved_tag}' in {parallel.TASKS}" + ) from exc + + waves = compute_waves(tasks, concurrency) + return { + "ok": True, + "tag": resolved_tag, + "waves": waves["waves"], + "blocked": waves["blocked"], + "deadlocked": waves["deadlocked"], + "ready": ready_set(tasks), + "concurrency": concurrency, + } + + +def cmd_fleet_waves(args): + """CLI wrapper for fleet-waves.""" + try: + emit(run_fleet_waves(args.concurrency, getattr(args, "tag", ""))) + except CommandError as exc: + fail(exc.message, **exc.extra) diff --git a/prd_taskmaster/lib.py b/prd_taskmaster/lib.py new file mode 100644 index 0000000..da0a118 --- /dev/null +++ b/prd_taskmaster/lib.py @@ -0,0 +1,389 @@ +"""Shared helpers, path constants, and VAGUE patterns for prd-taskmaster. + +emit()/fail() live here (CLI layer). Pure cores raise CommandError instead of +calling fail(), and return dicts instead of calling emit(). +""" + +import fcntl +import json +import os +import re +import shutil +import socket +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +# ─── Path constants ─────────────────────────────────────────────────────────── +# Templates live at the REPO ROOT (one level above this package dir). +PACKAGE_DIR = Path(__file__).resolve().parent +REPO_ROOT = PACKAGE_DIR.parent +TEMPLATE_DIR = REPO_ROOT / "templates" +TASKMASTER_DIR = Path(".taskmaster") +TASKMASTER_DOCS = TASKMASTER_DIR / "docs" +TASKMASTER_SCRIPTS = TASKMASTER_DIR / "scripts" +TASKMASTER_STATE = TASKMASTER_DIR / "state" +TASKMASTER_TASKS = TASKMASTER_DIR / "tasks" + + +# ─── CLI emit / fail ────────────────────────────────────────────────────────── + +def emit(data: dict) -> None: + """Print JSON to stdout and exit 0.""" + print(json.dumps(data, indent=2, default=str)) + sys.exit(0) + + +def fail(message: str, **extra) -> None: + """Print JSON error to stdout and exit 1.""" + print(json.dumps({"ok": False, "error": message, **extra}, indent=2, default=str)) + sys.exit(1) + + +class CommandError(Exception): + """Raised by pure cores to signal a failure path. + + Carries the message and any extra fields so the CLI wrapper can translate + it into fail(message, **extra) for identical CLI output, while a hosting + process (e.g. MCP server) can catch it without exiting. + """ + + def __init__(self, message: str, extra: dict | None = None) -> None: + super().__init__(message) + self.message = message + self.extra = extra or {} + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +# ─── Stateful-core helpers (atomic / locked writes, JSON IO) ────────────────── +# Ported byte-faithful from the plugin mcp-server/lib.py. All return values are +# dicts/strings — NEVER call sys.exit (per spec §13.3). + +def atomic_write(path: Path, content: str) -> None: + """Write content to path atomically via tmp + os.replace (atomic on POSIX).""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}") + tmp.write_text(content) + os.replace(tmp, path) + + +def locked_update(path: Path, transform: Callable[[str], str]) -> str: + """Read-modify-write under flock. transform takes current content, returns new content. + Returns the new content for convenience.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + lock_path = path.with_suffix(path.suffix + ".lock") + with open(lock_path, "w") as lock_f: + fcntl.flock(lock_f, fcntl.LOCK_EX) + try: + current = path.read_text() if path.exists() else "" + new = transform(current) + atomic_write(path, new) + return new + finally: + fcntl.flock(lock_f, fcntl.LOCK_UN) + + +def emit_json_error(message: str, **extra: Any) -> dict: + """Format an error response as a dict. DO NOT call sys.exit.""" + return {"ok": False, "error": message, **extra} + + +def read_json(path: Path) -> dict: + """Read and parse a JSON file. Returns empty dict if missing.""" + path = Path(path) + if not path.exists(): + return {} + return json.loads(path.read_text()) + + +def write_json(path: Path, data: dict) -> None: + """Write dict as JSON atomically.""" + atomic_write(path, json.dumps(data, indent=2, default=str)) + + +def word_count(text: str) -> int: + return len(text.split()) + + +def count_requirements(text: str) -> int: + """Count REQ-NNN patterns in PRD text.""" + return len(set(re.findall(r'REQ-\d{3}', text))) + + +def has_section(text: str, heading: str) -> bool: + """Check if markdown heading exists (case-insensitive).""" + pattern = r'^#{1,3}\s+.*' + re.escape(heading) + r'.*$' + return bool(re.search(pattern, text, re.MULTILINE | re.IGNORECASE)) + + +def get_section_content(text: str, heading: str) -> str: + """Extract content under a markdown heading until next same-level heading.""" + lines = text.split('\n') + capturing = False + level = 0 + content = [] + heading_re = re.compile(r'^(#{1,6})\s+(.*)') + for line in lines: + heading_match = heading_re.match(line) + if heading_match and heading.lower() in heading_match.group(2).lower(): + capturing = True + level = len(heading_match.group(1)) + continue + if capturing: + if heading_match and len(heading_match.group(1)) <= level: + break + content.append(line) + return '\n'.join(content).strip() + + +# ─── VAGUE_PATTERNS for validation ─────────────────────────────────────────── + +VAGUE_WORDS = [ + "fast", "quick", "slow", "good", "bad", "poor", + "user-friendly", "easy", "simple", "secure", "safe", + "scalable", "flexible", "performant", "efficient", +] + +VAGUE_PATTERN = re.compile( + r'\b(?:should\s+be\s+|must\s+be\s+|needs?\s+to\s+be\s+)?' + r'(' + '|'.join(VAGUE_WORDS) + r')\b', + re.IGNORECASE +) + + +# ─── Shared taskmaster detection / config / state helpers ───────────────────── + +def _detect_taskmaster_method() -> dict: + """Detect taskmaster: MCP > CLI > none.""" + # Check CLI + cli_cmd = None + cli_path = None + for candidate in ("taskmaster", "task-master"): + candidate_path = shutil.which(candidate) + if candidate_path: + cli_cmd = candidate + cli_path = candidate_path + break + cli_version = None + if cli_cmd: + try: + result = subprocess.run( + [cli_cmd, "--version"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + # task-master --version can print telemetry notices around the + # semver — extract just the version number. + import re as _re + m = _re.search(r"\d+\.\d+\.\d+", result.stdout) + cli_version = m.group(0) if m else result.stdout.strip().splitlines()[-1] + else: + cli_version = None + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # MCP detection: check if mcp__task-master-ai tools are available + # We can't directly test MCP from a script, so we check for config hints + mcp_available = False + for config_path in [ + Path.home() / ".claude.json", + Path.home() / ".claude" / "settings.json", + Path.home() / ".claude" / "settings" / "mcp.json", + Path.home() / ".config" / "claude-code" / "mcp.json", + Path(".mcp.json"), + ]: + if config_path.is_file(): + try: + with open(config_path) as f: + config = json.load(f) + servers = config.get("mcpServers", config.get("servers", {})) + if any("task-master" in k.lower() for k in servers): + mcp_available = True + break + except (json.JSONDecodeError, KeyError): + pass + + if mcp_available: + return {"method": "mcp", "version": None, "path": None} + elif cli_path: + return {"method": "cli", "version": cli_version, "path": cli_path, "command": cli_cmd} + else: + return {"method": "none", "version": None, "path": None} + + +def _read_taskmaster_model(role: str) -> dict: + """Read one role from .taskmaster/config.json, if present.""" + config_path = TASKMASTER_DIR / "config.json" + if not config_path.is_file(): + return {} + try: + with open(config_path) as f: + config = json.load(f) + except json.JSONDecodeError: + return {} + model = config.get("models", {}).get(role, {}) + return model if isinstance(model, dict) else {} + + +def _read_taskmaster_config() -> dict: + """Read .taskmaster/config.json if it exists.""" + config_path = TASKMASTER_DIR / "config.json" + if not config_path.is_file(): + return {} + try: + with open(config_path) as f: + config = json.load(f) + except json.JSONDecodeError: + return {} + return config if isinstance(config, dict) else {} + + +def _write_taskmaster_config(config: dict) -> None: + """Atomically write .taskmaster/config.json.""" + config_path = TASKMASTER_DIR / "config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = config_path.with_suffix(".json.tmp") + tmp_path.write_text(json.dumps(config, indent=2, default=str) + "\n") + tmp_path.replace(config_path) + + +def _local_port_open(host: str = "127.0.0.1", port: int = 8765, timeout: float = 0.25) -> bool: + """Return whether a local TCP endpoint is accepting connections.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _env_file_has_key(env_path: Path, key: str) -> bool: + if not env_path.is_file(): + return False + pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(key)}\s*=", re.MULTILINE) + return bool(pattern.search(env_path.read_text())) + + +def _ensure_env_entry(env_path: Path, key: str, value: str, comment: str | None = None) -> bool: + """Append a missing non-secret local env entry. Returns True when changed.""" + if _env_file_has_key(env_path, key): + return False + env_path.parent.mkdir(parents=True, exist_ok=True) + prefix = "\n" if env_path.is_file() and env_path.read_text() and not env_path.read_text().endswith("\n") else "" + suffix = f' # {comment}' if comment else "" + with open(env_path, "a") as f: + f.write(f'{prefix}{key}="{value}"{suffix}\n') + return True + + +def _read_env_file_value(env_path: Path, key: str) -> str | None: + """Read a simple KEY=value entry from an env file.""" + if not env_path.is_file(): + return None + pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(key)}\s*=\s*['\"]?([^'\"\n#]+)", re.MULTILINE) + match = pattern.search(env_path.read_text()) + return match.group(1).strip() if match else None + + +def _detect_perplexity_mcp() -> str | None: + """Return the detected Perplexity MCP flavor, if configured.""" + for config_path in [ + Path.home() / ".claude.json", + Path.home() / ".claude" / "settings.json", + Path.home() / ".claude" / "settings" / "mcp.json", + Path.home() / ".config" / "claude-code" / "mcp.json", + Path(".mcp.json"), + ]: + if config_path.is_file(): + try: + with open(config_path) as f: + config = json.load(f) + servers = config.get("mcpServers", config.get("servers", {})) + except (json.JSONDecodeError, KeyError): + continue + names = [str(name).lower() for name in servers] + if any("perplexity-api-free" in name for name in names): + return "perplexity-api-free-mcp" + if any("perplexity" in name for name in names): + return "perplexity-mcp" + return None + + +def _is_local_perplexity_free(model: dict) -> bool: + """Detect TaskMaster research config pointed at the local free Perplexity proxy.""" + provider = str(model.get("provider", "")).lower() + base_url = str( + model.get("baseURL") + or os.environ.get("PERPLEXITY_API_BASE_URL") + or os.environ.get("PERPLEXITY_API_FREE_BASE_URL") + or "" + ).lower() + local_proxy = ( + "127.0.0.1:8765" in base_url + or "localhost:8765" in base_url + or "perplexity-api-free" in base_url + ) + return provider in {"openai-compatible", "perplexity"} and local_proxy + + +def _current_taskmaster_tag() -> str: + """Read the active TaskMaster tag from .taskmaster/state.json.""" + state_path = TASKMASTER_DIR / "state.json" + if state_path.is_file(): + try: + with open(state_path) as f: + state = json.load(f) + tag = state.get("currentTag") + if isinstance(tag, str) and tag: + return tag + except json.JSONDecodeError: + pass + return "master" + + +def _resolve_tasks_payload(raw: object) -> tuple[list | None, object]: + """Return the active task list and write-back wrapper for flat or tagged TaskMaster files.""" + if isinstance(raw, list): + return raw, {"tasks": raw} + if not isinstance(raw, dict): + return None, raw + if isinstance(raw.get("tasks"), list): + return raw["tasks"], raw + + tag = _current_taskmaster_tag() + tagged = raw.get(tag) + if isinstance(tagged, dict) and isinstance(tagged.get("tasks"), list): + return tagged["tasks"], raw + + for value in raw.values(): + if isinstance(value, dict) and isinstance(value.get("tasks"), list): + return value["tasks"], raw + return None, raw + + +def _read_execution_state() -> dict: + """Read crash recovery state from .taskmaster/state/execution-state.json.""" + state_file = TASKMASTER_STATE / "execution-state.json" + if not state_file.is_file(): + return {"has_incomplete": False} + try: + with open(state_file) as f: + state = json.load(f) + return { + "has_incomplete": state.get("status") == "in_progress", + "last_task": state.get("current_task"), + "last_subtask": state.get("current_subtask"), + "mode": state.get("mode"), + "last_updated": state.get("last_updated"), + "checkpoint": state.get("last_checkpoint"), + } + except (json.JSONDecodeError, KeyError): + return {"has_incomplete": False} diff --git a/prd_taskmaster/license.py b/prd_taskmaster/license.py new file mode 100644 index 0000000..3a607df --- /dev/null +++ b/prd_taskmaster/license.py @@ -0,0 +1,346 @@ +"""Offline Atlas Pro license parsing and verification.""" + +import base64 as _base64 +import json as _json +import math as _math +import os as _os +import re as _re +import time as _time +from pathlib import Path as _Path +from typing import Any as _Any + +from prd_taskmaster import _ed25519 + + +_KEY_PREFIX = "ATLAS-" +_PAYLOAD_FIELDS = ("lid", "sub", "plan", "iat", "exp", "v") +_PLANS = ("pro-monthly", "pro-annual") +PUBLIC_KEY = bytes.fromhex("1fc868c32afba550e6db6db038302a6bd83fbbd848a87191f3a03bdcccf7e88d") +_GRACE_SECONDS = 14 * 24 * 60 * 60 +_REFRESH_WINDOW_SECONDS = 21 * 24 * 60 * 60 +_REFRESH_RETRY_SECONDS = 24 * 60 * 60 +_REFRESH_URL = "https://api.atlas-ai.au/license/refresh" +_LAST_REFRESH_FIELD = "last_refresh_attempt" +_LICENSE_DIRNAME = ".atlas-ai" +_LICENSE_FILENAME = "license.json" +_B64URL_RE = _re.compile(r"^[A-Za-z0-9_-]+$") +_HEX64_RE = _re.compile(r"^[0-9a-f]{64}$") + + +def _invalid(detail: str, payload: dict[str, _Any] | None = None) -> dict[str, _Any]: + return { + "ok": False, + "status": "invalid", + "detail": detail, + "payload": payload, + "payload_bytes": None, + "signature": None, + } + + +def _canonical_payload_bytes(payload: dict[str, _Any]) -> bytes: + return _json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +def _b64url_decode(value: str) -> bytes: + if not value or "=" in value or _B64URL_RE.fullmatch(value) is None: + raise ValueError("invalid base64url segment") + padded = value + ("=" * (-len(value) % 4)) + return _base64.urlsafe_b64decode(padded.encode("ascii")) + + +def _validate_payload(payload: dict[str, _Any]) -> str | None: + if set(payload) != set(_PAYLOAD_FIELDS): + return "payload fields must be exactly lid, sub, plan, iat, exp, v" + if not isinstance(payload["lid"], str) or not payload["lid"]: + return "lid must be a non-empty string" + if not isinstance(payload["sub"], str) or _HEX64_RE.fullmatch(payload["sub"]) is None: + return "sub must be a lowercase SHA-256 hex digest" + if payload["plan"] not in _PLANS: + return f"plan must be one of: {', '.join(_PLANS)}" + if not isinstance(payload["iat"], int) or not isinstance(payload["exp"], int): + return "iat and exp must be integer Unix timestamps" + if payload["exp"] < payload["iat"]: + return "exp must be greater than or equal to iat" + if payload["v"] != 1: + return "v must be 1" + return None + + +def parse_key(key_str: str) -> dict[str, _Any]: + """Parse an Atlas license key into payload bytes and signature bytes.""" + if not isinstance(key_str, str) or not key_str.startswith(_KEY_PREFIX): + return _invalid("license key must start with ATLAS-") + + body = key_str[len(_KEY_PREFIX):] + if body.count(".") != 1: + return _invalid("license key must contain one payload/signature separator") + + payload_segment, signature_segment = body.split(".", 1) + try: + payload_bytes = _b64url_decode(payload_segment) + signature = _b64url_decode(signature_segment) + except (ValueError, _base64.binascii.Error): + return _invalid("invalid base64url data") + + if len(signature) != 64: + return _invalid("Ed25519 signature must be 64 bytes") + + try: + payload = _json.loads(payload_bytes.decode("utf-8")) + except (UnicodeDecodeError, _json.JSONDecodeError): + return _invalid("payload must be UTF-8 JSON") + + if not isinstance(payload, dict): + return _invalid("payload must be a JSON object") + + detail = _validate_payload(payload) + if detail: + return _invalid(detail, payload) + + if payload_bytes != _canonical_payload_bytes(payload): + return _invalid("payload JSON is not canonical", payload) + + return { + "ok": True, + "status": "parsed", + "detail": "parsed", + "payload": payload, + "payload_bytes": payload_bytes, + "signature": signature, + "key": key_str, + } + + +def verify_signature(payload: bytes, sig: bytes, pubkey: bytes | None = None) -> bool: + """Return True when sig verifies for payload under pubkey.""" + pubkey = PUBLIC_KEY if pubkey is None else pubkey + try: + return _ed25519.verify(pubkey, payload, sig) + except Exception: + return False + + +def _invalid_status(detail: str) -> dict[str, _Any]: + return {"status": "invalid", "days_remaining": None, "detail": detail} + + +def get_status(key_str: str | dict[str, _Any] | None = None, *, now: int | None = None) -> dict[str, _Any]: + """Return active, grace, expired, or invalid status for a license key.""" + if key_str is None: + parsed = load_license() + if parsed is None: + return _invalid_status("no license saved") + else: + parsed = key_str if isinstance(key_str, dict) else parse_key(key_str) + + if not parsed.get("ok"): + return _invalid_status(parsed.get("detail", "invalid license key")) + + if not verify_signature(parsed["payload_bytes"], parsed["signature"], PUBLIC_KEY): + return _invalid_status("signature mismatch") + + current = int(_time.time() if now is None else now) + exp = int(parsed["payload"]["exp"]) + period_end = exp - _GRACE_SECONDS + + if current <= period_end: + return {"status": "active", "days_remaining": None, "detail": "license active"} + if current <= exp: + seconds_remaining = max(0, exp - current) + days_remaining = _math.ceil(seconds_remaining / 86400) + return { + "status": "grace", + "days_remaining": days_remaining, + "detail": f"license in grace period ({days_remaining} days remaining)", + } + return {"status": "expired", "days_remaining": 0, "detail": "license expired"} + + +def _license_path() -> _Path: + return _Path.home() / _LICENSE_DIRNAME / _LICENSE_FILENAME + + +def _read_license_store() -> dict[str, _Any]: + path = _license_path() + if not path.is_file(): + return {} + try: + data = _json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, _json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_license_store(data: dict[str, _Any]) -> None: + path = _license_path() + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + _os.chmod(path.parent, 0o700) + except OSError: + pass + + payload = _json.dumps(data, indent=2) + flags = _os.O_WRONLY | _os.O_CREAT | _os.O_TRUNC + fd = _os.open(path, flags, 0o600) + try: + with _os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(payload) + f.write("\n") + finally: + try: + _os.chmod(path, 0o600) + except OSError: + pass + + +def save_license(key_str: str, *, now: int | None = None) -> dict[str, _Any]: + """Persist a verified active or grace license under ~/.atlas-ai/license.json.""" + status = get_status(key_str, now=now) + if status["status"] not in {"active", "grace"}: + return {"ok": False, **status} + + path = _license_path() + _write_license_store({"key": key_str}) + + return {"ok": True, **status, "path": str(path)} + + +def load_license() -> dict[str, _Any] | None: + """Load and parse the saved license key, returning None on missing/corrupt data.""" + data = _read_license_store() + key_str = data.get("key") if isinstance(data, dict) else None + if not isinstance(key_str, str): + return None + parsed = parse_key(key_str) + if not parsed.get("ok"): + return None + last_attempt = data.get(_LAST_REFRESH_FIELD) + if isinstance(last_attempt, int): + parsed[_LAST_REFRESH_FIELD] = last_attempt + return parsed + + +def _record_refresh_attempt(now: int) -> None: + data = _read_license_store() + if not isinstance(data.get("key"), str): + return + data[_LAST_REFRESH_FIELD] = int(now) + _write_license_store(data) + + +def _log_refresh_failure(detail: str) -> None: + try: + print(f"atlas license refresh skipped: {detail}", file=__import__("sys").stderr) + except Exception: + pass + + +def _should_refresh(license_obj: dict[str, _Any] | None, now: int | None = None) -> bool: + current = int(_time.time() if now is None else now) + if not isinstance(license_obj, dict) or not license_obj.get("ok"): + return False + + status = get_status(license_obj, now=current) + if status["status"] not in {"active", "grace"}: + return False + + payload = license_obj.get("payload") + exp = payload.get("exp") if isinstance(payload, dict) else None + if not isinstance(exp, int) or exp - current > _REFRESH_WINDOW_SECONDS: + return False + + last_attempt = license_obj.get(_LAST_REFRESH_FIELD) + if isinstance(last_attempt, int) and current - last_attempt < _REFRESH_RETRY_SECONDS: + return False + + return True + + +def _http_error_reason(exc: _Any) -> str: + code = getattr(exc, "code", None) + reason = f"http_{code}" if code is not None else "http_error" + try: + body = exc.read() + if isinstance(body, bytes): + body = body.decode("utf-8") + data = _json.loads(body) + if isinstance(data, dict) and isinstance(data.get("reason"), str): + return data["reason"] + except Exception: + pass + return reason + + +def _refresh_if_needed(*, now: int | None = None) -> str: + current = int(_time.time() if now is None else now) + if _os.environ.get("ATLAS_TELEMETRY") == "0": + return "skipped" + + saved = load_license() + if not _should_refresh(saved, now=current): + return "skipped" + + lid = saved["payload"]["lid"] + _record_refresh_attempt(current) + + request_mod = __import__("urllib.request", fromlist=["Request", "urlopen"]) + error_mod = __import__("urllib.error", fromlist=["HTTPError", "URLError"]) + socket_mod = __import__("socket") + body = _json.dumps({"lid": lid}).encode("utf-8") + request = request_mod.Request( + _REFRESH_URL, + data=body, + method="POST", + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + + try: + with request_mod.urlopen(request, timeout=5) as response: + response_body = response.read() + except error_mod.HTTPError as exc: + _log_refresh_failure(_http_error_reason(exc)) + return "failed" + except (error_mod.URLError, socket_mod.timeout, TimeoutError, OSError, ConnectionError) as exc: + _log_refresh_failure(str(exc)) + return "failed" + except Exception as exc: + _log_refresh_failure(str(exc)) + return "failed" + + try: + data = _json.loads(response_body.decode("utf-8")) + except (AttributeError, UnicodeDecodeError, _json.JSONDecodeError): + _log_refresh_failure("invalid refresh response") + return "failed" + + new_key = data.get("key") if isinstance(data, dict) else None + if not isinstance(new_key, str): + _log_refresh_failure("refresh response missing key") + return "failed" + + parsed = parse_key(new_key) + if not parsed.get("ok") or not verify_signature(parsed["payload_bytes"], parsed["signature"], PUBLIC_KEY): + _log_refresh_failure("refreshed key failed verification") + return "failed" + + saved_result = save_license(new_key, now=current) + if not saved_result.get("ok"): + _log_refresh_failure(saved_result.get("detail", "refreshed key rejected")) + return "failed" + + _record_refresh_attempt(current) + return "refreshed" + + +def __getattr__(name: str) -> _Any: + if name == "should_refresh": + return _should_refresh + if name == "refresh_if_needed": + return _refresh_if_needed + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/prd_taskmaster/license_activate.py b/prd_taskmaster/license_activate.py new file mode 100644 index 0000000..f609954 --- /dev/null +++ b/prd_taskmaster/license_activate.py @@ -0,0 +1,78 @@ +"""CLI surface for Atlas license activation.""" + +import argparse +import json +import sys +from typing import Any + +from prd_taskmaster import license +from prd_taskmaster.lib import emit + + +def _activation_result(key_str: str) -> dict[str, Any]: + parsed = license.parse_key(key_str) + payload = parsed.get("payload") if parsed.get("ok") else None + status = license.get_status(parsed if parsed.get("ok") else key_str) + + result: dict[str, Any] = { + "ok": status["status"] in {"active", "grace"}, + "status": status["status"], + "days_remaining": status["days_remaining"], + "detail": status["detail"], + "plan": payload.get("plan") if isinstance(payload, dict) else None, + "license_id": payload.get("lid") if isinstance(payload, dict) else None, + } + + if result["ok"]: + saved = license.save_license(key_str) + result["path"] = saved.get("path") + if not saved.get("ok"): + result.update({ + "ok": False, + "status": saved.get("status", "invalid"), + "days_remaining": saved.get("days_remaining"), + "detail": saved.get("detail", "license could not be saved"), + }) + + return result + + +def _render_status_block(result: dict[str, Any]) -> str: + activated = result.get("ok") is True + happened = ( + "Atlas Pro license activated." + if activated + else "Atlas Pro license was not activated." + ) + plan = result.get("plan") or "unknown" + status = result.get("status") or "invalid" + days = result.get("days_remaining") + days_part = f" · {days} grace days remaining" if status == "grace" else "" + detail = result.get("detail") or status + next_step = ( + "run `prd-taskmaster detect-capabilities` to confirm premium tier." + if activated + else "check the key and run `prd-taskmaster license-activate <key>` again." + ) + + return "\n".join([ + "┌─ atlas ── license-activate ─┐", + f"│ What happened: {happened}", + f"│ Evidence: plan {plan} · status {status}{days_part}", + f"│ Detail: {detail}", + "└──────────────────────────────┘", + f"Next: {next_step}", + ]) + + +def cmd_license_activate(args: argparse.Namespace) -> None: + result = _activation_result(args.license_key) + if getattr(args, "json", False): + if result.get("ok"): + emit(result) + print(json.dumps(result, indent=2, default=str)) + sys.exit(1) + + print(_render_status_block(result)) + if not result.get("ok"): + sys.exit(1) diff --git a/prd_taskmaster/mode_recommend.py b/prd_taskmaster/mode_recommend.py new file mode 100644 index 0000000..b1f3ee6 --- /dev/null +++ b/prd_taskmaster/mode_recommend.py @@ -0,0 +1,511 @@ +""" +Capability detection for the PRD-TaskMaster plugin. + +Ported from prd-taskmaster-v4 script.py (cmd_detect_taskmaster, +cmd_detect_capabilities, cmd_validate_setup). + +All functions return dicts — never terminate the process (per spec §13.3). +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from prd_taskmaster import license +from prd_taskmaster.lib import emit_json_error + +# --------------------------------------------------------------------------- +# Constants (mirrored from v4 script.py) +# --------------------------------------------------------------------------- + +TASKMASTER_DIR = Path(".taskmaster") +TASKMASTER_MIN_VERSION = "0.43.0" +ATLAS_FLEET_REASON = "Atlas Fleet — atlas-launcher detected (parallel multi-session execution)" + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _parse_version(v: str) -> tuple[int, ...]: + """Parse a semver-ish string into a comparable tuple. + + Strips leading 'v' and ignores any pre-release suffix after '-'. + Returns (0, 0, 0) on parse failure so comparison is always safe. + """ + try: + v = v.strip().lstrip("v").split("-")[0] + return tuple(int(x) for x in v.split(".")) + except Exception: + return (0, 0, 0) + + +def _check_taskmaster_version(cli_path: str | None) -> dict: + """Probe the task-master binary for version and compare against minimum. + + Returns: + detected_version: str | None + supported: bool — True if >= TASKMASTER_MIN_VERSION + warning: str | None + """ + if not cli_path: + return { + "detected_version": None, + "supported": False, + "warning": "task-master binary not found", + } + try: + result = subprocess.run( + [cli_path, "--version"], + capture_output=True, text=True, timeout=5, + ) + detected = result.stdout.strip() if result.returncode == 0 else None + except (subprocess.TimeoutExpired, FileNotFoundError): + detected = None + + if not detected: + return { + "detected_version": None, + "supported": False, + "warning": "could not read task-master --version output", + } + + dv = _parse_version(detected) + mv = _parse_version(TASKMASTER_MIN_VERSION) + supported = dv >= mv + + return { + "detected_version": detected, + "minimum_version": TASKMASTER_MIN_VERSION, + "supported": supported, + "warning": ( + None if supported + else f"task-master {detected} is older than minimum supported {TASKMASTER_MIN_VERSION}. Upgrade: npm install -g task-master-ai@latest" + ), + } + + +def _safe_call(fn) -> bool: + """Run a detection lambda, swallowing exceptions as False.""" + try: + return bool(fn()) + except Exception: + return False + + +def _mcp_config_has_server( + config_path: Path, + server_name: str, + *, + allow_top_level: bool = False, +) -> bool: + """Return True when an MCP config declares the exact server key.""" + if not config_path.is_file(): + return False + + try: + with open(config_path) as f: + config = json.load(f) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return False + + if not isinstance(config, dict): + return False + + servers = config.get("mcpServers") + if isinstance(servers, dict) and server_name in servers: + return True + + return allow_top_level and server_name in config + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def detect_atlas_launcher() -> dict: + """Detect the Atlas Fleet launcher binary and MCP registration.""" + installed = bool( + shutil.which("atlas-launcher") + or shutil.which("atlas") + ) + + config_paths: list[tuple[Path, bool]] = [] + try: + config_paths.append((Path.home() / ".claude.json", False)) + except RuntimeError: + pass + config_paths.append((Path(".mcp.json"), True)) + + mcp_registered = any( + _mcp_config_has_server( + config_path, + "atlas-launcher", + allow_top_level=allow_top_level, + ) + for config_path, allow_top_level in config_paths + ) + + return { + "installed": installed, + "mcp_registered": mcp_registered, + } + + +def detect_taskmaster() -> dict: + """Detect taskmaster availability: MCP > CLI > none. + + Returns: + ok: True + method: "cli" | "mcp" | "none" + version: str | None (CLI version string, if found) + path: str | None (resolved CLI path, if found) + """ + # Check CLI — all three known entry-point names. + cli_path = ( + shutil.which("task-master") + or shutil.which("task-master-ai") + or shutil.which("taskmaster") + ) + cli_version: str | None = None + if cli_path: + try: + result = subprocess.run( + [cli_path, "--version"], + capture_output=True, text=True, timeout=10, + ) + cli_version = result.stdout.strip() if result.returncode == 0 else None + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # MCP detection: look for task-master-ai entry in known config files. + mcp_available = False + for config_path in [ + Path.home() / ".claude" / "settings" / "mcp.json", + Path.home() / ".config" / "claude-code" / "mcp.json", + Path(".mcp.json"), + ]: + if config_path.is_file(): + try: + with open(config_path) as f: + config = json.load(f) + servers = config.get("mcpServers", config.get("servers", {})) + if any("task-master" in k.lower() for k in servers): + mcp_available = True + break + except (json.JSONDecodeError, KeyError, OSError): + pass + + if mcp_available: + return {"ok": True, "method": "mcp", "version": None, "path": None} + elif cli_path: + return {"ok": True, "method": "cli", "version": cli_version, "path": cli_path} + else: + return {"ok": True, "method": "none", "version": None, "path": None} + + +def detect_capabilities() -> dict: + """Scan for available skills, tools, plugins, and external AI tools. + + v4 is designed to be tool-agnostic. Detection covers: + - Claude Code plugins (superpowers, atlas-*) + - Claude Code skills (evidence cards, loop runner, phase-executor, etc.) + - External AI tools (Cursor, RooCode, Codex CLI, Gemini CLI, etc.) + - TaskMaster (CLI or MCP) + + Returns: + ok: True + tier: "free" | "premium" + has_superpowers: bool + has_taskmaster: bool + has_atlas_premium: bool + capabilities: dict (per-tool flags) + recommended_mode: str + recommended_reason: str + alternative_modes: list + has_external_ai_tools: bool + """ + capabilities: dict[str, Any] = {} + + # ── Claude Code plugin detection ────────────────────────────────── + superpowers_paths = [ + Path.home() / ".claude" / "plugins" / "cache" / "claude-plugins-official" / "superpowers", + Path.home() / ".claude" / "plugins" / "superpowers", + ] + capabilities["superpowers"] = any(p.is_dir() for p in superpowers_paths) + + # ── Claude Code skill detection ─────────────────────────────────── + skills_dir = Path.home() / ".claude" / "skills" + skill_names = [ + "cdd", "ralph-loop", "atlas-user-test", "expand-tasks", + "phase-executor", "org-tree", + # Atlas premium markers + "atlas-loop", "atlas-cdd", "atlas-plan", "atlas-gamify", + # Customisation (v4+) + "customise-workflow", + ] + for skill_name in skill_names: + skill_path = skills_dir / skill_name / "SKILL.md" + capabilities[skill_name] = skill_path.is_file() + + # ── External AI tool detection (v4: tool-agnostic) ──────────────── + external_tools: dict[str, list] = { + "cursor": [ + lambda: bool(shutil.which("cursor")), + lambda: (Path.home() / ".cursor").is_dir(), + lambda: (Path.home() / ".config" / "Cursor").is_dir(), + ], + "codex-cli": [ + lambda: bool(shutil.which("codex")), + lambda: bool(shutil.which("openai-codex")), + ], + "gemini-cli": [ + lambda: bool(shutil.which("gemini")), + ], + "roo-code": [ + lambda: bool(shutil.which("roo")), + lambda: bool(shutil.which("roocode")), + lambda: (Path.home() / ".vscode" / "extensions").is_dir() + and any( + "roo" in p.name.lower() + for p in (Path.home() / ".vscode" / "extensions").glob("*") + ) if (Path.home() / ".vscode" / "extensions").is_dir() else False, + ], + "coderabbit": [ + lambda: bool(shutil.which("coderabbit")), + lambda: bool(shutil.which("cr")), + ], + "aider": [ + lambda: bool(shutil.which("aider")), + ], + "continue": [ + lambda: (Path.home() / ".continue").is_dir(), + ], + } + for tool_name, strategies in external_tools.items(): + capabilities[tool_name] = any(_safe_call(s) for s in strategies) + + # ── TaskMaster detection ────────────────────────────────────────── + tm = detect_taskmaster() + capabilities["taskmaster-mcp"] = tm["method"] == "mcp" + capabilities["taskmaster-cli"] = tm["method"] in ("mcp", "cli") + + # ── Derive tier flags ───────────────────────────────────────────── + atlas_launcher = detect_atlas_launcher() + license_status = license.get_status() + has_atlas_launcher_premium = ( + atlas_launcher["mcp_registered"] + and license_status["status"] in {"active", "grace"} + ) + has_atlas_premium = has_atlas_launcher_premium + has_free_ralph_stack = ( + capabilities.get("superpowers", False) + and capabilities.get("ralph-loop", False) + ) + has_external_ai_tools = any( + capabilities.get(t, False) + for t in ("cursor", "codex-cli", "gemini-cli", "roo-code", "aider", "continue") + ) + + # ── Mode recommendation ─────────────────────────────────────────── + if has_atlas_launcher_premium: + recommended = "D" + reason = ATLAS_FLEET_REASON + elif has_free_ralph_stack: + recommended = "C" + reason = "Verified Loop (recommended free) — superpowers + loop runner detected" + elif capabilities.get("superpowers"): + recommended = "A" + reason = "Plan & Drive — superpowers detected, no execution loop" + elif capabilities.get("taskmaster-cli"): + recommended = "B" + reason = "Auto-Execute — TaskMaster CLI available, native loop" + else: + recommended = "A" + reason = "Plan & Drive — universal fallback" + + alternative_modes = [] + if capabilities.get("cursor"): + alternative_modes.append({"mode": "E", "tool": "cursor", "description": "Cursor Composer handoff"}) + if capabilities.get("roo-code"): + alternative_modes.append({"mode": "F", "tool": "roo-code", "description": "RooCode agent handoff"}) + if capabilities.get("codex-cli"): + alternative_modes.append({"mode": "G", "tool": "codex-cli", "description": "Codex CLI auto-execute"}) + if capabilities.get("gemini-cli"): + alternative_modes.append({"mode": "H", "tool": "gemini-cli", "description": "Gemini CLI orchestrator"}) + if capabilities.get("coderabbit"): + alternative_modes.append({"mode": "I", "tool": "coderabbit", "description": "CodeRabbit PR review loop"}) + if capabilities.get("aider"): + alternative_modes.append({"mode": "J", "tool": "aider", "description": "Aider pair-programming"}) + + return { + "ok": True, + "tier": "premium" if has_atlas_premium else "free", + "license_status": license_status, + "has_superpowers": capabilities.get("superpowers", False), + "has_taskmaster": capabilities.get("taskmaster-cli", False), + "has_atlas_premium": has_atlas_premium, + "capabilities": capabilities, + "has_external_ai_tools": has_external_ai_tools, + "recommended_mode": recommended, + "recommended_reason": reason, + "alternative_modes": alternative_modes, + } + + +def validate_setup() -> dict: + """Run all Phase 0 SETUP checks and return per-check pass/fail + fix hints. + + Returns EXACTLY 6 checks (spec §5): + binary — task-master CLI installed + version — task-master version >= TASKMASTER_MIN_VERSION + project — .taskmaster/ directory exists + config — .taskmaster/config.json present + provider_main — main model configured + provider_research — research model configured (soft check) + + Returns: + ok: True + ready: bool + critical_failures: int + checks: list[dict] — exactly 6 entries, each with id, passed, fix + """ + checks = [] + + # Check 1: task-master binary installed + cli_path = ( + shutil.which("task-master") + or shutil.which("task-master-ai") + or shutil.which("taskmaster") + ) + cli_version: str | None = None + if cli_path: + try: + result = subprocess.run( + [cli_path, "--version"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + cli_version = result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + checks.append({ + "id": "binary", + "name": "task-master CLI installed", + "passed": bool(cli_path), + "detail": ( + f"Found at {cli_path} (version {cli_version})" if cli_path + else "Not found in PATH" + ), + "fix": "npm install -g task-master-ai" if not cli_path else None, + }) + + # Check 2: version >= minimum + version_info = _check_taskmaster_version(cli_path) + checks.append({ + "id": "version", + "name": f"task-master version >= {TASKMASTER_MIN_VERSION}", + "passed": version_info["supported"], + "detail": ( + f"detected {version_info['detected_version']} (min {TASKMASTER_MIN_VERSION})" + if version_info.get("detected_version") + else "version not detectable" + ), + "fix": "npm install -g task-master-ai@latest" if not version_info["supported"] else None, + "severity": "warning", + }) + + # Check 3: .taskmaster/ directory exists + has_tm_dir = TASKMASTER_DIR.is_dir() + checks.append({ + "id": "project", + "name": ".taskmaster/ project initialized", + "passed": has_tm_dir, + "detail": f".taskmaster/ {'exists' if has_tm_dir else 'missing'}", + "fix": "task-master init --yes" if not has_tm_dir else None, + }) + + # Check 4: config.json present + config_file = TASKMASTER_DIR / "config.json" + has_config = config_file.is_file() + checks.append({ + "id": "config", + "name": ".taskmaster/config.json present", + "passed": has_config, + "detail": ( + "config.json found" if has_config + else "config.json missing — project may not be fully initialized" + ), + "fix": "task-master init --yes" if not has_config else None, + }) + + # Check 5: main model configured + provider_ok = False + provider_detail = "Cannot read config" + main_model: str | None = None + research_model: str | None = None + if has_config: + try: + with open(config_file) as f: + cfg = json.load(f) + models = cfg.get("models", {}) + main_model = models.get("main", {}).get("modelId") + research_model = models.get("research", {}).get("modelId") + fallback_model = models.get("fallback", {}).get("modelId") + provider_ok = bool(main_model) + provider_detail = ( + f"main={main_model or 'unset'}, " + f"research={research_model or 'unset'}, " + f"fallback={fallback_model or 'unset'}" + ) + except (json.JSONDecodeError, KeyError, OSError) as exc: + provider_detail = f"config.json unreadable: {exc}" + + checks.append({ + "id": "provider_main", + "name": "Main model configured", + "passed": provider_ok, + "detail": provider_detail, + "fix": "task-master models --set-main sonnet --claude-code" if not provider_ok else None, + }) + + # Check 6: research model configured (soft — not blocking) + checks.append({ + "id": "provider_research", + "name": "Research model configured (optional but recommended)", + "passed": bool(research_model), + "detail": ( + f"research={research_model}" if research_model + else "research model unset — expand --research will fail" + ), + "fix": ( + "task-master models --set-research opus --claude-code" + if not research_model else None + ), + "severity": "warning", + }) + + # Aggregate — only non-warning failures are "critical" + critical_failures = [ + c for c in checks + if not c["passed"] and c.get("severity") != "warning" + ] + all_passed = len(critical_failures) == 0 + + return { + "ok": True, + "ready": all_passed, + "critical_failures": len(critical_failures), + "checks": checks, + "cli_path": cli_path, + "cli_version": cli_version, + "next_action": ( + "Setup complete. Proceed to Phase 1 (DISCOVER)." + if all_passed + else f"{len(critical_failures)} critical check(s) failing. Run the 'fix' command from each failed check." + ), + } diff --git a/prd_taskmaster/parallel.py b/prd_taskmaster/parallel.py new file mode 100644 index 0000000..9a286c7 --- /dev/null +++ b/prd_taskmaster/parallel.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Parallel research + complexity extension for prd-taskmaster. + +TaskMaster's own `expand --all --research` and `analyze-complexity --research` +run SEQUENTIALLY and write tasks.json after every call, so they cannot be +parallelized at the process level (single-writer file). This extension splits +the work into: + + 1. `plan` — emit one research-packet per task (JSON). The AGENT fans these + out to parallel subagents (research happens concurrently). + 2. `apply` — merge ALL results (subtasks + complexity + research notes) into + tasks.json in ONE atomic write, and emit a TaskMaster-format + complexity report. Single-writer safety preserved. + +Also bridges the tagged tasks.json format (script.py only understands flat): + 3. `extract` — copy one tag's {tasks} to a flat temp file for script.py + validate-tasks / enrich-tasks. + 4. `inject` — write the (validated/enriched) flat file back into the tag. + +All commands print JSON. Tag defaults to .taskmaster/state.json currentTag. + +Agent usage pattern (the parallelism lives in the agent, not this script): + plan -> spawn N parallel subagents, each researching a slice of packets + -> each returns results JSON -> concatenate -> apply + -> extract -> script.py validate-tasks/enrich-tasks -> inject + +Result schema expected by `apply` (list of objects): + {"id": <task id>, "complexityScore": 1-10, "recommendedSubtasks": N, + "reasoning": "...", "researchNotes": "...", + "subtasks": [{"title": "...", "description": "...", + "details": "...", "dependencies": [<sibling subtask ids>]}]} +""" + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +TASKS = Path(".taskmaster/tasks/tasks.json") +STATE = Path(".taskmaster/state.json") +REPORTS = Path(".taskmaster/reports") + + +def out(payload): + print(json.dumps(payload, indent=2, default=str)) + + +def fail(msg): + out({"ok": False, "error": msg}) + sys.exit(1) + + +def current_tag(args): + if getattr(args, "tag", None): + return args.tag + if STATE.is_file(): + return json.loads(STATE.read_text()).get("currentTag", "master") + return "master" + + +def load_tagged(tag): + if not TASKS.is_file(): + fail(f"{TASKS} not found") + raw = json.loads(TASKS.read_text()) + if tag not in raw or not isinstance(raw.get(tag), dict): + # flat (untagged) file: treat whole file as the tag when it matches + if "tasks" in raw and isinstance(raw["tasks"], list): + return raw, None # flat mode + fail(f"tag '{tag}' not found in {TASKS}") + return raw, tag + + +def get_tasks(raw, tag): + return raw["tasks"] if tag is None else raw[tag]["tasks"] + + +def write_atomic(path, payload): + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, indent=2, default=str)) + tmp.replace(path) + + +def cmd_plan(args): + tag = current_tag(args) + raw, tag_key = load_tagged(tag) + tasks = get_tasks(raw, tag_key) + packets = [] + for t in tasks: + if args.missing_only and len(t.get("subtasks") or []) >= 2: + continue + if str(t.get("status")) in ("done", "cancelled"): + continue + packets.append({ + "id": t["id"], + "title": t["title"], + "prompt": ( + "Research-then-decompose this task for the repo at the current " + "working directory. TASK TITLE: " + t["title"] + "\n" + "DESCRIPTION: " + str(t.get("description", "")) + "\n" + "DETAILS: " + str(t.get("details", "")) + "\n" + "TEST STRATEGY: " + str(t.get("testStrategy", "")) + "\n\n" + "1) RESEARCH: verify the named files/patterns exist in the repo; " + "check library APIs where named (use web/perplexity/context7 if " + "available); note pitfalls. 2) COMPLEXITY: score 1-10 + recommended " + "subtask count (3-7). 3) DECOMPOSE: TDD-ordered subtasks (each a " + "verifiable checkpoint: failing test -> implement -> green; exact " + "file paths; one concern per subtask). Return ONLY the JSON result " + "object per the schema in parallel.py." + ), + }) + out({"ok": True, "tag": tag, "count": len(packets), "packets": packets}) + + +def cmd_apply(args): + tag = current_tag(args) + raw, tag_key = load_tagged(tag) + tasks = get_tasks(raw, tag_key) + by_id = {t["id"]: t for t in tasks} + results = json.loads(Path(args.input).read_text()) + if isinstance(results, dict): + results = results.get("results", []) + + applied, complexity = [], [] + for r in results: + t = by_id.get(r.get("id")) + if t is None: + continue + subs = [] + for i, s in enumerate(r.get("subtasks") or [], start=1): + subs.append({ + "id": i, + "title": s.get("title", f"Step {i}"), + "description": s.get("description", ""), + "details": s.get("details", ""), + "status": "pending", + "dependencies": s.get("dependencies", []), + }) + if subs: + t["subtasks"] = subs + notes = (r.get("researchNotes") or "").strip() + if notes and notes not in str(t.get("details", "")): + t["details"] = str(t.get("details", "")) + "\n\nRESEARCH NOTES (parallel pass): " + notes + complexity.append({ + "taskId": t["id"], + "taskTitle": t["title"], + "complexityScore": r.get("complexityScore"), + "recommendedSubtasks": r.get("recommendedSubtasks"), + "expansionPrompt": "", + "reasoning": r.get("reasoning", ""), + }) + applied.append(t["id"]) + + write_atomic(TASKS, raw) + REPORTS.mkdir(parents=True, exist_ok=True) + report = { + "meta": { + "generatedAt": datetime.now(timezone.utc).isoformat(), + "tasksAnalyzed": len(complexity), + "thresholdScore": args.threshold, + "projectName": Path.cwd().name, + "usedResearch": True, + "generatedBy": "prd-taskmaster/parallel.py (agent-parallel research)", + "tag": tag, + }, + "complexityAnalysis": complexity, + } + suffix = "" if tag in ("master", None) else f"_{tag}" + report_path = REPORTS / f"task-complexity-report{suffix}.json" + write_atomic(report_path, report) + needs_more = [c["taskId"] for c in complexity + if (c.get("complexityScore") or 0) >= args.threshold + and len((by_id[c["taskId"]].get("subtasks") or [])) < (c.get("recommendedSubtasks") or 0)] + out({"ok": True, "tag": tag, "applied": applied, "report": str(report_path), + "needs_more_subtasks": needs_more}) + + +def cmd_extract(args): + tag = current_tag(args) + raw, tag_key = load_tagged(tag) + flat = {"tasks": get_tasks(raw, tag_key)} + Path(args.output).write_text(json.dumps(flat, indent=2, default=str)) + out({"ok": True, "tag": tag, "output": args.output, "count": len(flat["tasks"])}) + + +def cmd_inject(args): + tag = current_tag(args) + raw, tag_key = load_tagged(tag) + flat = json.loads(Path(args.input).read_text()) + tasks = flat.get("tasks", flat) if isinstance(flat, dict) else flat + if tag_key is None: + raw["tasks"] = tasks + else: + raw[tag_key]["tasks"] = tasks + raw[tag_key].setdefault("metadata", {})["updated"] = datetime.now(timezone.utc).isoformat() + write_atomic(TASKS, raw) + out({"ok": True, "tag": tag, "injected": len(tasks)}) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("plan") + sp.add_argument("--tag") + sp.add_argument("--missing-only", action="store_true") + sp.set_defaults(fn=cmd_plan) + + sa = sub.add_parser("apply") + sa.add_argument("--tag") + sa.add_argument("--input", required=True) + sa.add_argument("--threshold", type=int, default=7) + sa.set_defaults(fn=cmd_apply) + + se = sub.add_parser("extract") + se.add_argument("--tag") + se.add_argument("--output", required=True) + se.set_defaults(fn=cmd_extract) + + si = sub.add_parser("inject") + si.add_argument("--tag") + si.add_argument("--input", required=True) + si.set_defaults(fn=cmd_inject) + + args = p.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/prd_taskmaster/pipeline.py b/prd_taskmaster/pipeline.py new file mode 100644 index 0000000..0dea30c --- /dev/null +++ b/prd_taskmaster/pipeline.py @@ -0,0 +1,257 @@ +""" +Pipeline state machine for the plugin. ATOMIC compare-and-swap transitions. +Phases: SETUP -> DISCOVER -> GENERATE -> HANDOFF -> EXECUTE. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +from prd_taskmaster.lib import atomic_write, locked_update, read_json, emit_json_error, now_iso + +ATLAS_AI_DIR = Path(".atlas-ai") +STATE_DIR = ATLAS_AI_DIR / "state" +PIPELINE_FILE = STATE_DIR / "pipeline.json" +TASKMASTER_DIR = Path(".taskmaster") +TASKMASTER_STATE_FILE = TASKMASTER_DIR / "state.json" +TASKS_FILE = TASKMASTER_DIR / "tasks" / "tasks.json" +PRD_FILE = TASKMASTER_DIR / "docs" / "prd.md" + +PHASES = ["SETUP", "DISCOVER", "GENERATE", "HANDOFF", "EXECUTE"] + +LEGAL_TRANSITIONS = { + None: ["SETUP"], + "SETUP": ["DISCOVER"], + "DISCOVER": ["GENERATE"], + "GENERATE": ["HANDOFF"], + "HANDOFF": ["EXECUTE"], + "EXECUTE": [], +} + + +def _send_telemetry(event: str) -> None: + try: + from prd_taskmaster import telemetry + + telemetry.send_event(event) + except Exception: + pass + + +def _load_state() -> dict: + if not PIPELINE_FILE.exists(): + return {"current_phase": None, "phases_completed": [], "phase_evidence": {}, "version": "5.0.0"} + return read_json(PIPELINE_FILE) + + +def current_phase() -> dict: + state = _load_state() + return { + "ok": True, + "current_phase": state.get("current_phase"), + "phases_completed": state.get("phases_completed", []), + "phase_evidence": state.get("phase_evidence", {}), + } + + +def advance_phase(expected_current: Optional[str], target: str, evidence: dict) -> dict: + if target not in PHASES: + return emit_json_error(f"unknown target phase: {target}", phases=PHASES) + + def transform(content: str) -> str: + state = json.loads(content) if content.strip() else {"current_phase": None, "phases_completed": [], "phase_evidence": {}, "version": "5.0.0"} + actual = state.get("current_phase") + if actual != expected_current: + raise _CASMiss(actual) + if target not in LEGAL_TRANSITIONS.get(actual, []): + raise _IllegalTransition(actual, target) + state["current_phase"] = target + completed = state.get("phases_completed", []) + if expected_current and expected_current not in completed: + completed.append(expected_current) + state["phases_completed"] = completed + state.setdefault("phase_evidence", {})[target] = { + "entered_at": now_iso(), + "from": expected_current, + "evidence": evidence, + } + return json.dumps(state, indent=2, default=str) + + try: + locked_update(PIPELINE_FILE, transform) + except _CASMiss as e: + return emit_json_error( + f"stale expected_current: caller expected {expected_current}, actual is {e.actual}", + expected=expected_current, actual=e.actual + ) + except _IllegalTransition as e: + return emit_json_error( + f"illegal transition: {e.source} -> {e.target}", + legal=LEGAL_TRANSITIONS.get(e.source, []), + ) + + if target == "EXECUTE": + _send_telemetry("reach_execute") + + return {"ok": True, "new_phase": target, "previous": expected_current} + + +def check_gate(phase: str, evidence: dict) -> dict: + violations = [] + if phase == "SETUP": + vs = evidence.get("validate_setup", {}) + if not vs.get("ready") or vs.get("critical_failures", 1) > 0: + violations.append("validate_setup must report ready=true with 0 critical failures") + elif phase == "DISCOVER": + if not (evidence.get("user_approved") or (evidence.get("auto_classification") == "CLEAR" and evidence.get("assumptions_documented"))): + violations.append("DISCOVER gate requires user_approved=true OR auto_classification=CLEAR with assumptions_documented=true") + elif phase == "GENERATE": + if evidence.get("validation_grade") not in ("EXCELLENT", "GOOD"): + violations.append("validation_grade must be EXCELLENT or GOOD") + if evidence.get("task_count", 0) == 0: + violations.append("tasks must be parsed (task_count > 0)") + if evidence.get("subtask_coverage", 0) < 1.0: + violations.append("all tasks must have subtasks (coverage must be 1.0)") + elif phase == "HANDOFF": + if not evidence.get("user_mode_choice"): + violations.append("user_mode_choice must be recorded (from AskUserQuestion)") + if not evidence.get("plan_file_exists"): + violations.append("writing-plans must have written the plan file") + + return { + "ok": True, + "gate_passed": len(violations) == 0, + "violations": violations, + "phase": phase, + } + + +def _read_taskmaster_state() -> dict: + if not TASKMASTER_STATE_FILE.exists(): + return {} + try: + return read_json(TASKMASTER_STATE_FILE) + except Exception: + return {} + + +def _read_taskmaster_tasks() -> dict: + if not TASKS_FILE.exists(): + return {} + try: + tasks = read_json(TASKS_FILE) + return tasks if isinstance(tasks, dict) else {} + except Exception: + return {} + + +def _tag_task_lists(tasks: dict) -> dict[str, list[dict]]: + if isinstance(tasks.get("tasks"), list): + return {"master": tasks["tasks"]} + + tags: dict[str, list[dict]] = {} + for key, value in tasks.items(): + if isinstance(value, dict) and isinstance(value.get("tasks"), list): + tags[key] = value["tasks"] + return tags + + +def _count_tasks(items: list[dict]) -> dict[str, int]: + total = len(items) + done = sum(1 for item in items if item.get("status") == "done") + return { + "total": total, + "pending": total - done, + "done": done, + } + + +def _current_tag(state: dict, tag_lists: dict[str, list[dict]]) -> str: + configured = state.get("currentTag") + if isinstance(configured, str) and configured: + return configured + if "master" in tag_lists: + return "master" + return next(iter(tag_lists), "master") + + +def _recommended_pending_tag(tag_counts: dict[str, dict[str, int]]) -> str | None: + ready_tags = [ + (tag, counts) + for tag, counts in tag_counts.items() + if counts.get("pending", 0) > 0 + ] + if not ready_tags: + return None + ready_tags.sort(key=lambda item: (item[0] != "master", item[0])) + return ready_tags[0][0] + + +def preflight(cwd: Optional[str] = None) -> dict: + if cwd: + import os + os.chdir(cwd) + + _send_telemetry("atlas_invoked") + + state = _load_state() + cp = state.get("current_phase") + + prd_exists = PRD_FILE.exists() + tm_state = _read_taskmaster_state() + task_lists = _tag_task_lists(_read_taskmaster_tasks()) + tag_counts = { + tag: _count_tasks(items) + for tag, items in task_lists.items() + } + current_tag = _current_tag(tm_state, task_lists) + current_counts = tag_counts.get(current_tag, {"total": 0, "pending": 0, "done": 0}) + tasks_count = current_counts["total"] + pending_count = current_counts["pending"] + recommended_tag = current_tag + + has_taskmaster = TASKMASTER_DIR.exists() + alternate_pending_tag = _recommended_pending_tag(tag_counts) + + if has_taskmaster and pending_count > 0: + rec = "resume_existing_tasks" + elif prd_exists and tasks_count == 0: + rec = "parse_prd" + elif has_taskmaster and not prd_exists: + rec = "generate_prd" + elif not has_taskmaster: + rec = "run_setup" + elif alternate_pending_tag and alternate_pending_tag != current_tag: + rec = "select_taskmaster_tag" + recommended_tag = alternate_pending_tag + elif tasks_count > 0 and pending_count == 0: + rec = "complete" + elif cp == "EXECUTE" and tasks_count > 0: + rec = "resume" + else: + rec = "run_setup" + + return { + "ok": True, + "current_phase": cp, + "prd_path": str(PRD_FILE) if prd_exists else None, + "task_count": tasks_count, + "pending_task_count": pending_count, + "has_taskmaster": has_taskmaster, + "taskmaster_root": str(TASKMASTER_DIR), + "tasks_path": str(TASKS_FILE) if TASKS_FILE.exists() else None, + "current_tag": current_tag, + "recommended_tag": recommended_tag, + "tag_counts": tag_counts, + "recommended_action": rec, + } + + +class _CASMiss(Exception): + def __init__(self, actual): self.actual = actual + +class _IllegalTransition(Exception): + def __init__(self, source, target): + self.source = source + self.target = target diff --git a/prd_taskmaster/preflight.py b/prd_taskmaster/preflight.py new file mode 100644 index 0000000..dff9f5b --- /dev/null +++ b/prd_taskmaster/preflight.py @@ -0,0 +1,94 @@ +"""Environment / taskmaster detection commands.""" + +import argparse +import json +from pathlib import Path + +from prd_taskmaster.lib import ( + TASKMASTER_DIR, + TASKMASTER_DOCS, + TASKMASTER_TASKS, + CommandError, + emit, + fail, + _detect_taskmaster_method, + _read_execution_state, + _resolve_tasks_payload, +) + + +def run_preflight() -> dict: + """Detect environment: .taskmaster, PRD, task count, taskmaster method, CLAUDE.md, crash state.""" + has_taskmaster = TASKMASTER_DIR.is_dir() + prd_path = None + task_count = 0 + tasks_completed = 0 + tasks_pending = 0 + + if has_taskmaster: + # Find PRD + for candidate in [TASKMASTER_DOCS / "prd.md", TASKMASTER_DOCS / "prd.txt"]: + if candidate.is_file(): + prd_path = str(candidate) + break + if not prd_path: + # Glob for any .md in docs + docs = list(TASKMASTER_DOCS.glob("*.md")) if TASKMASTER_DOCS.is_dir() else [] + if docs: + prd_path = str(docs[0]) + + # Count tasks + tasks_json = TASKMASTER_TASKS / "tasks.json" + if tasks_json.is_file(): + try: + with open(tasks_json) as f: + data = json.load(f) + tasks, _ = _resolve_tasks_payload(data) + if isinstance(tasks, list): + task_count = len(tasks) + tasks_completed = sum(1 for t in tasks if t.get("status") == "done") + tasks_pending = task_count - tasks_completed + except (json.JSONDecodeError, KeyError): + pass + + # Detect taskmaster method + tm_method = _detect_taskmaster_method() + + # Check CLAUDE.md + has_claude_md = Path("CLAUDE.md").is_file() + + # Check crash state + crash_state = _read_execution_state() + + return { + "ok": True, + "has_taskmaster": has_taskmaster, + "prd_path": prd_path, + "task_count": task_count, + "tasks_completed": tasks_completed, + "tasks_pending": tasks_pending, + "taskmaster_method": tm_method["method"], + "has_claude_md": has_claude_md, + "has_crash_state": crash_state.get("has_incomplete", False), + "crash_state": crash_state if crash_state.get("has_incomplete") else None, + } + + +def cmd_preflight(args: argparse.Namespace) -> None: + try: + emit(run_preflight()) + except CommandError as e: + fail(e.message, **e.extra) + + +def run_detect_taskmaster() -> dict: + """Detect taskmaster method: MCP > CLI > none.""" + result = _detect_taskmaster_method() + return {"ok": True, **result} + + +def cmd_detect_taskmaster(args: argparse.Namespace) -> None: + try: + emit(run_detect_taskmaster()) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/prd_taskmaster/providers.py b/prd_taskmaster/providers.py new file mode 100644 index 0000000..c3194fa --- /dev/null +++ b/prd_taskmaster/providers.py @@ -0,0 +1,236 @@ +"""Provider configuration and detection commands.""" + +import argparse +import os +import shutil +from pathlib import Path + +from prd_taskmaster.lib import ( + CommandError, + emit, + fail, + _detect_perplexity_mcp, + _ensure_env_entry, + _is_local_perplexity_free, + _local_port_open, + _read_env_file_value, + _read_taskmaster_config, + _read_taskmaster_model, + _write_taskmaster_config, +) + + +def run_configure_providers() -> dict: + """Configure TaskMaster for native Claude/Codex plus local Perplexity API Free when available.""" + config = _read_taskmaster_config() + if not config: + raise CommandError( + ".taskmaster/config.json not found or invalid; initialise TaskMaster first", + { + "recommended_action": "init_taskmaster", + "fix": "python3 script.py init-taskmaster", + "note": "init-taskmaster preserves an existing .mcp.json (raw 'task-master init' overwrites it)", + }, + ) + + models = config.setdefault("models", {}) + changed = [] + + has_claude = shutil.which("claude") is not None + has_codex = shutil.which("codex") is not None + has_anthropic_key = bool(os.environ.get("ANTHROPIC_API_KEY")) + + if has_claude: + desired_main = { + "provider": "claude-code", + "modelId": "sonnet", + "maxTokens": 64000, + "temperature": 0.2, + } + elif has_codex: + desired_main = { + "provider": "codex-cli", + "modelId": "gpt-5.2-codex", + "maxTokens": 128000, + "temperature": 0.2, + } + elif has_anthropic_key: + desired_main = { + "provider": "anthropic", + "modelId": "claude-sonnet-4-20250514", + "maxTokens": 64000, + "temperature": 0.2, + } + else: + desired_main = None + + if desired_main and models.get("main") != desired_main: + models["main"] = desired_main + changed.append("main") + + if has_codex: + desired_fallback = { + "provider": "codex-cli", + "modelId": "gpt-5.2-codex", + "maxTokens": 128000, + "temperature": 0.2, + } + elif has_claude: + desired_fallback = { + "provider": "claude-code", + "modelId": "sonnet", + "maxTokens": 64000, + "temperature": 0.2, + } + elif has_anthropic_key: + desired_fallback = { + "provider": "anthropic", + "modelId": "claude-3-7-sonnet-20250219", + "maxTokens": 120000, + "temperature": 0.2, + } + else: + desired_fallback = None + + if desired_fallback and models.get("fallback") != desired_fallback: + models["fallback"] = desired_fallback + changed.append("fallback") + + existing_research = models.get("research") if isinstance(models.get("research"), dict) else {} + local_proxy_url = ( + _read_env_file_value(Path(".env"), "PERPLEXITY_API_BASE_URL") + or existing_research.get("baseURL") + or os.environ.get("PERPLEXITY_API_BASE_URL") + or os.environ.get("PERPLEXITY_API_FREE_BASE_URL") + or "http://127.0.0.1:8765" + ) + local_proxy_available = ( + "127.0.0.1:8765" in local_proxy_url + or "localhost:8765" in local_proxy_url + or _local_port_open() + ) + if local_proxy_available: + desired_research = { + "provider": "openai-compatible", + "modelId": "sonar", + "maxTokens": 8700, + "temperature": 0.1, + "baseURL": local_proxy_url, + } + if models.get("research") != desired_research: + models["research"] = desired_research + changed.append("research") + + if _ensure_env_entry(Path(".env"), "OPENAI_COMPATIBLE_API_KEY", "local-perplexity-api-free"): + changed.append(".env:OPENAI_COMPATIBLE_API_KEY") + if _ensure_env_entry(Path(".env"), "PERPLEXITY_API_BASE_URL", local_proxy_url): + changed.append(".env:PERPLEXITY_API_BASE_URL") + if Path(".env.example").is_file(): + if _ensure_env_entry( + Path(".env.example"), + "OPENAI_COMPATIBLE_API_KEY", + "local-perplexity-api-free", + "Local only: dummy key for local Perplexity API Free proxy.", + ): + changed.append(".env.example:OPENAI_COMPATIBLE_API_KEY") + if _ensure_env_entry( + Path(".env.example"), + "PERPLEXITY_API_BASE_URL", + local_proxy_url, + "Local only: Perplexity API Free proxy endpoint.", + ): + changed.append(".env.example:PERPLEXITY_API_BASE_URL") + + config.setdefault("global", {}).setdefault("defaultTag", "master") + _write_taskmaster_config(config) + + return { + "ok": True, + "changed": changed, + "models": models, + "local_perplexity_api_free": local_proxy_available, + "local_perplexity_api_free_url": local_proxy_url if local_proxy_available else None, + } + + +def cmd_configure_providers(args: argparse.Namespace) -> None: + try: + emit(run_configure_providers()) + except CommandError as e: + fail(e.message, **e.extra) + + +def run_detect_providers() -> dict: + """Auto-detect and report available AI providers. Zero questions asked.""" + providers = {} + + main_model = _read_taskmaster_model("main") + fallback_model = _read_taskmaster_model("fallback") + research_model = _read_taskmaster_model("research") + + # Prefer the concrete TaskMaster project config when native CLI providers are wired. + main_provider = str(main_model.get("provider", "")).lower() + fallback_provider = str(fallback_model.get("provider", "")).lower() + if main_provider in {"claude-code", "codex-cli"}: + providers["main"] = { + "provider": main_provider, + "status": "detected", + "source": ".taskmaster/config.json", + "modelId": main_model.get("modelId"), + } + if fallback_provider in {"claude-code", "codex-cli"}: + providers["fallback"] = { + "provider": fallback_provider, + "status": "detected", + "source": ".taskmaster/config.json", + "modelId": fallback_model.get("modelId"), + } + + # Check Anthropic API key / native CLI fallbacks. + api_key = os.environ.get("ANTHROPIC_API_KEY", "") + if api_key and "main" not in providers: + providers["main"] = {"provider": "anthropic", "status": "detected", "source": "ANTHROPIC_API_KEY env"} + elif "main" not in providers and shutil.which("claude"): + providers["main"] = {"provider": "claude-code", "status": "detected", "source": "claude CLI"} + elif "main" not in providers and shutil.which("codex"): + providers["main"] = {"provider": "codex-cli", "status": "detected", "source": "codex CLI"} + elif "main" not in providers: + providers["main"] = {"provider": "claude-code", "status": "fallback", "source": "Claude Max/Pro subscription"} + + if api_key and "fallback" not in providers: + providers["fallback"] = {"provider": "anthropic", "status": "detected", "source": "ANTHROPIC_API_KEY env"} + elif "fallback" not in providers and shutil.which("codex"): + providers["fallback"] = {"provider": "codex-cli", "status": "detected", "source": "codex CLI"} + elif "fallback" not in providers and shutil.which("claude"): + providers["fallback"] = {"provider": "claude-code", "status": "fallback", "source": "Claude Max/Pro subscription"} + elif "fallback" not in providers: + providers["fallback"] = {"provider": "claude-code", "status": "fallback", "source": "No native CLI found"} + + # Check Perplexity - local free proxy / MCP first, then paid API key. + perplexity_mcp = _detect_perplexity_mcp() + + perplexity_key = os.environ.get("PERPLEXITY_API_KEY", "") + + if _is_local_perplexity_free(research_model): + providers["research"] = { + "provider": "perplexity-api-free", + "status": "detected", + "source": ".taskmaster/config.json research.baseURL", + "modelId": research_model.get("modelId"), + "baseURL": research_model.get("baseURL"), + } + elif perplexity_mcp: + providers["research"] = {"provider": perplexity_mcp, "status": "detected", "source": "MCP server"} + elif perplexity_key: + providers["research"] = {"provider": "perplexity", "status": "detected", "source": "PERPLEXITY_API_KEY env"} + else: + providers["research"] = {"provider": "claude-code", "status": "fallback", "source": "No Perplexity — using Claude Code"} + + return {"ok": True, "providers": providers} + + +def cmd_detect_providers(args: argparse.Namespace) -> None: + try: + emit(run_detect_providers()) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/prd_taskmaster/render.py b/prd_taskmaster/render.py new file mode 100644 index 0000000..482c5a3 --- /dev/null +++ b/prd_taskmaster/render.py @@ -0,0 +1,91 @@ +"""User-facing terminal rendering helpers.""" + +from __future__ import annotations + +from typing import Iterable + +HEADER_WIDTH = 64 + +SYMBOLS = { + "pass": ("✓", "PASS"), + "blocked": ("✗", "BLOCKED"), + "warn": ("⚠", "WARN"), + "pending": ("○", "PENDING"), + "next": ("▶", "NEXT"), +} + +USER_PHASES: dict[str, tuple[str, int] | None] = { + "SETUP": ("PREFLIGHT", 1), + "DISCOVER": ("DISCOVERY", 2), + "GENERATE": ("GENERATE", 3), + "HANDOFF": ("HANDOFF", 4), + "EXECUTE": None, +} + +_PHASE_FIXES = { + "SETUP": "Run /atlas setup again after fixing the failed preflight check.", + "DISCOVER": "Record user approval or document clear assumptions before continuing.", + "GENERATE": "Regenerate or repair tasks until the score is GOOD or better.", + "HANDOFF": "Record the chosen mode and write the plan file before handoff.", +} + + +def render_phase_header(phase_or_number: str | int, name: str | None = None) -> str: + """Render the UX-SPEC phase header for user-facing pipeline phases.""" + if isinstance(phase_or_number, str): + mapped = USER_PHASES.get(phase_or_number.upper()) + if mapped is None: + return "" + name, number = mapped + else: + number = phase_or_number + if name is None: + raise ValueError("name is required when rendering by phase number") + + label = f"┌─ atlas ── PHASE {number}/4: {name.upper()} " + fill = max(0, HEADER_WIDTH - len(label) - 1) + return f"{label}{'─' * fill}┐" + + +def render_gate(result: dict) -> str: + """Render a pipeline gate result without exposing raw JSON.""" + phase = str(result.get("phase") or "").upper() + violations = _clean_lines(result.get("violations", [])) + + if result.get("gate_passed"): + summary = str(result.get("summary") or _default_gate_summary(phase)) + return f"{SYMBOLS['pass'][0]} Gate passed: {summary}" + + violation = violations[0] if violations else "required evidence is missing" + fix = str(result.get("fix") or _PHASE_FIXES.get(phase) or "Add the missing evidence and rerun the gate.") + return f"{SYMBOLS['blocked'][0]} Gate blocked: {violation}\nFix: {fix}" + + +def render_status_block( + phase_or_number: str | int, + *, + name: str | None = None, + what_happened: str, + evidence: Iterable[str], + next_step: str, +) -> str: + """Render a fixed-order status block: what happened, evidence, next step.""" + lines = [render_phase_header(phase_or_number, name)] + lines.append(f"What happened: {what_happened}") + lines.append("Evidence:") + lines.extend(f" {line}" for line in evidence) + lines.append(f"Next: {next_step}") + return "\n".join(line for line in lines if line) + + +def _clean_lines(values: object) -> list[str]: + if not isinstance(values, list): + return [] + return [str(value) for value in values if str(value).strip()] + + +def _default_gate_summary(phase: str) -> str: + if phase in USER_PHASES and USER_PHASES[phase] is not None: + name, _number = USER_PHASES[phase] + return f"{name.title()} evidence is ready" + return "required evidence is ready" diff --git a/prd_taskmaster/shipcheck.py b/prd_taskmaster/shipcheck.py new file mode 100644 index 0000000..7ac1862 --- /dev/null +++ b/prd_taskmaster/shipcheck.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Deterministic ship-check for prd-taskmaster pipelines. + +Emits SHIP_CHECK_OK to stdout ONLY when all gates pass. Called by execute-task +at termination AND by Step 9 (--dry-run) as a per-task predicate. + +Gate logic (grounded against actual pipeline.json / tasks.json schemas +observed 2026-06-04 in ai-human-tasker): + + Gate 1 — pipeline.json: current_phase == "EXECUTE". + During execution, EXECUTE is current_phase but NOT yet in + phases_completed (the EXECUTE→completed transition happens AFTER + ship-check passes). Older skel checked state.state == "complete" — that + field does not exist in live data. + + Gate 2 — tasks.json: every master.tasks[].status == "done". + No evidence_files field exists in tasks.json — that was a phantom check + in the older skel. + + Gate 3 — CDD evidence: for each task ID, a CDD card exists at + .atlas-ai/cdd/task-<id>.json OR a combined card whose name contains the + id (e.g. task-10-11-12.json covers tasks 10, 11, 12). Cards are written + by execute-task Step 5. + + Gate 4 — Plan file exists at .taskmaster/docs/plan.md OR + docs/superpowers/plans/*.md (matches Phase 2 path fallback). The older + skel checked .atlas-ai/ralph-loop-prompt.md — wrong path and irrelevant + after /goal migration. + + Gate 5 (HARD) — No non-zero "Exit status N" line in any .atlas-ai/evidence/ + file. This is the convergent must-do from the 2026-06-04 forensic audit + (T12 marked DONE while pnpm test exited 1 with 11 failing tests). + Override only via SHIP_CHECK_OVERRIDE_ADMIN token; overrides are logged + to .atlas-ai/state/execute-log.jsonl as an audit record. + +Interface (standalone shim, created in a later step): + python3 .atlas-ai/ship-check.py # standard gate + python3 .atlas-ai/ship-check.py --dry-run # always exit 0; report on stderr + python3 .atlas-ai/ship-check.py --override SHIP_CHECK_OVERRIDE_ADMIN # bypass Gate 5 + python3 .atlas-ai/ship-check.py --cwd /path/to/project # explicit project root + +Exit codes: + 0 — SHIP_CHECK_OK (stdout: exactly "SHIP_CHECK_OK\n", with " [OVERRIDE]" suffix if applicable) + 1 — gate failures (stderr only; nothing on stdout — log watchers must not see partial matches) + 2 — script error (IO, JSON parse, bad token) +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional, Tuple + +OVERRIDE_TOKEN = "SHIP_CHECK_OVERRIDE_ADMIN" +EXIT_STATUS_RE = re.compile(r"\bExit status\s+(\d+)\b", re.IGNORECASE) + + +def _send_telemetry(event: str) -> None: + try: + from prd_taskmaster import telemetry + + telemetry.send_event(event) + except Exception: + pass + + +def gate_pipeline(atlas: Path) -> Tuple[bool, List[str]]: + pf = atlas / "state" / "pipeline.json" + if not pf.exists(): + return False, ["pipeline.json missing at .atlas-ai/state/pipeline.json"] + try: + state = json.loads(pf.read_text()) + except json.JSONDecodeError as exc: + return False, [f"pipeline.json invalid JSON: {exc}"] + if state.get("current_phase") != "EXECUTE": + return False, [f"pipeline.json current_phase != EXECUTE (got {state.get('current_phase')!r})"] + return True, [] + + +def gate_tasks(repo_root: Path) -> Tuple[bool, List[str], list]: + tf = repo_root / ".taskmaster" / "tasks" / "tasks.json" + if not tf.exists(): + return False, ["tasks.json missing at .taskmaster/tasks/tasks.json"], [] + try: + tdata = json.loads(tf.read_text()) + except json.JSONDecodeError as exc: + return False, [f"tasks.json invalid JSON: {exc}"], [] + # Accept both canonical formats: tagged {"master": {"tasks": [...]}} + # and flat {"tasks": [...]} (Manual Mechanics Mode / fleet samples). + tasks = tdata.get("master", {}).get("tasks", []) + if not tasks and isinstance(tdata.get("tasks"), list): + tasks = tdata["tasks"] + if not tasks: + for value in tdata.values(): + if isinstance(value, dict) and isinstance(value.get("tasks"), list) and value["tasks"]: + tasks = value["tasks"] + break + if not tasks: + return False, ["tasks.json has no tasks (checked master.tasks, flat tasks, and tagged fallback)"], [] + failures: List[str] = [] + for t in tasks: + if t.get("status") != "done": + failures.append(f"task {t.get('id')} not done (status={t.get('status')!r})") + return len(failures) == 0, failures, tasks + + +def _has_card_for(cdd_dir: Path, tid) -> bool: + """A CDD card exists for task <tid> if task-<tid>.json exists OR any + combined card filename's hyphen-separated id-list contains <tid>.""" + tid_str = str(tid) + direct = cdd_dir / f"task-{tid_str}.json" + if direct.exists(): + return True + for card in cdd_dir.glob("task-*.json"): + stem = card.stem + if not stem.startswith("task-"): + continue + ids = stem[len("task-"):].split("-") + if tid_str in ids: + return True + return False + + +def gate_cdd(atlas: Path, tasks: list) -> Tuple[bool, List[str]]: + cdd_dir = atlas / "cdd" + if not cdd_dir.exists(): + return False, [".atlas-ai/cdd/ directory missing"] + failures: List[str] = [] + for t in tasks: + tid = t.get("id") + if tid is None: + continue + if not _has_card_for(cdd_dir, tid): + failures.append(f"task {tid}: no CDD card at .atlas-ai/cdd/task-{tid}.json or any combined variant") + return len(failures) == 0, failures + + +def gate_plan(repo_root: Path) -> Tuple[bool, List[str]]: + if (repo_root / ".taskmaster" / "docs" / "plan.md").exists(): + return True, [] + superpowers = repo_root / "docs" / "superpowers" / "plans" + if superpowers.exists() and any(superpowers.glob("*.md")): + return True, [] + return False, ["no plan file at .taskmaster/docs/plan.md or docs/superpowers/plans/*.md"] + + +def gate_exit_codes(atlas: Path) -> Tuple[bool, List[str]]: + failures: List[str] = [] + evidence_dir = atlas / "evidence" + if not evidence_dir.exists(): + # Gate 3 (CDD) catches missing evidence; this gate is silent when no evidence exists + return True, [] + for f in evidence_dir.rglob("*"): + if not f.is_file(): + continue + try: + text = f.read_text(errors="ignore") + except OSError: + continue + for match in EXIT_STATUS_RE.finditer(text): + try: + code = int(match.group(1)) + except (ValueError, IndexError): + continue + if code != 0: + rel = f.relative_to(atlas.parent) if atlas.parent in f.parents else f + failures.append(f"non-zero exit in {rel}: Exit status {code}") + break # one report per file is enough + return len(failures) == 0, failures + + +def log_override(atlas: Path, message: str) -> None: + log = atlas / "state" / "execute-log.jsonl" + log.parent.mkdir(parents=True, exist_ok=True) + entry = { + "iteration": "OVERRIDE", + "timestamp": datetime.now(timezone.utc).isoformat(), + "task_id": "SHIP_CHECK", + "event": "override_invoked", + "message": message, + } + with log.open("a") as fp: + fp.write(json.dumps(entry) + "\n") + + +def run_all_gates(repo_root: Path, override_active: bool) -> Tuple[bool, List[str]]: + failures: List[str] = [] + atlas = repo_root / ".atlas-ai" + + _, f1 = gate_pipeline(atlas) + failures.extend(f1) + + _, f2, tasks = gate_tasks(repo_root) + failures.extend(f2) + + if tasks: + _, f3 = gate_cdd(atlas, tasks) + failures.extend(f3) + + _, f4 = gate_plan(repo_root) + failures.extend(f4) + + ok5, f5 = gate_exit_codes(atlas) + if not ok5: + if override_active: + log_override(atlas, f"Gate 5 bypassed: {'; '.join(f5[:3])}{' ...' if len(f5) > 3 else ''}") + # Override accepts the failures; no append to global failures list + else: + failures.extend(f5) + + return len(failures) == 0, failures + + +def run_ship_check(cwd: Optional[str] = None, dry_run: bool = False, + override: Optional[str] = None) -> dict: + """Importable ship-check entry point. NEVER calls sys.exit. + + Args: + cwd: project root (defaults to current working directory). + dry_run: when True, the returned exit_code is forced to 0 regardless of + gate outcome (gates still run and the report is preserved). + override: if equal to OVERRIDE_TOKEN, Gate 5 (exit codes) is bypassed. + + Returns a dict: + passed: bool — True when all (non-overridden) gates pass. + failures: list[str] — per-gate failure detail (empty when passed). + override_active: bool — whether a valid override token was supplied. + override_invalid: bool — an override value was supplied but did not match. + dry_run: bool + exit_code: int — 0 / 1 / 2 mirroring the CLI contract. + error: str | None — populated on a script error (exit_code 2). + stdout: str | None — the exact stdout line the CLI would print, if any. + """ + if override is not None and override != OVERRIDE_TOKEN: + return { + "passed": False, + "failures": [], + "override_active": False, + "override_invalid": True, + "dry_run": dry_run, + "exit_code": 2, + "error": "--override value does not match expected token", + "stdout": None, + } + + override_active = override == OVERRIDE_TOKEN + repo_root = Path(cwd).resolve() if cwd else Path.cwd() + + try: + ok, failures = run_all_gates(repo_root, override_active=override_active) + except Exception as exc: # noqa: BLE001 — top-level guard + return { + "passed": False, + "failures": [], + "override_active": override_active, + "override_invalid": False, + "dry_run": dry_run, + "exit_code": 2, + "error": f"ship-check script error: {exc!r}", + "stdout": None, + } + + if dry_run: + exit_code = 0 + stdout = None + elif ok: + exit_code = 0 + suffix = " [OVERRIDE]" if override_active else "" + stdout = f"SHIP_CHECK_OK{suffix}" + _send_telemetry("ship_check_ok") + else: + exit_code = 1 + stdout = None + + return { + "passed": ok, + "failures": failures, + "override_active": override_active, + "override_invalid": False, + "dry_run": dry_run, + "exit_code": exit_code, + "error": None, + "stdout": stdout, + } + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Deterministic ship-check for prd-taskmaster pipelines.") + parser.add_argument("--dry-run", action="store_true", + help="Run all gates but always exit 0. Report goes to stderr. Used by execute-task Step 9 as a per-task predicate.") + parser.add_argument("--override", type=str, default=None, + help=f"Bypass Gate 5 (exit codes) if value equals {OVERRIDE_TOKEN}. Logged to execute-log.jsonl.") + parser.add_argument("--cwd", type=str, default=None, + help="Project root (defaults to current working directory).") + args = parser.parse_args(argv) + + result = run_ship_check(cwd=args.cwd, dry_run=args.dry_run, override=args.override) + + # Script error (bad token or IO/JSON failure) — exit 2, message on stderr. + if result["exit_code"] == 2: + print(f"FAIL: {result['error']}", file=sys.stderr) + return 2 + + if args.dry_run: + if result["passed"]: + print("[DRY-RUN] all gates would pass", file=sys.stderr) + else: + print("[DRY-RUN] gate failures (would block):", file=sys.stderr) + for f in result["failures"]: + print(f" - {f}", file=sys.stderr) + return 0 + + if result["passed"]: + print(result["stdout"]) + return 0 + + for f in result["failures"]: + print(f"FAIL: {f}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/prd_taskmaster/taskmaster.py b/prd_taskmaster/taskmaster.py new file mode 100644 index 0000000..d171a00 --- /dev/null +++ b/prd_taskmaster/taskmaster.py @@ -0,0 +1,92 @@ +"""TaskMaster CLI wrapper for standard .taskmaster projects.""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from prd_taskmaster.lib import emit_json_error + + +def _build_env() -> dict: + """Return the subprocess environment without overriding TaskMaster's project detection.""" + env = os.environ.copy() + env.pop("TASK_MASTER_PROJECT_ROOT", None) + return env + + +def _find_binary() -> str | None: + return shutil.which("task-master") or shutil.which("task-master-ai") or shutil.which("taskmaster") + + +def init_taskmaster(method: str = "cli") -> dict: + cli = _find_binary() + if not cli: + return emit_json_error( + "task-master binary not found in PATH", + install_cmd="npm install -g task-master-ai", + searched=["task-master", "task-master-ai", "taskmaster"], + ) + + project_root = Path.cwd() + env = _build_env() + + # DETECT-FIRST (FR-6): task-master init overwrites an existing .mcp.json with + # its own template (placeholder API keys), destroying e.g. a plugin's server + # registration. Snapshot it and restore verbatim if init clobbers it. + mcp_json = project_root / ".mcp.json" + mcp_before = mcp_json.read_text() if mcp_json.is_file() else None + + # try full flag set, fall back if older taskmaster + attempts = [ + [cli, "init", "--yes", "--store-tasks-in-git", "--rules=claude"], + [cli, "init", "--yes"], + ] + last_error = None + result = None + for cmd in attempts: + try: + r = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=60, cwd=project_root) + if r.returncode == 0: + result = {"ok": True, "project_root": str(project_root), "cmd": " ".join(cmd)} + break + last_error = r.stderr + except Exception as e: + last_error = str(e) + + mcp_protected = False + if mcp_before is not None and mcp_json.is_file() and mcp_json.read_text() != mcp_before: + mcp_json.write_text(mcp_before) + mcp_protected = True + + if result is None: + return emit_json_error(f"task-master init failed: {last_error}", project_root=str(project_root)) + + result["mcp_json_protected"] = mcp_protected + if mcp_protected: + result["note"] = ( + ".mcp.json was restored to its pre-init contents (task-master init had " + "overwritten it). Add TaskMaster's MCP server manually if you want it." + ) + return result + + +def cmd_init_taskmaster(args) -> None: + """CLI wrapper: protected task-master init (preserves an existing .mcp.json).""" + from prd_taskmaster.lib import emit, fail + + result = init_taskmaster() + if result.get("ok"): + emit(result) + else: + fail(result.get("error", "task-master init failed"), + **{k: v for k, v in result.items() if k not in ("ok", "error")}) + + +def detect_taskmaster_method() -> str: + """cli if binary found, mcp if MCP available, none otherwise.""" + # For v5.0 we only detect CLI presence; MCP probing is a future enhancement. + if _find_binary(): + return "cli" + return "none" diff --git a/prd_taskmaster/tasks.py b/prd_taskmaster/tasks.py new file mode 100644 index 0000000..a8466c2 --- /dev/null +++ b/prd_taskmaster/tasks.py @@ -0,0 +1,292 @@ +"""Task calculation, PRD backup, and tasks.json enrichment commands.""" + +import argparse +import json +import math +import re +import shutil +from datetime import datetime +from pathlib import Path + +from prd_taskmaster.lib import ( + TASKMASTER_TASKS, + CommandError, + emit, + fail, + _resolve_tasks_payload, +) + + +# Scale bands from discovery's Solo/Team/Enterprise classification +# (PRODUCT-SPEC US-T1: Solo 8-12 / Team 12-20 / Enterprise 20-30). +SCALE_BANDS = { + "solo": (8, 12), + "team": (12, 20), + "enterprise": (20, 30), +} + + +def run_calc_tasks(requirements: int, scale: str | None = None) -> dict: + """Calculate recommended task count: requirements * 1.5, clamped to the + scale band when discovery classified one, else the generic [10, 40].""" + raw = math.ceil(requirements * 1.5) + if scale: + key = scale.lower() + if key not in SCALE_BANDS: + raise CommandError( + f"unknown scale: {scale}", + {"valid_scales": sorted(SCALE_BANDS)}, + ) + lo, hi = SCALE_BANDS[key] + formula = f"ceil(requirements * 1.5), clamped to {key} band [{lo}, {hi}]" + else: + lo, hi = 10, 40 + formula = "ceil(requirements * 1.5), clamped [10, 40]" + recommended = max(lo, min(hi, raw)) + result = { + "ok": True, + "requirements_count": requirements, + "raw_calculation": raw, + "recommended": recommended, + "formula": formula, + } + if scale: + result["scale"] = scale.lower() + return result + + +def cmd_calc_tasks(args: argparse.Namespace) -> None: + try: + emit(run_calc_tasks(args.requirements, getattr(args, "scale", None))) + except CommandError as e: + fail(e.message, **e.extra) + + +def run_backup_prd(input_path: str) -> dict: + """Create timestamped backup of PRD.""" + src = Path(input_path) + if not src.is_file(): + raise CommandError(f"PRD file not found: {input_path}") + + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_name = f"prd-backup-{ts}.md" + backup_dir = src.parent + backup_path = backup_dir / backup_name + + shutil.copy2(str(src), str(backup_path)) + return { + "ok": True, + "original": str(src), + "backup_path": str(backup_path), + "timestamp": ts, + } + + +def cmd_backup_prd(args: argparse.Namespace) -> None: + try: + emit(run_backup_prd(args.input)) + except CommandError as e: + fail(e.message, **e.extra) + + +# ─── Complexity classification ──────────────────────────────────────────────── +# +# Design note: keyword-based TYPE classification is intentional and correct here. +# Unlike domain clustering (which requires semantic understanding of system context), +# task TYPE is signaled by the task author through deliberate word choice. +# "Research", "spike", "auth", "deploy" are load-bearing words — authors use them +# precisely because they mean something specific about the work shape. +# +# Priority order matters: RESEARCH is checked before COMPLEX because a task that +# asks you to "research the best auth framework" has an immediate deliverable of a +# decision document, not an implementation. RESEARCH lifecycle is the right scaffold. +# +# AI judgment is NOT used here because enrichment is a mechanical post-parse step +# that must be deterministic, fast, and runnable without model access. +# See script module docstring: "AI handles judgment; this script handles mechanics." + +# Keywords that signal high complexity or architectural work +_COMPLEX_KEYWORDS = re.compile( + r'\b(architect|integrat|authenticat|authoriz|encrypt|migration|' + r'schema|database|performance|scalab|security|infrastructure|' + r'pipeline|orchestrat|distributed|concurrent|async|webhook|' + r'deploy|docker|kubernetes|ci/cd|refactor)\b', + re.IGNORECASE, +) + +# Keywords that signal research-oriented work +_RESEARCH_KEYWORDS = re.compile( + r'\b(research|investigat|analyz|explore|evaluat|assess|discover|' + r'benchmark|audit|review|spike|poc|proof.of.concept)\b', + re.IGNORECASE, +) + +# Lifecycle phase assignments by complexity +_LIFECYCLE_MAP = { + "SIMPLE": ["implementation", "testing"], + "MEDIUM": ["planning", "implementation", "testing"], + "COMPLEX": ["planning", "implementation", "testing", "review"], + "RESEARCH": ["research", "planning", "review"], + "VALIDATION": ["testing", "review"], +} + + +def _classify_task(task: dict) -> dict: + """Derive phaseConfig for a single task dict.""" + title = task.get("title", "") or task.get("name", "") + description = task.get("description", "") or "" + details = task.get("details", "") or "" + classification_details = re.split( + r'\n\s*RESEARCH NOTES\s*\(parallel pass\):', + details, + maxsplit=1, + flags=re.IGNORECASE, + )[0] + task_type = task.get("type", "") or "" + + combined = f"{title} {description} {classification_details}".lower() + + # VALIDATION: USER-TEST tasks + if "user-test" in combined or "user validation checkpoint" in title.lower(): + complexity = "VALIDATION" + # RESEARCH: research/investigation tasks + elif _RESEARCH_KEYWORDS.search(combined): + complexity = "RESEARCH" + # COMPLEX: architectural/integration/security tasks or many subtasks + elif _COMPLEX_KEYWORDS.search(combined) or len(task.get("subtasks", [])) >= 5: + complexity = "COMPLEX" + # MEDIUM: moderate — multiple subtasks or moderate description + elif len(task.get("subtasks", [])) >= 2 or len(combined.split()) >= 30: + complexity = "MEDIUM" + else: + complexity = "SIMPLE" + + requires_cdd = complexity in ("MEDIUM", "COMPLEX", "RESEARCH") + requires_research = complexity in ("RESEARCH", "COMPLEX") + + acceptance_criteria = _generate_acceptance_criteria(task, complexity) + + return { + "complexity": complexity, + "requiresCDD": requires_cdd, + "requiresResearch": requires_research, + "lifecycle": _LIFECYCLE_MAP[complexity], + "cddCardId": None, # Set later when CDD card is created + "acceptanceCriteria": acceptance_criteria, + } + + +def _generate_acceptance_criteria(task: dict, complexity: str) -> list: + """Generate sensible acceptance criteria based on task content and complexity.""" + title = task.get("title", "") or task.get("name", "") + description = task.get("description", "") or "" + criteria = [] + + # Universal criteria for all tasks + criteria.append(f"Implementation matches requirements described in task: {title}") + criteria.append("All automated tests pass with no regressions") + + # Complexity-specific criteria + if complexity == "VALIDATION": + criteria = [ + "All listed functionality manually tested and confirmed working", + "No critical or high-severity bugs present", + "Performance meets defined targets", + "User experience meets acceptance standards", + ] + elif complexity == "RESEARCH": + criteria.extend([ + "Research findings documented with sources", + "Recommendation or decision recorded in task notes", + "Trade-offs clearly articulated", + ]) + elif complexity == "COMPLEX": + criteria.extend([ + "Architecture decision documented (ADR or task notes)", + "Integration points tested end-to-end", + "Security considerations addressed", + "Performance benchmarked against targets", + "Code reviewed before merge", + ]) + elif complexity == "MEDIUM": + criteria.extend([ + "Unit tests cover primary code paths", + "Edge cases handled and documented", + "Code reviewed or self-reviewed", + ]) + # SIMPLE gets only the universal criteria + + # Extract subtask-derived criteria + for subtask in task.get("subtasks", []): + st_title = subtask.get("title", "") or subtask.get("name", "") + if st_title: + criteria.append(f"Subtask completed: {st_title}") + + return criteria + + +def run_enrich_tasks(input_path: str | None) -> dict: + """Enrich tasks.json with phaseConfig metadata. + + Classifies each task's complexity (SIMPLE/MEDIUM/COMPLEX/RESEARCH/VALIDATION), + sets requiresCDD/requiresResearch flags, generates acceptanceCriteria, and + assigns lifecycle phases. Writes phaseConfig directly into tasks.json. + + This is intentionally a direct write — TaskMaster CLI cannot inject structured + JSON metadata, so we own the enrichment as a post-parse step. + """ + tasks_path = Path(input_path) if input_path else TASKMASTER_TASKS / "tasks.json" + if not tasks_path.is_file(): + raise CommandError(f"tasks.json not found: {tasks_path}") + + try: + with open(tasks_path) as f: + raw = json.load(f) + except json.JSONDecodeError as e: + raise CommandError(f"Failed to parse {tasks_path}: {e}") + + # Support {tasks: [...]}, tagged {"master": {"tasks": [...]}}, and bare list formats. + tasks, wrapper = _resolve_tasks_payload(raw) + if not isinstance(tasks, list): + raise CommandError( + "tasks.json must be a list, a flat object with a 'tasks' list, or a tagged TaskMaster object", + {"tasks_path": str(tasks_path)}, + ) + + enriched_count = 0 + for task in tasks: + if not isinstance(task, dict): + continue + + # Skip if already enriched (idempotent) + if "phaseConfig" in task: + continue + + phase_config = _classify_task(task) + task["phaseConfig"] = phase_config + enriched_count += 1 + + # Write back atomically + tmp_path = tasks_path.with_suffix(".json.tmp") + try: + tmp_path.write_text(json.dumps(wrapper, indent=2, default=str)) + tmp_path.replace(tasks_path) + except Exception as e: + if tmp_path.exists(): + tmp_path.unlink() + raise CommandError(f"Failed to write {tasks_path}: {e}") + + return { + "ok": True, + "tasks_path": str(tasks_path), + "total_tasks": len(tasks), + "enriched": enriched_count, + "already_enriched": len(tasks) - enriched_count, + } + + +def cmd_enrich_tasks(args: argparse.Namespace) -> None: + try: + emit(run_enrich_tasks(args.input)) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/prd_taskmaster/telemetry.py b/prd_taskmaster/telemetry.py new file mode 100644 index 0000000..40ea4ec --- /dev/null +++ b/prd_taskmaster/telemetry.py @@ -0,0 +1,98 @@ +"""Privacy-first Atlas client telemetry.""" + +from __future__ import annotations + +import uuid +import json +import os +import platform +import threading +import urllib.request as urllib_request +from pathlib import Path + +from prd_taskmaster import __version__ + + +_ATLAS_DIRNAME = ".atlas-ai" +_INSTALL_ID_FILENAME = "install_id" +_TELEMETRY_URL = "https://api.atlas-ai.au/telemetry" +_EVENTS = frozenset({"atlas_invoked", "reach_execute", "ship_check_ok", "install"}) + + +def _install_id_path() -> Path: + return Path.home() / _ATLAS_DIRNAME / _INSTALL_ID_FILENAME + + +def _valid_uuid4(value: str) -> bool: + try: + parsed = uuid.UUID(value) + except ValueError: + return False + return parsed.version == 4 and str(parsed) == value + + +def get_install_id() -> str: + """Return the stable anonymous install UUID for this machine user.""" + path = _install_id_path() + try: + existing = path.read_text(encoding="utf-8").strip() + except OSError: + existing = "" + if _valid_uuid4(existing): + return existing + + install_id = str(uuid.uuid4()) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path.write_text(install_id + "\n", encoding="utf-8") + return install_id + + +def _config_opted_out() -> bool: + for path in ( + Path.home() / _ATLAS_DIRNAME / "config.json", + Path.cwd() / ".taskmaster" / "config.json", + ): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + continue + if isinstance(data, dict) and data.get("telemetry") is False: + return True + return False + + +def _opted_out() -> bool: + return os.environ.get("ATLAS_TELEMETRY") == "0" or _config_opted_out() + + +def _post_payload(payload: dict[str, str]) -> None: + try: + body = json.dumps(payload).encode("utf-8") + request = urllib_request.Request( + _TELEMETRY_URL, + data=body, + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(request, timeout=1) as response: + response.read() + except Exception: + pass + + +def send_event(event_name: str) -> None: + """Send a best-effort anonymous telemetry event without blocking exit.""" + if event_name not in _EVENTS or _opted_out(): + return + + try: + payload = { + "install_id": get_install_id(), + "event": event_name, + "version": __version__, + "os": platform.system(), + } + thread = threading.Thread(target=lambda: _post_payload(payload), daemon=True) + thread.start() + except Exception: + pass diff --git a/prd_taskmaster/templates.py b/prd_taskmaster/templates.py new file mode 100644 index 0000000..d494b00 --- /dev/null +++ b/prd_taskmaster/templates.py @@ -0,0 +1,37 @@ +"""PRD template loading command.""" + +import argparse + +from prd_taskmaster.lib import ( + TEMPLATE_DIR, + CommandError, + emit, + fail, +) + + +def run_load_template(template_type: str) -> dict: + """Load a PRD template by type.""" + template_map = { + "comprehensive": TEMPLATE_DIR / "taskmaster-prd-comprehensive.md", + "minimal": TEMPLATE_DIR / "taskmaster-prd-minimal.md", + } + tpl_path = template_map.get(template_type) + if not tpl_path or not tpl_path.is_file(): + raise CommandError(f"Template not found: {template_type}", {"available": list(template_map.keys())}) + + content = tpl_path.read_text() + return { + "ok": True, + "type": template_type, + "path": str(tpl_path), + "content": content, + "line_count": content.count('\n') + 1, + } + + +def cmd_load_template(args: argparse.Namespace) -> None: + try: + emit(run_load_template(args.type)) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/prd_taskmaster/validation.py b/prd_taskmaster/validation.py new file mode 100644 index 0000000..b012035 --- /dev/null +++ b/prd_taskmaster/validation.py @@ -0,0 +1,460 @@ +"""PRD and tasks.json validation commands.""" + +import argparse +import json +import re +from pathlib import Path + +from prd_taskmaster.lib import ( + TASKMASTER_TASKS, + VAGUE_PATTERN, + CommandError, + emit, + fail, + count_requirements, + get_section_content, + has_section, + word_count, + _resolve_tasks_payload, +) + + +def run_validate_prd(input_path: str) -> dict: + """Run 13 quality checks on a PRD file.""" + prd_path = Path(input_path) + if not prd_path.is_file(): + raise CommandError(f"PRD file not found: {input_path}") + + text = prd_path.read_text() + checks = [] + warnings = [] + + # ─── Required Elements (9 checks, 5 points each = 45 points) ───────── + + # Check 1: Executive summary exists and is 50-200 words + exec_summary = get_section_content(text, "Executive Summary") + wc = word_count(exec_summary) + checks.append({ + "id": 1, + "category": "required", + "name": "Executive summary exists", + "passed": has_section(text, "Executive Summary") and 20 <= wc <= 500, + "detail": f"Found {wc} words" if exec_summary else "Section missing", + "points": 5, + }) + + # Check 2: Problem statement includes user impact + problem = get_section_content(text, "Problem Statement") + has_user_impact = bool( + re.search(r'user\s+impact|who\s+is\s+affected|pain\s+point', problem, re.IGNORECASE) + or has_section(text, "User Impact") + ) + checks.append({ + "id": 2, + "category": "required", + "name": "Problem statement includes user impact", + "passed": has_user_impact, + "detail": "User impact found" if has_user_impact else "No user impact section", + "points": 5, + }) + + # Check 3: Problem statement includes business impact + has_biz_impact = bool( + re.search(r'business\s+impact|revenue|cost|strategic', problem, re.IGNORECASE) + or has_section(text, "Business Impact") + ) + checks.append({ + "id": 3, + "category": "required", + "name": "Problem statement includes business impact", + "passed": has_biz_impact, + "detail": "Business impact found" if has_biz_impact else "No business impact section", + "points": 5, + }) + + # Check 4: Goals have SMART metrics + goals_section = get_section_content(text, "Goals") + has_smart = bool(re.search( + r'(metric|baseline|target|timeframe|measurement)', + goals_section, re.IGNORECASE + )) + checks.append({ + "id": 4, + "category": "required", + "name": "Goals have SMART metrics", + "passed": has_smart, + "detail": "SMART elements found" if has_smart else "Goals lack measurable metrics", + "points": 5, + }) + + # Check 5: User stories have acceptance criteria (min 3 per story) + stories_section = get_section_content(text, "User Stories") + story_blocks = re.split(r'###\s+Story\s+\d+', stories_section) + ac_counts = [] + for block in story_blocks[1:]: # skip pre-heading text + ac_matches = re.findall(r'- \[[ x]\]', block) + ac_counts.append(len(ac_matches)) + stories_ok = all(c >= 3 for c in ac_counts) if ac_counts else False + checks.append({ + "id": 5, + "category": "required", + "name": "User stories have acceptance criteria (min 3)", + "passed": stories_ok or not ac_counts, # pass if no stories section (minimal template) + "detail": f"Stories: {len(ac_counts)}, AC counts: {ac_counts}" if ac_counts else "No user stories found (may be minimal PRD)", + "points": 5, + }) + + # Check 6: Functional requirements are testable (no vague language) + reqs_section = get_section_content(text, "Functional Requirements") + if not reqs_section: + reqs_section = get_section_content(text, "Requirements") + vague_in_reqs = VAGUE_PATTERN.findall(reqs_section) + checks.append({ + "id": 6, + "category": "required", + "name": "Functional requirements are testable", + "passed": len(vague_in_reqs) == 0, + "detail": f"Vague terms found: {vague_in_reqs}" if vague_in_reqs else "All requirements are specific", + "points": 5, + }) + + # Check 7: Each requirement has priority (Must/Should/Could or P0/P1/P2) + has_priority = bool(re.search( + r'(must\s+have|should\s+have|could\s+have|nice\s+to\s+have|P0|P1|P2)', + reqs_section, re.IGNORECASE + )) + checks.append({ + "id": 7, + "category": "required", + "name": "Requirements have priority labels", + "passed": has_priority, + "detail": "Priority labels found" if has_priority else "No priority classification found", + "points": 5, + }) + + # Check 8: Requirements are numbered (REQ-NNN) + req_count = count_requirements(text) + checks.append({ + "id": 8, + "category": "required", + "name": "Requirements are numbered (REQ-NNN)", + "passed": req_count > 0, + "detail": f"Found {req_count} numbered requirements" if req_count else "No REQ-NNN numbering found", + "points": 5, + }) + + # Check 9: Technical considerations address architecture + tech_section = get_section_content(text, "Technical") + has_arch = bool(re.search( + r'(architecture|system\s+design|component|integration|diagram)', + tech_section, re.IGNORECASE + )) + checks.append({ + "id": 9, + "category": "required", + "name": "Technical considerations address architecture", + "passed": has_arch, + "detail": "Architecture content found" if has_arch else "No architectural detail found", + "points": 5, + }) + + # ─── Taskmaster-specific (4 checks, 3 points each = 12 points) ─────── + + # Check 10: Non-functional requirements have specific targets + nfr_section = get_section_content(text, "Non-Functional") + has_nfr_targets = bool(re.search( + r'\d+\s*(ms|seconds?|minutes?|%|MB|GB|requests?/s)', + nfr_section, re.IGNORECASE + )) + checks.append({ + "id": 10, + "category": "taskmaster", + "name": "Non-functional requirements have specific targets", + "passed": has_nfr_targets or not nfr_section, + "detail": "Specific targets found" if has_nfr_targets else "No measurable NFR targets", + "points": 3, + }) + + # Check 11: Requirements have task breakdown hints + has_task_hints = bool(re.search( + r'task\s+breakdown|implementation\s+step|~\d+h', + text, re.IGNORECASE + )) + checks.append({ + "id": 11, + "category": "taskmaster", + "name": "Requirements have task breakdown hints", + "passed": has_task_hints, + "detail": "Task breakdown hints found" if has_task_hints else "No task breakdown hints", + "points": 3, + }) + + # Check 12: Dependencies identified + has_deps = bool(re.search( + r'(dependenc|depends\s+on|blocked\s+by|prerequisite|REQ-\d{3}.*depends)', + text, re.IGNORECASE + )) + checks.append({ + "id": 12, + "category": "taskmaster", + "name": "Dependencies identified for task sequencing", + "passed": has_deps, + "detail": "Dependencies documented" if has_deps else "No dependency information found", + "points": 3, + }) + + # Check 13: Out of scope defined + has_oos = has_section(text, "Out of Scope") + oos_content = get_section_content(text, "Out of Scope") + checks.append({ + "id": 13, + "category": "taskmaster", + "name": "Out of scope explicitly defined", + "passed": has_oos and len(oos_content.strip()) > 10, + "detail": "Out of scope section found" if has_oos else "No Out of Scope section", + "points": 3, + }) + + # ─── Placeholder detection (catches template leftovers) ──────────── + placeholder_patterns = [ + (r'\{\{[^}]+\}\}', 'mustache'), # {{placeholder}} + (r'\[TBD\]', 'tbd'), # [TBD] + (r'\[TODO\]', 'todo'), # [TODO] + (r'\[INSERT .+?\]', 'insert'), # [INSERT something] + (r'<[A-Z][A-Z_ ]+>', 'angle_bracket'), # <PLACEHOLDER> + (r'\[(?:Name|Date|Feature|Product|YYYY)\]', 'bracket'), # [Name], [Date], etc. + ] + placeholders_found = [] + for pattern, ptype in placeholder_patterns: + matches = re.findall(pattern, text) + for m in matches: + placeholders_found.append({"type": ptype, "match": m}) + + if placeholders_found: + warnings.append({ + "type": "placeholders", + "count": len(placeholders_found), + "items": placeholders_found[:10], # cap at 10 for readability + "suggestion": "Replace all template placeholders with actual content", + }) + + # ─── Vague language warnings ───────────────────────────────────────── + all_vague = VAGUE_PATTERN.findall(text) + vague_penalty = min(len(all_vague), 5) + for match in set(all_vague): + warnings.append({ + "type": "vague_language", + "term": match, + "suggestion": f"Replace '{match}' with a specific, measurable target", + }) + + # ─── Missing detail warnings ───────────────────────────────────────── + if not has_section(text, "Validation Checkpoint"): + warnings.append({ + "type": "missing_detail", + "item": "Validation checkpoints", + "suggestion": "Add validation checkpoints for each implementation phase", + }) + + # ─── Scoring ───────────────────────────────────────────────────────── + score = sum(c["points"] for c in checks if c["passed"]) + max_score = sum(c["points"] for c in checks) + placeholder_penalty = min(len(placeholders_found), 10) + score -= vague_penalty # deduct for vague language + score -= placeholder_penalty # deduct for template placeholders + score = max(0, score) + + pct = (score / max_score * 100) if max_score > 0 else 0 + if pct >= 91: + grade = "EXCELLENT" + elif pct >= 83: + grade = "GOOD" + elif pct >= 75: + grade = "ACCEPTABLE" + else: + grade = "NEEDS_WORK" + + passed_count = sum(1 for c in checks if c["passed"]) + + return { + "ok": True, + "score": score, + "max_score": max_score, + "percentage": round(pct, 1), + "grade": grade, + "checks_passed": passed_count, + "checks_total": len(checks), + "checks": checks, + "warnings": warnings, + "vague_penalty": vague_penalty, + "placeholder_penalty": placeholder_penalty, + "placeholders_found": len(placeholders_found), + "placeholder_details": placeholders_found[:10] if placeholders_found else [], + } + + +def cmd_validate_prd(args: argparse.Namespace) -> None: + try: + emit(run_validate_prd(args.input)) + except CommandError as e: + fail(e.message, **e.extra) + + +def run_validate_tasks(input_path: str | None, allow_empty_subtasks: bool, require_phase_config: bool) -> dict: + """Validate a manually-authored TaskMaster-compatible tasks.json file.""" + tasks_path = Path(input_path) if input_path else TASKMASTER_TASKS / "tasks.json" + if not tasks_path.is_file(): + raise CommandError(f"tasks.json not found: {tasks_path}") + + try: + with open(tasks_path) as f: + raw = json.load(f) + except json.JSONDecodeError as e: + raise CommandError(f"Failed to parse {tasks_path}: {e}") + + tasks, _ = _resolve_tasks_payload(raw) + if not isinstance(tasks, list): + raise CommandError( + "tasks.json must be a list, a flat object with a 'tasks' list, or a tagged TaskMaster object", + {"tasks_path": str(tasks_path)}, + ) + + allowed_statuses = {"pending", "in-progress", "review", "done", "deferred", "cancelled"} + allowed_priorities = {"high", "medium", "low"} + problems = [] + ids = [] + + placeholder_re = re.compile( + r'(\{\{[^}]+\}\}|\[TBD\]|\[TODO\]|\[INSERT .+?\]|<[A-Z][A-Z_ ]+>|\[(?:Name|Date|Feature|Product|YYYY)\])', + re.IGNORECASE, + ) + generic_re = re.compile( + r'^\s*(implement|build|create|add|fix)\s+(feature|functionality|task|thing|stuff)\s*$', + re.IGNORECASE, + ) + + def check_text(label: str, field: str, value: object, *, required: bool = True) -> str: + text = str(value or "").strip() + if required and not text: + problems.append(f"{label}: missing {field}") + return text + if text and placeholder_re.search(text): + problems.append(f"{label}: {field} contains placeholder text") + if text and generic_re.search(text): + problems.append(f"{label}: {field} is too generic") + return text + + for index, task in enumerate(tasks): + if not isinstance(task, dict): + problems.append(f"task[{index}] must be an object") + continue + + task_id = task.get("id") + ids.append(task_id) + label = f"task {task_id if task_id is not None else index + 1}" + + if task_id is None: + problems.append(f"{label}: missing id") + check_text(label, "title", task.get("title")) + check_text(label, "description", task.get("description")) + check_text(label, "details", task.get("details")) + check_text(label, "testStrategy", task.get("testStrategy")) + if task.get("status", "pending") not in allowed_statuses: + problems.append(f"{label}: invalid status {task.get('status')!r}") + priority = str(task.get("priority", "")).strip().lower() + if not priority: + problems.append(f"{label}: missing priority") + elif priority not in allowed_priorities: + problems.append(f"{label}: invalid priority {task.get('priority')!r}") + if require_phase_config and not isinstance(task.get("phaseConfig"), dict): + problems.append(f"{label}: missing phaseConfig") + + deps = task.get("dependencies", []) + if deps is None: + deps = [] + if not isinstance(deps, list): + problems.append(f"{label}: dependencies must be a list") + + subtasks = task.get("subtasks", []) + if subtasks is None: + subtasks = [] + if not isinstance(subtasks, list): + problems.append(f"{label}: subtasks must be a list") + continue + if not allow_empty_subtasks and len(subtasks) < 2: + problems.append(f"{label}: must have at least 2 subtasks") + + sub_ids = set() + for sub_index, subtask in enumerate(subtasks): + if not isinstance(subtask, dict): + problems.append(f"{label} subtask[{sub_index}]: must be an object") + continue + sub_id = subtask.get("id") + if sub_id in sub_ids: + problems.append(f"{label}: duplicate subtask id {sub_id!r}") + sub_ids.add(sub_id) + if sub_id is None: + problems.append(f"{label} subtask[{sub_index}]: missing id") + sub_label = f"{label} subtask {sub_id if sub_id is not None else sub_index + 1}" + check_text(sub_label, "title", subtask.get("title")) + check_text(sub_label, "description", subtask.get("description")) + if subtask.get("status", "pending") not in allowed_statuses: + problems.append(f"{label} subtask[{sub_index}]: invalid status {subtask.get('status')!r}") + sub_deps = subtask.get("dependencies", []) + if sub_deps is None: + sub_deps = [] + if not isinstance(sub_deps, list): + problems.append(f"{sub_label}: dependencies must be a list") + + for subtask in subtasks: + if not isinstance(subtask, dict): + continue + sub_id = subtask.get("id") + sub_deps = subtask.get("dependencies", []) or [] + if not isinstance(sub_deps, list): + continue + for dep in sub_deps: + if dep not in sub_ids: + problems.append(f"{label} subtask {sub_id}: dependency {dep!r} does not exist in sibling subtasks") + + real_ids = [task_id for task_id in ids if task_id is not None] + duplicate_ids = sorted({task_id for task_id in real_ids if real_ids.count(task_id) > 1}, key=str) + for task_id in duplicate_ids: + problems.append(f"duplicate task id {task_id!r}") + + id_set = set(real_ids) + for task in tasks: + if not isinstance(task, dict): + continue + task_id = task.get("id") + deps = task.get("dependencies", []) or [] + if not isinstance(deps, list): + continue + for dep in deps: + if dep not in id_set: + problems.append(f"task {task_id}: dependency {dep!r} does not exist") + + if problems: + raise CommandError( + "Task validation failed", + { + "tasks_path": str(tasks_path), + "task_count": len(tasks), + "problems": problems, + }, + ) + + return { + "ok": True, + "tasks_path": str(tasks_path), + "task_count": len(tasks), + "subtask_count": sum(len(t.get("subtasks", []) or []) for t in tasks if isinstance(t, dict)), + "message": "Task file is valid for manual prd-taskmaster mode", + } + + +def cmd_validate_tasks(args: argparse.Namespace) -> None: + try: + emit(run_validate_tasks(args.input, args.allow_empty_subtasks, args.require_phase_config)) + except CommandError as e: + fail(e.message, **e.extra) diff --git a/script.py b/script.py index 97c2bde..ba0f496 100755 --- a/script.py +++ b/script.py @@ -1,1079 +1,8 @@ #!/usr/bin/env python3 -"""PRD-TaskMaster automation script. - -Replaces all deterministic operations from SKILL.md with code. -AI handles judgment (questions, PRD content, decisions); this script handles mechanics. - -Usage: - script.py preflight # Detect environment state - script.py detect-taskmaster # Find MCP or CLI taskmaster - script.py load-template --type comprehensive|minimal # Load PRD template - script.py validate-prd --input <path> # Run 13 quality checks - script.py calc-tasks --requirements <count> # Calculate recommended task count - script.py gen-test-tasks --total <count> # Generate USER-TEST task specs - script.py gen-scripts --output-dir <dir> # Create 5 tracking scripts - script.py backup-prd --input <path> # Timestamped backup - script.py read-state # Crash recovery state - script.py log-progress --task-id T --title "..." # Append to progress.md - script.py init-taskmaster --method cli|mcp # Initialize taskmaster project -""" - -import argparse -import json -import math -import os -import re -import shutil -import subprocess +"""prd-taskmaster CLI — thin shim over the prd_taskmaster package.""" import sys -from datetime import datetime, timezone from pathlib import Path - -SKILL_DIR = Path(__file__).parent -TEMPLATE_DIR = SKILL_DIR / "templates" -TASKMASTER_DIR = Path(".taskmaster") -TASKMASTER_DOCS = TASKMASTER_DIR / "docs" -TASKMASTER_SCRIPTS = TASKMASTER_DIR / "scripts" -TASKMASTER_STATE = TASKMASTER_DIR / "state" -TASKMASTER_TASKS = TASKMASTER_DIR / "tasks" - -# ─── Helpers ────────────────────────────────────────────────────────────────── - -def emit(data: dict) -> None: - """Print JSON to stdout and exit 0.""" - print(json.dumps(data, indent=2, default=str)) - sys.exit(0) - - -def fail(message: str, **extra) -> None: - """Print JSON error to stdout and exit 1.""" - print(json.dumps({"ok": False, "error": message, **extra}, indent=2, default=str)) - sys.exit(1) - - -def now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def word_count(text: str) -> int: - return len(text.split()) - - -def count_requirements(text: str) -> int: - """Count REQ-NNN patterns in PRD text.""" - return len(set(re.findall(r'REQ-\d{3}', text))) - - -def has_section(text: str, heading: str) -> bool: - """Check if markdown heading exists (case-insensitive).""" - pattern = r'^#{1,3}\s+.*' + re.escape(heading) + r'.*$' - return bool(re.search(pattern, text, re.MULTILINE | re.IGNORECASE)) - - -def get_section_content(text: str, heading: str) -> str: - """Extract content under a markdown heading until next same-level heading.""" - lines = text.split('\n') - capturing = False - level = 0 - content = [] - heading_re = re.compile(r'^(#{1,6})\s+(.*)') - for line in lines: - heading_match = heading_re.match(line) - if heading_match and heading.lower() in heading_match.group(2).lower(): - capturing = True - level = len(heading_match.group(1)) - continue - if capturing: - if heading_match and len(heading_match.group(1)) <= level: - break - content.append(line) - return '\n'.join(content).strip() - - -# ─── VAGUE_PATTERNS for validation ─────────────────────────────────────────── - -VAGUE_WORDS = [ - "fast", "quick", "slow", "good", "bad", "poor", - "user-friendly", "easy", "simple", "secure", "safe", - "scalable", "flexible", "performant", "efficient", -] - -VAGUE_PATTERN = re.compile( - r'\b(?:should\s+be\s+|must\s+be\s+|needs?\s+to\s+be\s+)?' - r'(' + '|'.join(VAGUE_WORDS) + r')\b', - re.IGNORECASE -) - - -# ─── Subcommands ────────────────────────────────────────────────────────────── - -def cmd_preflight(args: argparse.Namespace) -> None: - """Detect environment: .taskmaster, PRD, task count, taskmaster method, CLAUDE.md, crash state.""" - has_taskmaster = TASKMASTER_DIR.is_dir() - prd_path = None - task_count = 0 - tasks_completed = 0 - tasks_pending = 0 - - if has_taskmaster: - # Find PRD - for candidate in [TASKMASTER_DOCS / "prd.md", TASKMASTER_DOCS / "prd.txt"]: - if candidate.is_file(): - prd_path = str(candidate) - break - if not prd_path: - # Glob for any .md in docs - docs = list(TASKMASTER_DOCS.glob("*.md")) if TASKMASTER_DOCS.is_dir() else [] - if docs: - prd_path = str(docs[0]) - - # Count tasks - tasks_json = TASKMASTER_TASKS / "tasks.json" - if tasks_json.is_file(): - try: - with open(tasks_json) as f: - data = json.load(f) - tasks = data.get("tasks", data) if isinstance(data, dict) else data - if isinstance(tasks, list): - task_count = len(tasks) - tasks_completed = sum(1 for t in tasks if t.get("status") == "done") - tasks_pending = task_count - tasks_completed - except (json.JSONDecodeError, KeyError): - pass - - # Detect taskmaster method - tm_method = _detect_taskmaster_method() - - # Check CLAUDE.md - has_claude_md = Path("CLAUDE.md").is_file() - - # Check crash state - crash_state = _read_execution_state() - - emit({ - "ok": True, - "has_taskmaster": has_taskmaster, - "prd_path": prd_path, - "task_count": task_count, - "tasks_completed": tasks_completed, - "tasks_pending": tasks_pending, - "taskmaster_method": tm_method["method"], - "has_claude_md": has_claude_md, - "has_crash_state": crash_state.get("has_incomplete", False), - "crash_state": crash_state if crash_state.get("has_incomplete") else None, - }) - - -def _detect_taskmaster_method() -> dict: - """Detect taskmaster: MCP > CLI > none.""" - # Check CLI - cli_path = shutil.which("taskmaster") - cli_version = None - if cli_path: - try: - result = subprocess.run( - ["taskmaster", "--version"], - capture_output=True, text=True, timeout=10 - ) - cli_version = result.stdout.strip() if result.returncode == 0 else None - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - - # MCP detection: check if mcp__task-master-ai tools are available - # We can't directly test MCP from a script, so we check for config hints - mcp_available = False - for config_path in [ - Path.home() / ".claude" / "settings" / "mcp.json", - Path.home() / ".config" / "claude-code" / "mcp.json", - Path(".mcp.json"), - ]: - if config_path.is_file(): - try: - with open(config_path) as f: - config = json.load(f) - servers = config.get("mcpServers", config.get("servers", {})) - if any("task-master" in k.lower() for k in servers): - mcp_available = True - break - except (json.JSONDecodeError, KeyError): - pass - - if mcp_available: - return {"method": "mcp", "version": None, "path": None} - elif cli_path: - return {"method": "cli", "version": cli_version, "path": cli_path} - else: - return {"method": "none", "version": None, "path": None} - - -def _read_execution_state() -> dict: - """Read crash recovery state from .taskmaster/state/execution-state.json.""" - state_file = TASKMASTER_STATE / "execution-state.json" - if not state_file.is_file(): - return {"has_incomplete": False} - try: - with open(state_file) as f: - state = json.load(f) - return { - "has_incomplete": state.get("status") == "in_progress", - "last_task": state.get("current_task"), - "last_subtask": state.get("current_subtask"), - "mode": state.get("mode"), - "last_updated": state.get("last_updated"), - "checkpoint": state.get("last_checkpoint"), - } - except (json.JSONDecodeError, KeyError): - return {"has_incomplete": False} - - -def cmd_detect_taskmaster(args: argparse.Namespace) -> None: - """Detect taskmaster method: MCP > CLI > none.""" - result = _detect_taskmaster_method() - emit({"ok": True, **result}) - - -def cmd_load_template(args: argparse.Namespace) -> None: - """Load a PRD template by type.""" - template_map = { - "comprehensive": TEMPLATE_DIR / "taskmaster-prd-comprehensive.md", - "minimal": TEMPLATE_DIR / "taskmaster-prd-minimal.md", - } - tpl_path = template_map.get(args.type) - if not tpl_path or not tpl_path.is_file(): - fail(f"Template not found: {args.type}", available=list(template_map.keys())) - - content = tpl_path.read_text() - emit({ - "ok": True, - "type": args.type, - "path": str(tpl_path), - "content": content, - "line_count": content.count('\n') + 1, - }) - - -def cmd_validate_prd(args: argparse.Namespace) -> None: - """Run 13 quality checks on a PRD file.""" - prd_path = Path(args.input) - if not prd_path.is_file(): - fail(f"PRD file not found: {args.input}") - - text = prd_path.read_text() - checks = [] - warnings = [] - - # ─── Required Elements (9 checks, 5 points each = 45 points) ───────── - - # Check 1: Executive summary exists and is 50-200 words - exec_summary = get_section_content(text, "Executive Summary") - wc = word_count(exec_summary) - checks.append({ - "id": 1, - "category": "required", - "name": "Executive summary exists", - "passed": has_section(text, "Executive Summary") and 20 <= wc <= 500, - "detail": f"Found {wc} words" if exec_summary else "Section missing", - "points": 5, - }) - - # Check 2: Problem statement includes user impact - problem = get_section_content(text, "Problem Statement") - has_user_impact = bool( - re.search(r'user\s+impact|who\s+is\s+affected|pain\s+point', problem, re.IGNORECASE) - or has_section(text, "User Impact") - ) - checks.append({ - "id": 2, - "category": "required", - "name": "Problem statement includes user impact", - "passed": has_user_impact, - "detail": "User impact found" if has_user_impact else "No user impact section", - "points": 5, - }) - - # Check 3: Problem statement includes business impact - has_biz_impact = bool( - re.search(r'business\s+impact|revenue|cost|strategic', problem, re.IGNORECASE) - or has_section(text, "Business Impact") - ) - checks.append({ - "id": 3, - "category": "required", - "name": "Problem statement includes business impact", - "passed": has_biz_impact, - "detail": "Business impact found" if has_biz_impact else "No business impact section", - "points": 5, - }) - - # Check 4: Goals have SMART metrics - goals_section = get_section_content(text, "Goals") - has_smart = bool(re.search( - r'(metric|baseline|target|timeframe|measurement)', - goals_section, re.IGNORECASE - )) - checks.append({ - "id": 4, - "category": "required", - "name": "Goals have SMART metrics", - "passed": has_smart, - "detail": "SMART elements found" if has_smart else "Goals lack measurable metrics", - "points": 5, - }) - - # Check 5: User stories have acceptance criteria (min 3 per story) - stories_section = get_section_content(text, "User Stories") - story_blocks = re.split(r'###\s+Story\s+\d+', stories_section) - ac_counts = [] - for block in story_blocks[1:]: # skip pre-heading text - ac_matches = re.findall(r'- \[[ x]\]', block) - ac_counts.append(len(ac_matches)) - stories_ok = all(c >= 3 for c in ac_counts) if ac_counts else False - checks.append({ - "id": 5, - "category": "required", - "name": "User stories have acceptance criteria (min 3)", - "passed": stories_ok or not ac_counts, # pass if no stories section (minimal template) - "detail": f"Stories: {len(ac_counts)}, AC counts: {ac_counts}" if ac_counts else "No user stories found (may be minimal PRD)", - "points": 5, - }) - - # Check 6: Functional requirements are testable (no vague language) - reqs_section = get_section_content(text, "Functional Requirements") - if not reqs_section: - reqs_section = get_section_content(text, "Requirements") - vague_in_reqs = VAGUE_PATTERN.findall(reqs_section) - checks.append({ - "id": 6, - "category": "required", - "name": "Functional requirements are testable", - "passed": len(vague_in_reqs) == 0, - "detail": f"Vague terms found: {vague_in_reqs}" if vague_in_reqs else "All requirements are specific", - "points": 5, - }) - - # Check 7: Each requirement has priority (Must/Should/Could or P0/P1/P2) - has_priority = bool(re.search( - r'(must\s+have|should\s+have|could\s+have|nice\s+to\s+have|P0|P1|P2)', - reqs_section, re.IGNORECASE - )) - checks.append({ - "id": 7, - "category": "required", - "name": "Requirements have priority labels", - "passed": has_priority, - "detail": "Priority labels found" if has_priority else "No priority classification found", - "points": 5, - }) - - # Check 8: Requirements are numbered (REQ-NNN) - req_count = count_requirements(text) - checks.append({ - "id": 8, - "category": "required", - "name": "Requirements are numbered (REQ-NNN)", - "passed": req_count > 0, - "detail": f"Found {req_count} numbered requirements" if req_count else "No REQ-NNN numbering found", - "points": 5, - }) - - # Check 9: Technical considerations address architecture - tech_section = get_section_content(text, "Technical") - has_arch = bool(re.search( - r'(architecture|system\s+design|component|integration|diagram)', - tech_section, re.IGNORECASE - )) - checks.append({ - "id": 9, - "category": "required", - "name": "Technical considerations address architecture", - "passed": has_arch, - "detail": "Architecture content found" if has_arch else "No architectural detail found", - "points": 5, - }) - - # ─── Taskmaster-specific (4 checks, 3 points each = 12 points) ─────── - - # Check 10: Non-functional requirements have specific targets - nfr_section = get_section_content(text, "Non-Functional") - has_nfr_targets = bool(re.search( - r'\d+\s*(ms|seconds?|minutes?|%|MB|GB|requests?/s)', - nfr_section, re.IGNORECASE - )) - checks.append({ - "id": 10, - "category": "taskmaster", - "name": "Non-functional requirements have specific targets", - "passed": has_nfr_targets or not nfr_section, - "detail": "Specific targets found" if has_nfr_targets else "No measurable NFR targets", - "points": 3, - }) - - # Check 11: Requirements have task breakdown hints - has_task_hints = bool(re.search( - r'task\s+breakdown|implementation\s+step|~\d+h', - text, re.IGNORECASE - )) - checks.append({ - "id": 11, - "category": "taskmaster", - "name": "Requirements have task breakdown hints", - "passed": has_task_hints, - "detail": "Task breakdown hints found" if has_task_hints else "No task breakdown hints", - "points": 3, - }) - - # Check 12: Dependencies identified - has_deps = bool(re.search( - r'(dependenc|depends\s+on|blocked\s+by|prerequisite|REQ-\d{3}.*depends)', - text, re.IGNORECASE - )) - checks.append({ - "id": 12, - "category": "taskmaster", - "name": "Dependencies identified for task sequencing", - "passed": has_deps, - "detail": "Dependencies documented" if has_deps else "No dependency information found", - "points": 3, - }) - - # Check 13: Out of scope defined - has_oos = has_section(text, "Out of Scope") - oos_content = get_section_content(text, "Out of Scope") - checks.append({ - "id": 13, - "category": "taskmaster", - "name": "Out of scope explicitly defined", - "passed": has_oos and len(oos_content.strip()) > 10, - "detail": "Out of scope section found" if has_oos else "No Out of Scope section", - "points": 3, - }) - - # ─── Vague language warnings ───────────────────────────────────────── - all_vague = VAGUE_PATTERN.findall(text) - vague_penalty = min(len(all_vague), 5) - for match in set(all_vague): - warnings.append({ - "type": "vague_language", - "term": match, - "suggestion": f"Replace '{match}' with a specific, measurable target", - }) - - # ─── Missing detail warnings ───────────────────────────────────────── - if not has_section(text, "Validation Checkpoint"): - warnings.append({ - "type": "missing_detail", - "item": "Validation checkpoints", - "suggestion": "Add validation checkpoints for each implementation phase", - }) - - # ─── Scoring ───────────────────────────────────────────────────────── - score = sum(c["points"] for c in checks if c["passed"]) - max_score = sum(c["points"] for c in checks) - score -= vague_penalty # deduct for vague language - score = max(0, score) - - pct = (score / max_score * 100) if max_score > 0 else 0 - if pct >= 91: - grade = "EXCELLENT" - elif pct >= 83: - grade = "GOOD" - elif pct >= 75: - grade = "ACCEPTABLE" - else: - grade = "NEEDS_WORK" - - passed_count = sum(1 for c in checks if c["passed"]) - - emit({ - "ok": True, - "score": score, - "max_score": max_score, - "percentage": round(pct, 1), - "grade": grade, - "checks_passed": passed_count, - "checks_total": len(checks), - "checks": checks, - "warnings": warnings, - "vague_penalty": vague_penalty, - }) - - -def cmd_calc_tasks(args: argparse.Namespace) -> None: - """Calculate recommended task count: requirements * 1.5, clamped 10-40.""" - raw = math.ceil(args.requirements * 1.5) - recommended = max(10, min(40, raw)) - emit({ - "ok": True, - "requirements_count": args.requirements, - "raw_calculation": raw, - "recommended": recommended, - "formula": "ceil(requirements * 1.5), clamped [10, 40]", - }) - - -def cmd_gen_test_tasks(args: argparse.Namespace) -> None: - """Generate USER-TEST task insertion specs (one every 5 tasks).""" - total = args.total - tasks = [] - checkpoint_num = 0 - - for i in range(5, total + 1, 5): - checkpoint_num += 1 - start = i - 4 - end = i - tasks.append({ - "checkpoint_number": checkpoint_num, - "title": f"User Validation Checkpoint {checkpoint_num}", - "insert_after_task": end, - "covers_tasks": f"{start}-{end}", - "description": f"Manually test functionality from Tasks {start} to {end}", - "priority": "high", - "dependencies": [str(end)], - "template": ( - f"# USER-TEST-{checkpoint_num}: User Validation Checkpoint {checkpoint_num}\n\n" - f"## Purpose\n" - f"Manual testing of functionality implemented in Tasks {start}-{end}\n\n" - f"## Prerequisites\n" - f"All subtasks in Tasks {start}-{end} must be completed and merged to main branch.\n\n" - f"## Testing Checklist\n\n" - f"### Functionality Tests\n" - f"- [ ] Test each requirement covered in Tasks {start}-{end}\n\n" - f"### Integration Tests\n" - f"- [ ] Test integration between components\n" - f"- [ ] Verify no regressions in existing features\n\n" - f"## Acceptance Criteria\n" - f"- All functionality tests pass\n" - f"- No critical bugs found\n" - f"- Performance meets targets\n\n" - f"## If Tests Fail\n" - f"1. Document issue in .taskmaster/docs/progress.md\n" - f"2. Create fix tasks before proceeding\n" - f"3. Do NOT continue to next tasks until fixed\n\n" - f"## When Complete\n" - f'Type "passed" to continue to next tasks.' - ), - }) - - emit({ - "ok": True, - "total_implementation_tasks": total, - "test_tasks_generated": len(tasks), - "final_total": total + len(tasks), - "tasks": tasks, - }) - - -def cmd_gen_scripts(args: argparse.Namespace) -> None: - """Create 5 automation scripts in output directory.""" - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - created = [] - - # 1. track-time.py - _write_script(output_dir / "track-time.py", _SCRIPT_TRACK_TIME) - created.append("track-time.py") - - # 2. rollback.sh - _write_script(output_dir / "rollback.sh", _SCRIPT_ROLLBACK) - created.append("rollback.sh") - - # 3. learn-accuracy.py - _write_script(output_dir / "learn-accuracy.py", _SCRIPT_LEARN_ACCURACY) - created.append("learn-accuracy.py") - - # 4. security-audit.py - _write_script(output_dir / "security-audit.py", _SCRIPT_SECURITY_AUDIT) - created.append("security-audit.py") - - # 5. execution-state.py - _write_script(output_dir / "execution-state.py", _SCRIPT_EXECUTION_STATE) - created.append("execution-state.py") - - emit({ - "ok": True, - "output_dir": str(output_dir), - "files_created": created, - "count": len(created), - }) - - -def _write_script(path: Path, content: str) -> None: - """Write a script file and make it executable.""" - path.write_text(content) - path.chmod(0o755) - - -def cmd_backup_prd(args: argparse.Namespace) -> None: - """Create timestamped backup of PRD.""" - src = Path(args.input) - if not src.is_file(): - fail(f"PRD file not found: {args.input}") - - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - backup_name = f"prd-backup-{ts}.md" - backup_dir = src.parent - backup_path = backup_dir / backup_name - - shutil.copy2(str(src), str(backup_path)) - emit({ - "ok": True, - "original": str(src), - "backup_path": str(backup_path), - "timestamp": ts, - }) - - -def cmd_read_state(args: argparse.Namespace) -> None: - """Read crash recovery state.""" - state = _read_execution_state() - emit({"ok": True, **state}) - - -def cmd_log_progress(args: argparse.Namespace) -> None: - """Append progress entry to .taskmaster/docs/progress.md.""" - progress_file = TASKMASTER_DOCS / "progress.md" - progress_file.parent.mkdir(parents=True, exist_ok=True) - - timestamp = now_iso() - entry = f""" -## Task {args.task_id}: {args.title} - COMPLETED -**Completed**: {timestamp} -**Duration**: {args.duration or 'N/A'} -**Subtasks**: {args.subtasks or 'N/A'} -**Tests**: {args.tests or 'N/A'} -**Issues**: {args.issues or 'None'} -**Git**: Merged to main, tagged as checkpoint-task-{args.task_id} - -""" - - if progress_file.is_file(): - existing = progress_file.read_text() - else: - existing = "# Task Progress Log\n\nAuto-generated by PRD-TaskMaster.\n\n---\n" - - progress_file.write_text(existing + entry) - - emit({ - "ok": True, - "path": str(progress_file), - "task_id": args.task_id, - "timestamp": timestamp, - }) - - -def cmd_init_taskmaster(args: argparse.Namespace) -> None: - """Initialize taskmaster project via CLI.""" - method = args.method - - if method == "cli": - cli_path = shutil.which("taskmaster") - if not cli_path: - fail("taskmaster CLI not found in PATH") - - try: - result = subprocess.run( - ["taskmaster", "init", "--yes", "--store-tasks-in-git", "--rules=claude"], - capture_output=True, text=True, timeout=60 - ) - emit({ - "ok": result.returncode == 0, - "method": "cli", - "stdout": result.stdout, - "stderr": result.stderr, - "returncode": result.returncode, - }) - except subprocess.TimeoutExpired: - fail("taskmaster init timed out after 60s") - except FileNotFoundError: - fail("taskmaster CLI not found") - - elif method == "mcp": - # MCP init must be done by AI via tool calls - we just confirm readiness - emit({ - "ok": True, - "method": "mcp", - "message": "MCP initialization must be done via AI tool call: mcp__task-master-ai__initialize_project", - "params": { - "projectRoot": str(Path.cwd()), - "yes": True, - "storeTasksInGit": True, - "initGit": False, - "rules": ["claude"], - }, - }) - else: - fail(f"Unknown method: {method}", valid=["cli", "mcp"]) - - -# ─── Embedded script templates ──────────────────────────────────────────────── - -_SCRIPT_TRACK_TIME = '''#!/usr/bin/env python3 -"""Track task start/end times with UTC timestamps.""" -import json, sys, os -from datetime import datetime, timezone -from pathlib import Path - -STATE_FILE = Path(".taskmaster/state/time-tracking.json") - -def load(): - if STATE_FILE.is_file(): - return json.loads(STATE_FILE.read_text()) - return {"tasks": {}} - -def save(data): - STATE_FILE.parent.mkdir(parents=True, exist_ok=True) - STATE_FILE.write_text(json.dumps(data, indent=2)) - -def now_iso(): - return datetime.now(timezone.utc).isoformat() - -if len(sys.argv) < 3: - print("Usage: track-time.py start|complete <task_id> [subtask_id]") - sys.exit(1) - -action, task_id = sys.argv[1], sys.argv[2] -subtask_id = sys.argv[3] if len(sys.argv) > 3 else None -data = load() - -key = f"{task_id}" + (f".{subtask_id}" if subtask_id else "") - -if action == "start": - data["tasks"][key] = {"started": now_iso(), "status": "in_progress"} - save(data) - print(json.dumps({"ok": True, "action": "started", "key": key, "time": now_iso()})) -elif action == "complete": - entry = data["tasks"].get(key, {}) - entry["completed"] = now_iso() - entry["status"] = "done" - if "started" in entry: - from datetime import datetime as dt - start = dt.fromisoformat(entry["started"]) - end = dt.fromisoformat(entry["completed"]) - entry["duration_minutes"] = round((end - start).total_seconds() / 60, 1) - data["tasks"][key] = entry - save(data) - print(json.dumps({"ok": True, "action": "completed", "key": key, "duration": entry.get("duration_minutes")})) -else: - print(json.dumps({"ok": False, "error": f"Unknown action: {action}"})) - sys.exit(1) -''' - -_SCRIPT_ROLLBACK = '''#!/usr/bin/env bash -# Rollback to a task checkpoint tag. -# Usage: rollback.sh <task_number> - -set -euo pipefail - -TASK_NUM="${1:?Usage: rollback.sh <task_number>}" -TAG="checkpoint-task-$(printf '%03d' "$TASK_NUM")" -TIMESTAMP=$(date +%Y%m%d-%H%M%S) -BACKUP_BRANCH="rollback-backup-${TIMESTAMP}" - -echo "Checking for checkpoint tag: $TAG" - -if ! git rev-parse "$TAG" >/dev/null 2>&1; then - echo "ERROR: Tag $TAG not found. Available checkpoints:" - git tag -l 'checkpoint-task-*' - exit 1 -fi - -echo "Creating backup branch: $BACKUP_BRANCH" -git branch "$BACKUP_BRANCH" - -echo "Resetting to $TAG..." -git reset --hard "$TAG" - -echo "" -echo "Rollback complete." -echo " Rolled back to: $TAG" -echo " Backup branch: $BACKUP_BRANCH" -echo "" -echo "To undo this rollback: git checkout $BACKUP_BRANCH" -''' - -_SCRIPT_LEARN_ACCURACY = '''#!/usr/bin/env python3 -"""Analyze estimation accuracy from time tracking data.""" -import json, sys -from pathlib import Path - -STATE_FILE = Path(".taskmaster/state/time-tracking.json") - -def main(): - if not STATE_FILE.is_file(): - print(json.dumps({"ok": False, "error": "No time tracking data found"})) - sys.exit(1) - - data = json.loads(STATE_FILE.read_text()) - tasks = data.get("tasks", {}) - - completed = {k: v for k, v in tasks.items() if v.get("status") == "done" and "duration_minutes" in v} - - if not completed: - print(json.dumps({"ok": True, "message": "No completed tasks with timing data", "count": 0})) - return - - durations = [v["duration_minutes"] for v in completed.values()] - avg_duration = sum(durations) / len(durations) - - print(json.dumps({ - "ok": True, - "tasks_analyzed": len(completed), - "average_duration_minutes": round(avg_duration, 1), - "total_minutes": round(sum(durations), 1), - "tasks": {k: v.get("duration_minutes") for k, v in completed.items()}, - }, indent=2)) - -if __name__ == "__main__": - main() -''' - -_SCRIPT_SECURITY_AUDIT = '''#!/usr/bin/env python3 -"""Auto-generate security checklist based on codebase scan.""" -import json, os, re, sys -from pathlib import Path - -def scan_patterns(): - """Scan for security-relevant patterns in codebase.""" - findings = [] - cwd = Path(".") - - # Detect patterns - patterns = { - "authentication": r"(password|login|auth|session|jwt|token)", - "database": r"(sql|query|SELECT|INSERT|UPDATE|DELETE|prisma|sequelize|knex)", - "api": r"(api|endpoint|route|controller|express|fastapi|flask)", - "encryption": r"(encrypt|decrypt|hash|bcrypt|crypto|aes)", - "file_upload": r"(upload|multipart|formdata|file.*input)", - "environment": r"(process\\.env|os\\.environ|dotenv|\\.env)", - } - - for ext in ["*.py", "*.js", "*.ts", "*.tsx", "*.jsx"]: - for f in cwd.rglob(ext): - if ".git" in str(f) or "node_modules" in str(f) or ".taskmaster" in str(f): - continue - try: - content = f.read_text(errors="ignore") - for category, pattern in patterns.items(): - if re.search(pattern, content, re.IGNORECASE): - findings.append({"file": str(f), "category": category}) - except Exception: - pass - - categories = list(set(f["category"] for f in findings)) - - checklist = [] - if "authentication" in categories: - checklist.extend([ - "Passwords hashed with bcrypt (cost >= 10)", - "Session tokens cryptographically secure", - "Rate limiting on auth endpoints", - ]) - if "database" in categories: - checklist.extend([ - "All queries use parameterized statements", - "No SQL injection vulnerabilities", - "Database credentials not in source code", - ]) - if "api" in categories: - checklist.extend([ - "HTTPS enforced in production", - "CSRF protection enabled", - "Input validation on all endpoints", - "Security headers set (CSP, X-Frame-Options)", - ]) - if "encryption" in categories: - checklist.extend([ - "Strong encryption algorithms used (AES-256)", - "Keys stored securely (not hardcoded)", - ]) - if "environment" in categories: - checklist.extend([ - ".env files in .gitignore", - "No secrets committed to repository", - ]) - - # Always include - checklist.extend([ - "Dependencies checked for vulnerabilities (npm audit / pip audit)", - "Error messages do not leak internal details", - ]) - - print(json.dumps({ - "ok": True, - "categories_detected": categories, - "findings_count": len(findings), - "checklist": checklist, - }, indent=2)) - -if __name__ == "__main__": - scan_patterns() -''' - -_SCRIPT_EXECUTION_STATE = '''#!/usr/bin/env python3 -"""Manage execution state for crash recovery.""" -import json, sys -from datetime import datetime, timezone -from pathlib import Path - -STATE_FILE = Path(".taskmaster/state/execution-state.json") - -def load(): - if STATE_FILE.is_file(): - return json.loads(STATE_FILE.read_text()) - return {} - -def save(data): - STATE_FILE.parent.mkdir(parents=True, exist_ok=True) - STATE_FILE.write_text(json.dumps(data, indent=2)) - -def now_iso(): - return datetime.now(timezone.utc).isoformat() - -if len(sys.argv) < 2: - print("Usage: execution-state.py status|start|complete|checkpoint <args>") - sys.exit(1) - -action = sys.argv[1] - -if action == "status": - state = load() - if not state: - print(json.dumps({"has_incomplete": False})) - else: - print(json.dumps({ - "has_incomplete": state.get("status") == "in_progress", - **state, - }, indent=2)) - -elif action == "start": - task_id = sys.argv[2] if len(sys.argv) > 2 else None - subtask_id = sys.argv[3] if len(sys.argv) > 3 else None - mode = sys.argv[4] if len(sys.argv) > 4 else "sequential" - state = load() - state.update({ - "status": "in_progress", - "current_task": task_id, - "current_subtask": subtask_id, - "mode": mode, - "last_updated": now_iso(), - }) - save(state) - print(json.dumps({"ok": True, "action": "started", **state})) - -elif action == "complete": - task_id = sys.argv[2] if len(sys.argv) > 2 else None - state = load() - completed = state.get("completed_tasks", []) - if task_id and task_id not in completed: - completed.append(task_id) - state.update({ - "status": "idle", - "current_task": None, - "current_subtask": None, - "completed_tasks": completed, - "last_updated": now_iso(), - "last_checkpoint": task_id, - }) - save(state) - print(json.dumps({"ok": True, "action": "completed", **state})) - -elif action == "checkpoint": - task_id = sys.argv[2] if len(sys.argv) > 2 else None - state = load() - state["last_checkpoint"] = task_id - state["last_updated"] = now_iso() - save(state) - print(json.dumps({"ok": True, "action": "checkpoint", **state})) - -else: - print(json.dumps({"ok": False, "error": f"Unknown action: {action}"})) - sys.exit(1) -''' - - -# ─── Argument parsing ───────────────────────────────────────────────────────── - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="prd-taskmaster", - description="PRD-TaskMaster automation: deterministic operations for the prd-taskmaster skill.", - ) - sub = parser.add_subparsers(dest="command", required=True) - - # preflight - sub.add_parser("preflight", help="Detect environment state") - - # detect-taskmaster - sub.add_parser("detect-taskmaster", help="Find MCP or CLI taskmaster") - - # load-template - p = sub.add_parser("load-template", help="Load PRD template") - p.add_argument("--type", required=True, choices=["comprehensive", "minimal"]) - - # validate-prd - p = sub.add_parser("validate-prd", help="Run 13 quality checks on a PRD") - p.add_argument("--input", required=True, help="Path to PRD file") - - # calc-tasks - p = sub.add_parser("calc-tasks", help="Calculate recommended task count") - p.add_argument("--requirements", required=True, type=int, help="Number of functional requirements") - - # gen-test-tasks - p = sub.add_parser("gen-test-tasks", help="Generate USER-TEST task specs") - p.add_argument("--total", required=True, type=int, help="Total implementation tasks") - - # gen-scripts - p = sub.add_parser("gen-scripts", help="Create 5 tracking scripts") - p.add_argument("--output-dir", required=True, help="Directory for script output") - - # backup-prd - p = sub.add_parser("backup-prd", help="Timestamped PRD backup") - p.add_argument("--input", required=True, help="Path to PRD file") - - # read-state - sub.add_parser("read-state", help="Read crash recovery state") - - # log-progress - p = sub.add_parser("log-progress", help="Append progress entry") - p.add_argument("--task-id", required=True) - p.add_argument("--title", required=True) - p.add_argument("--duration", default=None) - p.add_argument("--subtasks", default=None) - p.add_argument("--tests", default=None) - p.add_argument("--issues", default=None) - - # init-taskmaster - p = sub.add_parser("init-taskmaster", help="Initialize taskmaster project") - p.add_argument("--method", required=True, choices=["cli", "mcp"]) - - return parser - - -DISPATCH = { - "preflight": cmd_preflight, - "detect-taskmaster": cmd_detect_taskmaster, - "load-template": cmd_load_template, - "validate-prd": cmd_validate_prd, - "calc-tasks": cmd_calc_tasks, - "gen-test-tasks": cmd_gen_test_tasks, - "gen-scripts": cmd_gen_scripts, - "backup-prd": cmd_backup_prd, - "read-state": cmd_read_state, - "log-progress": cmd_log_progress, - "init-taskmaster": cmd_init_taskmaster, -} - - -def main(): - parser = build_parser() - args = parser.parse_args() - handler = DISPATCH.get(args.command) - if handler: - handler(args) - else: - fail(f"Unknown command: {args.command}") - - +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from prd_taskmaster.cli import main if __name__ == "__main__": main() diff --git a/scripts/deploy-worker.sh b/scripts/deploy-worker.sh new file mode 100755 index 0000000..8f4d93e --- /dev/null +++ b/scripts/deploy-worker.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKERS_DIR="$ROOT_DIR/workers" + +if [[ -f "$WORKERS_DIR/.dev.vars" ]]; then + echo "Refusing to deploy while workers/.dev.vars exists; move local secrets out of the bundle context first." >&2 + exit 1 +fi + +cd "$WORKERS_DIR" + +npm run typecheck +npm test + +if [[ "${DRY_RUN:-0}" == "1" ]]; then + npx wrangler deploy --dry-run --outdir dist + exit 0 +fi + +npx wrangler d1 migrations apply LICENSE_DB --remote +npx wrangler deploy diff --git a/scripts/license/ed25519_ref.py b/scripts/license/ed25519_ref.py new file mode 100644 index 0000000..491afc3 --- /dev/null +++ b/scripts/license/ed25519_ref.py @@ -0,0 +1,160 @@ +"""Small stdlib-only Ed25519 reference implementation. + +This module implements PureEdDSA Ed25519 from RFC 8032 for license-key test +fixtures and local signing tools. It is intentionally minimal: private keys are +32-byte seeds, public keys are 32 bytes, and signatures are 64 bytes. +""" + +from __future__ import annotations + +import hashlib +import os + + +P = 2**255 - 19 +Q = 2**252 + 27742317777372353535851937790883648493 +D = (-121665 * pow(121666, P - 2, P)) % P +SQRT_M1 = pow(2, (P - 1) // 4, P) + + +def _inv(x: int) -> int: + return pow(x, P - 2, P) + + +def _recover_x(y: int, sign: int) -> int | None: + if y >= P: + return None + x2 = ((y * y - 1) * _inv(D * y * y + 1)) % P + if x2 == 0: + return None if sign else 0 + x = pow(x2, (P + 3) // 8, P) + if (x * x - x2) % P != 0: + x = (x * SQRT_M1) % P + if (x * x - x2) % P != 0: + return None + if (x & 1) != sign: + x = P - x + return x + + +BASE_Y = (4 * _inv(5)) % P +BASE_X = _recover_x(BASE_Y, 0) +if BASE_X is None: # pragma: no cover - impossible for the Ed25519 base point. + raise RuntimeError("failed to recover Ed25519 base point") +BASE = (BASE_X, BASE_Y, 1, (BASE_X * BASE_Y) % P) +IDENTITY = (0, 1, 1, 0) + + +def _sha512(data: bytes) -> bytes: + return hashlib.sha512(data).digest() + + +def _sha512_modq(data: bytes) -> int: + return int.from_bytes(_sha512(data), "little") % Q + + +def _point_add(p1: tuple[int, int, int, int], p2: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + x1, y1, z1, t1 = p1 + x2, y2, z2, t2 = p2 + a = ((y1 - x1) * (y2 - x2)) % P + b = ((y1 + x1) * (y2 + x2)) % P + c = (2 * D * t1 * t2) % P + d = (2 * z1 * z2) % P + e = b - a + f = d - c + g = d + c + h = b + a + return ((e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P) + + +def _point_mul(scalar: int, point: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + result = IDENTITY + addend = point + while scalar: + if scalar & 1: + result = _point_add(result, addend) + addend = _point_add(addend, addend) + scalar >>= 1 + return result + + +def _point_equal(p1: tuple[int, int, int, int], p2: tuple[int, int, int, int]) -> bool: + return ( + (p1[0] * p2[2] - p2[0] * p1[2]) % P == 0 + and (p1[1] * p2[2] - p2[1] * p1[2]) % P == 0 + ) + + +def _point_compress(point: tuple[int, int, int, int]) -> bytes: + z_inv = _inv(point[2]) + x = (point[0] * z_inv) % P + y = (point[1] * z_inv) % P + return int.to_bytes(y | ((x & 1) << 255), 32, "little") + + +def _point_decompress(encoded: bytes) -> tuple[int, int, int, int] | None: + if len(encoded) != 32: + return None + y = int.from_bytes(encoded, "little") + sign = y >> 255 + y &= (1 << 255) - 1 + x = _recover_x(y, sign) + if x is None: + return None + return (x, y, 1, (x * y) % P) + + +def _secret_expand(secret: bytes) -> tuple[int, bytes]: + if len(secret) != 32: + raise ValueError("Ed25519 private seed must be 32 bytes") + digest = bytearray(_sha512(secret)) + digest[0] &= 248 + digest[31] &= 63 + digest[31] |= 64 + return int.from_bytes(digest[:32], "little"), bytes(digest[32:]) + + +def secret_to_public(secret: bytes) -> bytes: + """Derive the 32-byte Ed25519 public key from a 32-byte private seed.""" + scalar, _prefix = _secret_expand(secret) + return _point_compress(_point_mul(scalar, BASE)) + + +def sign(secret: bytes, message: bytes) -> bytes: + """Return a deterministic 64-byte PureEdDSA Ed25519 signature.""" + scalar, prefix = _secret_expand(secret) + public = _point_compress(_point_mul(scalar, BASE)) + r = _sha512_modq(prefix + message) + encoded_r = _point_compress(_point_mul(r, BASE)) + h = _sha512_modq(encoded_r + public + message) + s = (r + h * scalar) % Q + return encoded_r + int.to_bytes(s, 32, "little") + + +def verify(public: bytes, message: bytes, signature: bytes) -> bool: + """Return True when signature is valid for message under public.""" + if len(public) != 32 or len(signature) != 64: + return False + point_a = _point_decompress(public) + if point_a is None: + return False + encoded_r = signature[:32] + point_r = _point_decompress(encoded_r) + if point_r is None: + return False + s = int.from_bytes(signature[32:], "little") + if s >= Q: + return False + h = _sha512_modq(encoded_r + public + message) + return _point_equal(_point_mul(s, BASE), _point_add(point_r, _point_mul(h, point_a))) + + +def generate_private_key() -> bytes: + """Generate a 32-byte Ed25519 private seed.""" + return os.urandom(32) + + +def generate_keypair() -> tuple[bytes, bytes]: + """Generate and return (private_seed, public_key).""" + private_seed = generate_private_key() + return private_seed, secret_to_public(private_seed) diff --git a/scripts/license/sign-key.py b/scripts/license/sign-key.py new file mode 100755 index 0000000..2e6ad0c --- /dev/null +++ b/scripts/license/sign-key.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Atlas license key signer and verifier. + +License keys are encoded as ATLAS-<base64url(payload)>.<base64url(signature)>. +The signature is Ed25519 over the canonical JSON payload bytes. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import ed25519_ref # noqa: E402 + + +KEY_PREFIX = "ATLAS-" +PAYLOAD_FIELDS = ("lid", "sub", "plan", "iat", "exp", "v") +PLANS = ("pro-monthly", "pro-annual") +PRIVATE_KEY_ENV = "ATLAS_LICENSE_PRIVATE_KEY_HEX" +VECTOR_NOW = 1_717_200_000 +VECTOR_SEED_LABEL = b"atlas-license-test-vector-seed-v1" +VECTOR_MISMATCH_SEED_LABEL = b"atlas-license-test-vector-mismatch-seed-v1" +_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_HEX64_RE = re.compile(r"^[0-9a-f]{64}$") + + +class LicenseKeyError(ValueError): + """Raised when a license key is malformed or fails structural checks.""" + + +def canonical_payload_bytes(payload: dict[str, Any]) -> bytes: + """Return sorted-key, compact UTF-8 JSON bytes for a payload.""" + return json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +def b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def b64url_decode(value: str) -> bytes: + if not value or "=" in value or _B64URL_RE.fullmatch(value) is None: + raise LicenseKeyError("invalid base64url segment") + padded = value + ("=" * (-len(value) % 4)) + try: + return base64.urlsafe_b64decode(padded.encode("ascii")) + except ValueError as exc: + raise LicenseKeyError("invalid base64url data") from exc + + +def build_payload(*, lid: str, email: str, plan: str, iat: int, exp: int) -> dict[str, Any]: + if plan not in PLANS: + raise LicenseKeyError(f"plan must be one of: {', '.join(PLANS)}") + return { + "lid": lid, + "sub": hashlib.sha256(email.encode("utf-8")).hexdigest(), + "plan": plan, + "iat": int(iat), + "exp": int(exp), + "v": 1, + } + + +def validate_payload(payload: dict[str, Any]) -> None: + if set(payload) != set(PAYLOAD_FIELDS): + raise LicenseKeyError("payload fields must be exactly lid, sub, plan, iat, exp, v") + if not isinstance(payload["lid"], str) or not payload["lid"]: + raise LicenseKeyError("lid must be a non-empty string") + if not isinstance(payload["sub"], str) or _HEX64_RE.fullmatch(payload["sub"]) is None: + raise LicenseKeyError("sub must be a lowercase SHA-256 hex digest") + if payload["plan"] not in PLANS: + raise LicenseKeyError(f"plan must be one of: {', '.join(PLANS)}") + if not isinstance(payload["iat"], int) or not isinstance(payload["exp"], int): + raise LicenseKeyError("iat and exp must be integer Unix timestamps") + if payload["exp"] < payload["iat"]: + raise LicenseKeyError("exp must be greater than or equal to iat") + if payload["v"] != 1: + raise LicenseKeyError("v must be 1") + + +def encode_license_key(payload: dict[str, Any], signature: bytes) -> str: + validate_payload(payload) + if len(signature) != 64: + raise LicenseKeyError("Ed25519 signature must be 64 bytes") + return f"{KEY_PREFIX}{b64url_encode(canonical_payload_bytes(payload))}.{b64url_encode(signature)}" + + +def decode_license_key(license_key: str) -> tuple[dict[str, Any], bytes]: + if not license_key.startswith(KEY_PREFIX): + raise LicenseKeyError("license key must start with ATLAS-") + body = license_key[len(KEY_PREFIX) :] + if body.count(".") != 1: + raise LicenseKeyError("license key must contain one payload/signature separator") + payload_segment, signature_segment = body.split(".", 1) + payload_bytes = b64url_decode(payload_segment) + signature = b64url_decode(signature_segment) + if len(signature) != 64: + raise LicenseKeyError("Ed25519 signature must be 64 bytes") + try: + payload = json.loads(payload_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise LicenseKeyError("payload must be UTF-8 JSON") from exc + if not isinstance(payload, dict): + raise LicenseKeyError("payload must be a JSON object") + validate_payload(payload) + if payload_bytes != canonical_payload_bytes(payload): + raise LicenseKeyError("payload JSON is not canonical") + return payload, signature + + +def sign_payload(payload: dict[str, Any], private_seed: bytes) -> str: + validate_payload(payload) + signature = ed25519_ref.sign(private_seed, canonical_payload_bytes(payload)) + return encode_license_key(payload, signature) + + +def verify_license_key( + license_key: str, + public_key: bytes, + *, + now: int | None = None, +) -> dict[str, Any]: + try: + payload, signature = decode_license_key(license_key) + except LicenseKeyError as exc: + return {"ok": False, "reason": str(exc), "payload": None} + + if not ed25519_ref.verify(public_key, canonical_payload_bytes(payload), signature): + return {"ok": False, "reason": "signature mismatch", "payload": payload} + if now is not None and payload["exp"] < int(now): + return {"ok": False, "reason": "expired", "payload": payload} + return {"ok": True, "reason": "valid", "payload": payload} + + +def deterministic_vector_seed(label: bytes = VECTOR_SEED_LABEL) -> bytes: + """Return a deterministic non-secret seed for reproducible test vectors.""" + return hashlib.sha256(label).digest() + + +def _is_under_repo(path: Path) -> bool: + try: + path.resolve().relative_to(REPO_ROOT) + return True + except ValueError: + return False + + +def _read_private_seed(path: Path | None) -> bytes: + if path is None: + value = os.environ.get(PRIVATE_KEY_ENV) + if not value: + raise LicenseKeyError(f"provide --private-key or set {PRIVATE_KEY_ENV}") + return bytes.fromhex(value) + if _is_under_repo(path): + raise LicenseKeyError("private-key path must be outside the repository") + text = path.read_text(encoding="utf-8").strip() + try: + data = json.loads(text) + except json.JSONDecodeError: + seed_hex = text + else: + seed_hex = data.get("private_seed_hex", "") + seed = bytes.fromhex(seed_hex) + if len(seed) != 32: + raise LicenseKeyError("private key seed must be 32 bytes") + return seed + + +def _write_keypair(path: Path, *, force: bool) -> bytes: + if _is_under_repo(path): + raise LicenseKeyError("private-key path must be outside the repository") + if path.exists() and not force: + raise LicenseKeyError(f"{path} already exists; pass --force to overwrite") + private_seed, public_key = ed25519_ref.generate_keypair() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "kty": "Ed25519", + "private_seed_hex": private_seed.hex(), + "public_key_hex": public_key.hex(), + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + return public_key + + +def generate_vectors() -> dict[str, Any]: + private_seed = deterministic_vector_seed() + mismatch_seed = deterministic_vector_seed(VECTOR_MISMATCH_SEED_LABEL) + public_key = ed25519_ref.secret_to_public(private_seed) + + valid_payload = build_payload( + lid="lic_vec_valid", + email="valid@example.com", + plan="pro-monthly", + iat=VECTOR_NOW - 3600, + exp=VECTOR_NOW + 86400, + ) + expired_payload = build_payload( + lid="lic_vec_expired", + email="expired@example.com", + plan="pro-annual", + iat=VECTOR_NOW - 172800, + exp=VECTOR_NOW - 86400, + ) + + valid_key = sign_payload(valid_payload, private_seed) + expired_key = sign_payload(expired_payload, private_seed) + + tampered_payload = dict(valid_payload) + tampered_payload["lid"] = "lic_vec_tampered" + _original_payload, valid_signature = decode_license_key(valid_key) + tampered_key = encode_license_key(tampered_payload, valid_signature) + + mismatch_key = sign_payload(valid_payload, mismatch_seed) + + return { + "format": "atlas-license-key-v1", + "now": VECTOR_NOW, + "public_key_hex": public_key.hex(), + "vectors": [ + { + "name": "valid", + "payload": valid_payload, + "key": valid_key, + "expected": {"ok": True, "reason": "valid"}, + "reproducible": True, + }, + { + "name": "expired", + "payload": expired_payload, + "key": expired_key, + "expected": {"ok": False, "reason": "expired"}, + "reproducible": True, + }, + { + "name": "tampered-payload", + "payload": tampered_payload, + "key": tampered_key, + "expected": {"ok": False, "reason": "signature mismatch"}, + "reproducible": False, + }, + { + "name": "signature-mismatch", + "payload": valid_payload, + "key": mismatch_key, + "expected": {"ok": False, "reason": "signature mismatch"}, + "reproducible": False, + }, + ], + } + + +def _cmd_keygen(args: argparse.Namespace) -> int: + public_key = _write_keypair(args.private_key, force=args.force) + print(public_key.hex()) + return 0 + + +def _cmd_sign(args: argparse.Namespace) -> int: + private_seed = _read_private_seed(args.private_key) + payload = build_payload( + lid=args.lid, + email=args.email, + plan=args.plan, + iat=args.iat, + exp=args.exp, + ) + print(sign_payload(payload, private_seed)) + return 0 + + +def _cmd_verify(args: argparse.Namespace) -> int: + public_key = bytes.fromhex(args.public_key) + result = verify_license_key(args.key, public_key, now=args.now) + print(json.dumps(result, sort_keys=True)) + return 0 if result["ok"] else 1 + + +def _cmd_vectors(args: argparse.Namespace) -> int: + data = generate_vectors() + rendered = json.dumps(data, indent=2, sort_keys=True) + "\n" + if args.out: + args.out.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Sign and verify Atlas license keys.") + subcommands = parser.add_subparsers(dest="command", required=True) + + keygen = subcommands.add_parser("keygen", help="generate a local Ed25519 keypair") + keygen.add_argument( + "--private-key", + required=True, + type=Path, + help="path outside the repo where the private key JSON will be written", + ) + keygen.add_argument("--force", action="store_true", help="overwrite an existing private-key file") + keygen.set_defaults(func=_cmd_keygen) + + sign = subcommands.add_parser("sign", help="sign a license payload") + sign.add_argument( + "--private-key", + type=Path, + help=f"path outside the repo containing the private seed JSON; defaults to {PRIVATE_KEY_ENV}", + ) + sign.add_argument("--lid", required=True, help="license id to embed in the payload") + sign.add_argument("--email", required=True, help="subscriber email to SHA-256 hash into sub") + sign.add_argument("--plan", required=True, choices=PLANS, help="licensed plan") + sign.add_argument("--iat", required=True, type=int, help="issued-at Unix timestamp") + sign.add_argument("--exp", required=True, type=int, help="expiry Unix timestamp including 14-day grace") + sign.set_defaults(func=_cmd_sign) + + verify = subcommands.add_parser("verify", help="verify an Atlas license key") + verify.add_argument("--public-key", required=True, help="Ed25519 public key as 64 hex characters") + verify.add_argument("--key", required=True, help="ATLAS license key string to verify") + verify.add_argument("--now", type=int, help="Unix timestamp for expiry checks") + verify.set_defaults(func=_cmd_verify) + + vectors = subcommands.add_parser("vectors", help="generate deterministic license test vectors") + vectors.add_argument("--out", type=Path, help="optional output path for test_vectors.json") + vectors.set_defaults(func=_cmd_vectors) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except (OSError, ValueError, LicenseKeyError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/license/sign-key.ts b/scripts/license/sign-key.ts new file mode 100644 index 0000000..5bac7c2 --- /dev/null +++ b/scripts/license/sign-key.ts @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +const crypto = require("node:crypto"); + +const KEY_PREFIX = "ATLAS-"; +const PLANS = new Set(["pro-monthly", "pro-annual"]); +const PRIVATE_KEY_ENV = "ATLAS_LICENSE_PRIVATE_KEY_HEX"; +const VECTOR_SEED_LABEL = "atlas-license-test-vector-seed-v1"; +const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +function canonicalJson(value) { + if (value === null || typeof value !== "object") { + if (typeof value === "number" && !Number.isInteger(value)) { + throw new Error("payload numbers must be integers"); + } + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; +} + +function canonicalPayloadBytes(payload) { + validatePayload(payload); + return Buffer.from(canonicalJson(payload), "utf8"); +} + +function b64url(data) { + return Buffer.from(data) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function buildPayload({ lid, email, plan, iat, exp }) { + return { + lid, + sub: crypto.createHash("sha256").update(email, "utf8").digest("hex"), + plan, + iat: Number(iat), + exp: Number(exp), + v: 1, + }; +} + +function validatePayload(payload) { + const keys = Object.keys(payload).sort().join(","); + if (keys !== "exp,iat,lid,plan,sub,v") { + throw new Error("payload fields must be exactly lid, sub, plan, iat, exp, v"); + } + if (typeof payload.lid !== "string" || payload.lid.length === 0) { + throw new Error("lid must be a non-empty string"); + } + if (typeof payload.sub !== "string" || !/^[0-9a-f]{64}$/.test(payload.sub)) { + throw new Error("sub must be a lowercase SHA-256 hex digest"); + } + if (!PLANS.has(payload.plan)) { + throw new Error("plan must be pro-monthly or pro-annual"); + } + if (!Number.isInteger(payload.iat) || !Number.isInteger(payload.exp)) { + throw new Error("iat and exp must be integer Unix timestamps"); + } + if (payload.exp < payload.iat) { + throw new Error("exp must be greater than or equal to iat"); + } + if (payload.v !== 1) { + throw new Error("v must be 1"); + } +} + +function privateKeyFromSeed(seed) { + if (!Buffer.isBuffer(seed) || seed.length !== 32) { + throw new Error("Ed25519 private seed must be 32 bytes"); + } + return crypto.createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), + format: "der", + type: "pkcs8", + }); +} + +function encodeLicenseKey(payload, signature) { + if (!Buffer.isBuffer(signature) || signature.length !== 64) { + throw new Error("Ed25519 signature must be 64 bytes"); + } + return `${KEY_PREFIX}${b64url(canonicalPayloadBytes(payload))}.${b64url(signature)}`; +} + +function signPayload(payload, privateSeed) { + const signature = crypto.sign(null, canonicalPayloadBytes(payload), privateKeyFromSeed(privateSeed)); + return encodeLicenseKey(payload, signature); +} + +function deterministicVectorSeed() { + return crypto.createHash("sha256").update(VECTOR_SEED_LABEL, "utf8").digest(); +} + +function flagValue(args, flag) { + const index = args.indexOf(flag); + if (index === -1) { + return undefined; + } + if (index + 1 >= args.length) { + throw new Error(`${flag} requires a value`); + } + return args[index + 1]; +} + +function seedFromHex(hex) { + if (!hex || !/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error("private key seed must be 64 hex characters"); + } + return Buffer.from(hex, "hex"); +} + +function usage() { + return [ + "Usage:", + " node sign-key.ts sign-payload --payload-json JSON [--private-key-hex HEX]", + " node sign-key.ts sign --lid ID --email EMAIL --plan PLAN --iat TS --exp TS [--private-key-hex HEX]", + "", + `sign uses --private-key-hex or ${PRIVATE_KEY_ENV}. sign-payload defaults to the fixture seed.`, + ].join("\n"); +} + +function main(argv) { + const [command, ...args] = argv; + if (command === "--help" || command === "-h" || !command) { + console.log(usage()); + return 0; + } + if (command === "sign-payload") { + const payloadJson = flagValue(args, "--payload-json"); + if (!payloadJson) { + throw new Error("--payload-json is required"); + } + const seedHex = flagValue(args, "--private-key-hex"); + const seed = seedHex ? seedFromHex(seedHex) : deterministicVectorSeed(); + console.log(signPayload(JSON.parse(payloadJson), seed)); + return 0; + } + if (command === "sign") { + const seed = seedFromHex(flagValue(args, "--private-key-hex") || process.env[PRIVATE_KEY_ENV]); + const payload = buildPayload({ + lid: flagValue(args, "--lid"), + email: flagValue(args, "--email"), + plan: flagValue(args, "--plan"), + iat: flagValue(args, "--iat"), + exp: flagValue(args, "--exp"), + }); + console.log(signPayload(payload, seed)); + return 0; + } + throw new Error(`unknown command: ${command}\n${usage()}`); +} + +module.exports = { + buildPayload, + canonicalPayloadBytes, + encodeLicenseKey, + signPayload, +}; + +if (require.main === module) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 2; + } +} diff --git a/scripts/ops/kpi-snapshot.sh b/scripts/ops/kpi-snapshot.sh new file mode 100755 index 0000000..71b533e --- /dev/null +++ b/scripts/ops/kpi-snapshot.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env sh +set -eu + +print_header() { + printf '%-20s | %-12s | %-12s | %-12s\n' "Metric" "last-7d" "last-30d" "all-time" + printf '%-20s-+-%-12s-+-%-12s-+-%-12s\n' "--------------------" "------------" "------------" "------------" +} + +fixture_value() { + metric="$1" + bucket="$2" + python3 - "$metric" "$bucket" <<'PY' +import json +import os +import sys + +metric, bucket = sys.argv[1], sys.argv[2] +data = json.loads(os.environ["KPI_FIXTURE_JSON"]) +print(data.get(metric, {}).get(bucket, "not configured")) +PY +} + +query_value() { + metric="$1" + bucket="$2" + if [ -n "${KPI_FIXTURE_JSON:-}" ]; then + fixture_value "$metric" "$bucket" + return + fi + if [ -n "${KPI_QUERY_CMD:-}" ]; then + sh -c "$KPI_QUERY_CMD \"\$1\" \"\$2\"" sh "$metric" "$bucket" + return + fi + if [ -n "${CF_ACCOUNT_ID:-}" ] && [ -n "${CF_API_TOKEN:-}" ]; then + printf 'not configured (analytics query command missing)' + return + fi + printf 'not configured' +} + +row_value() { + metric="$1" + bucket="$2" + if [ "$metric" = "C1" ] && [ "${LICENSE_TELEMETRY_DEPLOYED:-0}" != "1" ]; then + printf 'n/a (license telemetry not deployed)' + return + fi + query_value "$metric" "$bucket" +} + +print_row() { + metric="$1" + label="$2" + last_7d="$(row_value "$metric" "last_7d")" + last_30d="$(row_value "$metric" "last_30d")" + all_time="$(row_value "$metric" "all_time")" + printf '%-20s | %-12s | %-12s | %-12s\n' "$label" "$last_7d" "$last_30d" "$all_time" +} + +printf 'Atlas KPI snapshot\n' +printf 'Date ranges: last-7d, last-30d, all-time\n\n' +print_header +print_row "A1" "A1 installs" +print_row "A3" "A3 weekly actives" +print_row "AC1" "AC1 reach_execute" +print_row "AC2" "AC2 ship_check_ok" +print_row "C1" "C1 conversion" diff --git a/skel/README.md b/skel/README.md new file mode 100644 index 0000000..7ca5b61 --- /dev/null +++ b/skel/README.md @@ -0,0 +1,17 @@ +# .atlas-ai/ — Your plugin workspace + +This directory is managed by @atlas-ai/prd-taskmaster. Files here layer on top of plugin defaults. + +## Structure +- `customizations/` — Your editable customization files. See customizations/README.md. +- `state/pipeline.json` — Pipeline phase state machine +- `references/` — Captured reference heatmap +- `debrief/` — Dogfood debriefs +- `config/atlas.json` — Your workflow preferences (from customise-workflow skill) +- `ship-check.py` — Deterministic completion check used by /atlas-ralph-loop:ralph-loop (Hayden's patched fork of /ralph-loop) + +## Reset +Delete this directory to start fresh. Backup first if you care about the state. + +TaskMaster project state lives in the normal project-level `.taskmaster/` +directory. diff --git a/skel/customizations/README.md b/skel/customizations/README.md new file mode 100644 index 0000000..ee6663b --- /dev/null +++ b/skel/customizations/README.md @@ -0,0 +1,12 @@ +# Customizations + +Layer-on-top user content. Plugin behavior is not overridden — these files are additive. + +| File | Purpose | +|---|---| +| system-prompt-template.md | Prepended to every subagent spawn | +| task-enrichment-rules.md | Pattern-matched onto each task before execute-task | +| verification-preferences.md | Your preferred evidence patterns | +| domain-vocabulary.md | Domain-specific term mappings | + +Edit freely. Changes take effect next plugin invocation. diff --git a/skel/customizations/domain-vocabulary.md b/skel/customizations/domain-vocabulary.md new file mode 100644 index 0000000..0721ca5 --- /dev/null +++ b/skel/customizations/domain-vocabulary.md @@ -0,0 +1,5 @@ +# domain-vocabulary.md + +Domain-specific term mappings. Helps the plugin translate user jargon into concrete actions — e.g., "when user says 'checkout', they mean the cart→payment flow." + +Leave empty for plugin defaults. diff --git a/skel/customizations/system-prompt-template.md b/skel/customizations/system-prompt-template.md new file mode 100644 index 0000000..4a84650 --- /dev/null +++ b/skel/customizations/system-prompt-template.md @@ -0,0 +1,5 @@ +# system-prompt-template.md + +Content here is prepended to every subagent spawn by the plugin. Use for persistent context the plugin should pass to every worker — codebase conventions, domain terminology, stylistic guardrails. + +Leave empty for plugin defaults. diff --git a/skel/customizations/task-enrichment-rules.md b/skel/customizations/task-enrichment-rules.md new file mode 100644 index 0000000..e8b2697 --- /dev/null +++ b/skel/customizations/task-enrichment-rules.md @@ -0,0 +1,5 @@ +# task-enrichment-rules.md + +Pattern-matched onto each task before execute-task dispatches the implementer. Use for domain-specific enrichment — e.g., "when task mentions 'auth', inject reminder about session token handling." + +Leave empty for plugin defaults. diff --git a/skel/customizations/verification-preferences.md b/skel/customizations/verification-preferences.md new file mode 100644 index 0000000..d44eec0 --- /dev/null +++ b/skel/customizations/verification-preferences.md @@ -0,0 +1,5 @@ +# verification-preferences.md + +Your preferred evidence patterns for the CDD GREEN/RED/BLUE verification pass. Use for team/project-specific evidence standards — e.g., "require screenshot for UI changes", "require coverage report for backend changes." + +Leave empty for plugin defaults. diff --git a/skel/ship-check.py b/skel/ship-check.py new file mode 100755 index 0000000..0062bdb --- /dev/null +++ b/skel/ship-check.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Deterministic ship-check for prd-taskmaster pipelines. + +Emits SHIP_CHECK_OK to stdout ONLY when all gates pass. Called by execute-task +at termination AND by Step 9 (--dry-run) as a per-task predicate. + +Gate logic (grounded against actual pipeline.json / tasks.json schemas +observed 2026-06-04 in ai-human-tasker): + + Gate 1 — pipeline.json: current_phase == "EXECUTE". + During execution, EXECUTE is current_phase but NOT yet in + phases_completed (the EXECUTE→completed transition happens AFTER + ship-check passes). Older skel checked state.state == "complete" — that + field does not exist in live data. + + Gate 2 — tasks.json: every master.tasks[].status == "done". + No evidence_files field exists in tasks.json — that was a phantom check + in the older skel. + + Gate 3 — CDD evidence: for each task ID, a CDD card exists at + .atlas-ai/cdd/task-<id>.json OR a combined card whose name contains the + id (e.g. task-10-11-12.json covers tasks 10, 11, 12). Cards are written + by execute-task Step 5. + + Gate 4 — Plan file exists at .taskmaster/docs/plan.md OR + docs/superpowers/plans/*.md (matches Phase 2 path fallback). The older + skel checked .atlas-ai/ralph-loop-prompt.md — wrong path and irrelevant + after /goal migration. + + Gate 5 (HARD) — No non-zero "Exit status N" line in any .atlas-ai/evidence/ + file. This is the convergent must-do from the 2026-06-04 forensic audit + (T12 marked DONE while pnpm test exited 1 with 11 failing tests). + Override only via SHIP_CHECK_OVERRIDE_ADMIN token; overrides are logged + to .atlas-ai/state/execute-log.jsonl as an audit record. + +Interface: + python3 .atlas-ai/ship-check.py # standard gate + python3 .atlas-ai/ship-check.py --dry-run # always exit 0; report on stderr + python3 .atlas-ai/ship-check.py --override SHIP_CHECK_OVERRIDE_ADMIN # bypass Gate 5 + python3 .atlas-ai/ship-check.py --cwd /path/to/project # explicit project root + +Exit codes: + 0 — SHIP_CHECK_OK (stdout: exactly "SHIP_CHECK_OK\n", with " [OVERRIDE]" suffix if applicable) + 1 — gate failures (stderr only; nothing on stdout — log watchers must not see partial matches) + 2 — script error (IO, JSON parse, bad token) +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Tuple + +OVERRIDE_TOKEN = "SHIP_CHECK_OVERRIDE_ADMIN" +EXIT_STATUS_RE = re.compile(r"\bExit status\s+(\d+)\b", re.IGNORECASE) + + +def gate_pipeline(atlas: Path) -> Tuple[bool, List[str]]: + pf = atlas / "state" / "pipeline.json" + if not pf.exists(): + return False, ["pipeline.json missing at .atlas-ai/state/pipeline.json"] + try: + state = json.loads(pf.read_text()) + except json.JSONDecodeError as exc: + return False, [f"pipeline.json invalid JSON: {exc}"] + if state.get("current_phase") != "EXECUTE": + return False, [f"pipeline.json current_phase != EXECUTE (got {state.get('current_phase')!r})"] + return True, [] + + +def gate_tasks(repo_root: Path) -> Tuple[bool, List[str], list]: + tf = repo_root / ".taskmaster" / "tasks" / "tasks.json" + if not tf.exists(): + return False, ["tasks.json missing at .taskmaster/tasks/tasks.json"], [] + try: + tdata = json.loads(tf.read_text()) + except json.JSONDecodeError as exc: + return False, [f"tasks.json invalid JSON: {exc}"], [] + # Accept both canonical formats: tagged {"master": {"tasks": [...]}} + # and flat {"tasks": [...]} (Manual Mechanics Mode / fleet samples). + tasks = tdata.get("master", {}).get("tasks", []) + if not tasks and isinstance(tdata.get("tasks"), list): + tasks = tdata["tasks"] + if not tasks: + for value in tdata.values(): + if isinstance(value, dict) and isinstance(value.get("tasks"), list) and value["tasks"]: + tasks = value["tasks"] + break + if not tasks: + return False, ["tasks.json has no tasks (checked master.tasks, flat tasks, and tagged fallback)"], [] + failures: List[str] = [] + for t in tasks: + if t.get("status") != "done": + failures.append(f"task {t.get('id')} not done (status={t.get('status')!r})") + return len(failures) == 0, failures, tasks + + +def _has_card_for(cdd_dir: Path, tid) -> bool: + """A CDD card exists for task <tid> if task-<tid>.json exists OR any + combined card filename's hyphen-separated id-list contains <tid>.""" + tid_str = str(tid) + direct = cdd_dir / f"task-{tid_str}.json" + if direct.exists(): + return True + for card in cdd_dir.glob("task-*.json"): + stem = card.stem + if not stem.startswith("task-"): + continue + ids = stem[len("task-"):].split("-") + if tid_str in ids: + return True + return False + + +def gate_cdd(atlas: Path, tasks: list) -> Tuple[bool, List[str]]: + cdd_dir = atlas / "cdd" + if not cdd_dir.exists(): + return False, [".atlas-ai/cdd/ directory missing"] + failures: List[str] = [] + for t in tasks: + tid = t.get("id") + if tid is None: + continue + if not _has_card_for(cdd_dir, tid): + failures.append(f"task {tid}: no CDD card at .atlas-ai/cdd/task-{tid}.json or any combined variant") + return len(failures) == 0, failures + + +def gate_plan(repo_root: Path) -> Tuple[bool, List[str]]: + if (repo_root / ".taskmaster" / "docs" / "plan.md").exists(): + return True, [] + superpowers = repo_root / "docs" / "superpowers" / "plans" + if superpowers.exists() and any(superpowers.glob("*.md")): + return True, [] + return False, ["no plan file at .taskmaster/docs/plan.md or docs/superpowers/plans/*.md"] + + +def gate_exit_codes(atlas: Path) -> Tuple[bool, List[str]]: + failures: List[str] = [] + evidence_dir = atlas / "evidence" + if not evidence_dir.exists(): + # Gate 3 (CDD) catches missing evidence; this gate is silent when no evidence exists + return True, [] + for f in evidence_dir.rglob("*"): + if not f.is_file(): + continue + try: + text = f.read_text(errors="ignore") + except OSError: + continue + for match in EXIT_STATUS_RE.finditer(text): + try: + code = int(match.group(1)) + except (ValueError, IndexError): + continue + if code != 0: + rel = f.relative_to(atlas.parent) if atlas.parent in f.parents else f + failures.append(f"non-zero exit in {rel}: Exit status {code}") + break # one report per file is enough + return len(failures) == 0, failures + + +def log_override(atlas: Path, message: str) -> None: + log = atlas / "state" / "execute-log.jsonl" + log.parent.mkdir(parents=True, exist_ok=True) + entry = { + "iteration": "OVERRIDE", + "timestamp": datetime.now(timezone.utc).isoformat(), + "task_id": "SHIP_CHECK", + "event": "override_invoked", + "message": message, + } + with log.open("a") as fp: + fp.write(json.dumps(entry) + "\n") + + +def run_all_gates(repo_root: Path, override_active: bool) -> Tuple[bool, List[str]]: + failures: List[str] = [] + atlas = repo_root / ".atlas-ai" + + _, f1 = gate_pipeline(atlas) + failures.extend(f1) + + _, f2, tasks = gate_tasks(repo_root) + failures.extend(f2) + + if tasks: + _, f3 = gate_cdd(atlas, tasks) + failures.extend(f3) + + _, f4 = gate_plan(repo_root) + failures.extend(f4) + + ok5, f5 = gate_exit_codes(atlas) + if not ok5: + if override_active: + log_override(atlas, f"Gate 5 bypassed: {'; '.join(f5[:3])}{' ...' if len(f5) > 3 else ''}") + # Override accepts the failures; no append to global failures list + else: + failures.extend(f5) + + return len(failures) == 0, failures + + +def main() -> int: + parser = argparse.ArgumentParser(description="Deterministic ship-check for prd-taskmaster pipelines.") + parser.add_argument("--dry-run", action="store_true", + help="Run all gates but always exit 0. Report goes to stderr. Used by execute-task Step 9 as a per-task predicate.") + parser.add_argument("--override", type=str, default=None, + help=f"Bypass Gate 5 (exit codes) if value equals {OVERRIDE_TOKEN}. Logged to execute-log.jsonl.") + parser.add_argument("--cwd", type=str, default=None, + help="Project root (defaults to current working directory).") + args = parser.parse_args() + + repo_root = Path(args.cwd).resolve() if args.cwd else Path.cwd() + override_active = args.override == OVERRIDE_TOKEN + if args.override is not None and not override_active: + print("FAIL: --override value does not match expected token", file=sys.stderr) + return 2 + + try: + ok, failures = run_all_gates(repo_root, override_active=override_active) + except Exception as exc: # noqa: BLE001 — top-level guard + print(f"FAIL: ship-check script error: {exc!r}", file=sys.stderr) + return 2 + + if args.dry_run: + if ok: + print("[DRY-RUN] all gates would pass", file=sys.stderr) + else: + print("[DRY-RUN] gate failures (would block):", file=sys.stderr) + for f in failures: + print(f" - {f}", file=sys.stderr) + return 0 + + if ok: + suffix = " [OVERRIDE]" if override_active else "" + print(f"SHIP_CHECK_OK{suffix}") + return 0 + + for f in failures: + print(f"FAIL: {f}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/customise-workflow/SKILL.md b/skills/customise-workflow/SKILL.md new file mode 100644 index 0000000..a862e6f --- /dev/null +++ b/skills/customise-workflow/SKILL.md @@ -0,0 +1,225 @@ +--- +name: customise-workflow +description: >- + Customise the prd-taskmaster plugin workflow via curated brainstorm + questions. The AI asks, the user answers in plain English, and the skill + writes their preferences to .atlas-ai/config/atlas.json. Future runs of + prd-taskmaster read that file and apply user preferences to phase gates, + validation strictness, default provider, preferred execution mode, and + template choice. For deeper tweaks beyond the curated questions, users can + hand-edit files in .atlas-ai/customizations/. Use when the user says + "customise workflow", "customize workflow", "adjust my PRD settings", + "tune the skill", or wants to change how prd-taskmaster behaves. +user-invocable: true +allowed-tools: + - Read + - Write + - Edit + - Bash + - AskUserQuestion +--- + +# customise-workflow + +AI-driven workflow customisation for the `prd-taskmaster` plugin. +Replaces manual JSON editing. Part of the plugin's companion-skills family. + +**Script**: `skills/customise-workflow/script.py` (all commands output JSON) +**Plugin config root**: `.atlas-ai/` (per-project, lives alongside TaskMaster's +`.taskmaster/`) + +## When to Use + +Activate when the user says: "customise workflow", "customize workflow", +"adjust PRD settings", "tune the skill", "change my defaults", or +"personalise prd-taskmaster". + +Skip: generating a new PRD (use `/prd-taskmaster:go`), executing tasks (use +HANDOFF modes), or running research expansion (use `/expand-tasks`). + +## The One Rule + +**The AI asks the questions and writes the config. The user never manually +edits JSON. The config file is the output, not the input.** If the user wants +tweaks beyond the curated questions, point them at `.atlas-ai/customizations/` +(see "Customizations directory" below) — do not hand them raw JSON. + +## Flow + +``` +LOAD → ASK → VALIDATE → WRITE → VERIFY +``` + +### Phase 1: LOAD current config + +Run the script to load existing preferences (or defaults if first run): + +```bash +python3 skills/customise-workflow/script.py load-config +``` + +Returns JSON with current preferences across 6 categories: provider, +validation, execution, template, autonomous, gates. Writes to +`.atlas-ai/config/atlas.json` if missing, seeding defaults. + +### Phase 2: ASK curated questions + +Read `questions/curated-questions.md` and ask each one via `AskUserQuestion`. +The questions are curated so plain-English answers map cleanly to config keys. +Example: + +``` +Q1: Which AI provider do you prefer for task generation? + Options: Gemini (free, token-efficient), Claude Code (free, Max only), + OpenAI GPT-4, Anthropic Direct API, OpenRouter, Ollama (local) + +Q2: How strict should PRD validation be? + Options: Strict (block on NEEDS_WORK), Normal (warn but allow GOOD+), + Lenient (accept ACCEPTABLE+) + +Q3: Which execution mode should prd-taskmaster default to? + Options: Plan & Drive, Auto-Execute, Verified Loop, Atlas Fleet, ... + +... +``` + +Do NOT ask all questions at once. Ask one curated question at a time and +adapt follow-ups based on answers. (Same pattern as +`superpowers:brainstorming`.) + +### Phase 3: VALIDATE answers + +Run the script with each user answer as it arrives. The script validates the +answer against allowed values and returns either `ok: true` or a hint about +what's wrong. + +```bash +python3 skills/customise-workflow/script.py validate-answer \ + --key provider_main --value gemini-cli +``` + +If validation fails, re-ask the question with the hint. Never write an invalid +value. + +### Phase 4: WRITE config + +After all curated questions are answered, commit the config: + +```bash +python3 skills/customise-workflow/script.py write-config --input /tmp/answers.json +``` + +This writes to `.atlas-ai/config/atlas.json` in the current project. +Idempotent — re-running customise-workflow reads and updates the existing +file. The script creates the `.atlas-ai/config/` directory if missing. + +### Phase 5: VERIFY + +Show the user their final config and confirm it matches their intent: + +```bash +python3 skills/customise-workflow/script.py show-config +``` + +If the user says "that's not what I meant" for any key, re-enter Phase 2 for +just that key, re-validate, and re-write. + +## Script Commands Reference + +| Command | Purpose | +|---|---| +| `load-config` | Load current `.atlas-ai/config/atlas.json` (or defaults) | +| `list-questions` | Return the curated question set as JSON | +| `validate-answer --key K --value V` | Validate a single answer | +| `write-config --input <file>` | Write validated answers to `.atlas-ai/config/atlas.json` | +| `show-config` | Display current config | +| `reset-config` | Delete `.atlas-ai/config/atlas.json` (back to defaults) | + +## Config Schema + +`.atlas-ai/config/atlas.json` has 6 top-level keys: + +```json +{ + "provider": { + "main": "gemini-cli|claude-code|anthropic|openai|openrouter|ollama|...", + "model_main": "gemini-3-pro-preview|sonnet|gpt-4o|...", + "research": "gemini-cli|perplexity|...", + "model_research": "sonar-pro|gemini-3-pro-preview|...", + "fallback": "gemini-cli|claude-code|...", + "model_fallback": "gemini-3-flash-preview|haiku|..." + }, + "validation": { + "strictness": "strict|normal|lenient", + "ai_review_default": true, + "min_passing_grade": "EXCELLENT|GOOD|ACCEPTABLE|NEEDS_WORK" + }, + "execution": { + "preferred_mode": "A|B|C|D|E|F|G|H|I|J", + "auto_handoff": true, + "external_tool": "cursor|codex-cli|gemini-cli|..." + }, + "template": { + "default": "comprehensive|minimal", + "custom_template_path": null + }, + "autonomous": { + "allow_self_brainstorm": true, + "ralph_loop_auto_approve": true + }, + "gates": { + "skip_phase_0_if_validated": false, + "skip_user_approval_in_discovery": false, + "require_research_expansion": true + } +} +``` + +Phase files (`skills/setup`, `skills/discover`, `skills/generate`, +`skills/handoff`, `skills/execute-task`) read this config at runtime and apply +user preferences before falling back to documented defaults. + +## Customizations directory + +For tweaks that go beyond the curated questions — custom template overrides, +provider-model mapping tables, gate hooks, per-phase overrides — users can +drop files into `.atlas-ai/customizations/`. This is the escape hatch for +power users. The curated questions cover the 80% case; the customization +directory covers everything else. + +Expected layout: + +``` +.atlas-ai/ + config/ + atlas.json # written by this skill + customizations/ # user-editable, never overwritten by this skill + templates/ # custom PRD templates + prompts/ # provider prompt overrides + gates/ # custom gate predicates + README.md # user-authored notes +``` + +Rules: + +1. This skill NEVER writes into `.atlas-ai/customizations/` — that's user + territory. +2. Phase skills read `.atlas-ai/customizations/` as a fallback *after* the + curated `atlas.json` but *before* documented defaults. +3. When a user asks for a setting not covered by curated questions, the AI + proposes a customization file shape, the user edits, and the AI verifies + the file parses. + +## Critical Rules + +1. Never ask the user to edit JSON directly — the skill asks curated questions + and writes the file. +2. Questions are curated and AI-adapted, not a fixed form — adapt follow-ups + to earlier answers. +3. Every answer is validated before being written (`validate-answer`). +4. Config is idempotent — re-running updates cleanly. +5. Config is per-project (lives in `.atlas-ai/config/`), not global. +6. Customization files live in `.atlas-ai/customizations/` and are + user-authored — this skill never overwrites them. +7. Phase skills must GRACEFULLY FALL BACK to documented defaults when config + keys are missing. diff --git a/skills/discover/SKILL.md b/skills/discover/SKILL.md new file mode 100644 index 0000000..3e4c166 --- /dev/null +++ b/skills/discover/SKILL.md @@ -0,0 +1,228 @@ +--- +name: discover +description: >- + Phase 1 of the prd-taskmaster pipeline: brainstorm-driven discovery. Delegates + to superpowers:brainstorming in Interactive Mode (one adaptive question at a + time), or self-brainstorms in Autonomous Mode when no user is present. + Intercepts before the brainstorming chain hands off to writing-plans — this + skill owns the exit. Extracts constraints, calibrates scale + (Solo / Team / Enterprise), and advances the pipeline to GENERATE. +user-invocable: false +allowed-tools: + - Read + - Skill + - AskUserQuestion + - Write +--- + +# Phase 1: Discover + +Declarative phase skill. Invoked by the prd-taskmaster orchestrator when +`current_phase` is `DISCOVER`. Never called directly by a user. + +The one rule: **invoke `superpowers:brainstorming` for discovery, intercept +before it chains to `writing-plans` — we control the exit, not the brainstorm +skill.** + +## Entry gate + +1. Call `mcp__plugin_prd-taskmaster_go__check_gate(phase="DISCOVER", evidence={})`. + If the call returns blocked evidence, do not print the raw JSON. Render + one plain-English line: + + ```text + ✗ Gate blocked: <first violation> + Fix: <one copy-pasteable action> + ``` + + A passed gate renders as `✓ Gate passed: <summary>`. The gate protects + against re-entering a completed phase or skipping ahead from SETUP. + + **Known issue (Mum dogfood feedback [4]):** check_gate is structurally + an EXIT gate. On first DISCOVER entry, evidence=`{}` will fail the + `user_approved=true OR auto_classification=CLEAR with assumptions_documented` + requirement (which the User Approval Gate / Self-Approval Gate below + produces). State machine LEGAL_TRANSITIONS already prevents illegal + entry — proceed past this gate on first entry. Semantic fix in flight + (see morning brief). +2. Detect execution context. If any of the following signals are present, + switch to Autonomous Mode: + - `.claude/ralph-loop.local.md` exists in the project root + - An `auto-enter` / `auto-approve` daemon is running against this session + - The skill was invoked with an explicit `--autonomous` flag + - Parent orchestrator is a cron, `/pentest-wtf`, or `/ralph-loop` + Otherwise proceed in Interactive Mode (default). + +## Discovery checklist + +Copy into your response before running the procedure: + +``` +┌─ atlas ── PHASE 2/4: DISCOVERY ────────────────────────────┐ +What happened: Discovery is gathering the goal, constraints, and scale. +Evidence: + ○ Mode detected: waiting + ○ Goal captured: waiting + ○ Questions answered: 0 of ~5 + ○ Constraints listed: waiting + ○ Scale classified: waiting + ○ Approval recorded: waiting +Next: answer the next discovery question, or say "simpler" for a shorter path. +``` + +During the interview, label each turn `Discovery <n> of ~<N>` and keep a +running `So far:` list. If the user says "simpler", "quick path", or similar, +stop asking adaptive questions, summarize the current assumptions, and ask for +approval to continue with the shorter spec. + +## Interactive Mode (default — user present) + +1. Take the user's goal / description from the skill invocation args. +2. Invoke `superpowers:brainstorming` with the goal as input. +3. Brainstorming runs its adaptive question flow — one domain-agnostic question + at a time. Let it drive the Q&A rhythm. +4. **INTERCEPT POINT**: when brainstorming signals readiness to chain to + `writing-plans`, STOP. Do NOT let it invoke `writing-plans`. Capture the + brainstorm output (design, requirements, decisions) into local state + instead. The prd-taskmaster orchestrator owns the handoff — not + `superpowers:brainstorming`. +5. Present the summary to the user for approval via `AskUserQuestion` (see + User Approval Gate below). + +## Autonomous Mode (no user present) + +**Do NOT invoke `superpowers:brainstorming`** — it blocks on user input and +will stall an unattended session. Instead, self-brainstorm using this +template: + +1. Read the goal statement from skill args or + `session-context/CLAUDE-soul-purpose.md`. +2. Read `session-context/CLAUDE-activeContext.md` for project context. +3. Write discovery notes directly to + `session-context/discovery-{timestamp}.md` answering every question the + interactive flow would ask: + - Who is this for? + - What problem does it solve? + - What are the success metrics? + - What are the constraints (tech stack, timeline, team, budget, + integrations, regulatory)? + - What's explicitly out of scope? + - What's the scale (Solo / Team / Enterprise)? +4. Self-approve: the skill acts as both interrogator and approver. Document + assumptions explicitly so the user can audit them on wake-up. +5. Commit the discovery file. The git history becomes the audit trail — if + the user later disagrees, they can reset to that commit and re-run. + +**Autonomous mode is first-class, not degraded.** A well-run autonomous +discovery produces a spec the user reads on wake-up and says "yes, that's +what I meant" without edits. If you find yourself needing to ask more than +two questions the user didn't anticipate, the discovery is under-specified — +stop and write a handoff note instead of proceeding. + +## User Approval Gate (Interactive Mode) + +After brainstorming completes, present via `AskUserQuestion`: + +``` +┌─ atlas ── PHASE 2/4: DISCOVERY ────────────────────────────┐ +What happened: Discovery captured the goal and constraints. +Evidence: + ✓ Goal: [one sentence] + ✓ Audience: [who it's for] + ✓ Approach: [proposed solution] + ✓ Key decisions: [list] + ✓ Constraints: [known limitations] + ✓ Scale: [Solo | Team | Enterprise] +Gate passed: discovery is clear enough to generate the spec. +Next: approve this summary or ask to refine one part. +``` + +- If user says "refine" → ask what to change, update the summary, re-present. +- If user approves → capture as the discovery output and proceed to exit gate. + +## Self-Approval Gate (Autonomous Mode) + +Write the discovery summary to `session-context/discovery-{timestamp}.md` and +commit it. No interactive approval is required, but assumptions MUST be +explicit in the written summary so the user can audit on wake-up. + +## Smart Defaults + +If brainstorming (or self-brainstorming) produces thin answers, fill gaps +with reasonable assumptions instead of forcing extra questions: + +- Target audience: small team (< 10 users) unless specified otherwise. +- Timeline: MVP in 4–6 weeks. +- Tech stack: inferred from requirements — do not pick arbitrarily. +- Scale: moderate (hundreds of users, not millions). + +Document every assumption in the discovery summary so GENERATE can surface +them in the spec. + +## Constraint Extraction (MANDATORY before advancing) + +Before moving to GENERATE, explicitly extract and list all constraints +mentioned during discovery. Emit this block: + +``` +CONSTRAINTS CAPTURED: +- Tech stack: [e.g., "must use Python", "React frontend", "no new dependencies"] +- Timeline: [e.g., "MVP in 2 weeks", "no deadline"] +- Team: [e.g., "solo developer", "3-person team"] +- Budget: [e.g., "free tier only", "$500/month max"] +- Integration: [e.g., "must work with existing Postgres DB", "connects to Stripe"] +- Regulatory: [e.g., "HIPAA compliant", "GDPR", "none specified"] +- Domain-specific: [e.g., "authorized pentest scope: 10.0.0.0/24 only", + "learning goal: intermediate level"] +``` + +Present this list alongside the discovery summary. These constraints MUST be +passed to GENERATE — they inform spec content, task decomposition depth, and +acceptance criteria. If a constraint is mentioned in discovery but missing +from the spec, that's a bug. + +## Scope Calibration + +Infer project scale from discovery answers and set decomposition guidance: + +| Scale | Signal | Task Cap | Subtask Depth | +|------------|-------------------------------------------------|-------------|------------------| +| Solo | "just me", "side project", "learning" | 8–12 tasks | 2–3 subtasks each | +| Team | "small team", "MVP", "product", "startup" | 12–20 tasks | 3–5 subtasks each | +| Enterprise | "compliance", "multiple teams", "platform" | 20–30 tasks | 5–8 subtasks each | + +Pass the scale classification to GENERATE so task count is calibrated, not +arbitrary. + +## Exit gate + +After approval (Interactive) or commit (Autonomous), constraints captured, +and scale classified: + +1. Call `mcp__plugin_prd-taskmaster_go__advance_phase(expected_current="DISCOVER", target="GENERATE", evidence={"user_approved": True, "constraints_captured": True, "scale": "<Solo|Team|Enterprise>", "assumptions_documented": True})`. + The call atomically transitions `pipeline.json` from DISCOVER to GENERATE. + The `expected_current` field is the compare-and-swap guard; + `evidence` is stored under `phase_evidence[GENERATE]` for audit. +2. Return control to the orchestrator (`prd-taskmaster` skill). Do NOT invoke + GENERATE directly — the orchestrator re-reads `current_phase` and routes. + +## Red flags (stop and report, do not paper over) + +- "Brainstorming wants to call writing-plans — I'll let it" → NO. Intercept. + The prd-taskmaster pipeline owns the exit, not `superpowers:brainstorming`. +- "User hasn't answered, I'll pick for them in Interactive Mode" → NO. If + stalled, ask one more targeted question or write a handoff note — don't + silently self-approve in Interactive Mode. +- "Autonomous mode — I'll invoke superpowers:brainstorming anyway" → NO. + It blocks on user input and will stall the session. Self-brainstorm with + the template above. +- "Constraints are obvious, I'll skip the CONSTRAINTS CAPTURED block" → NO. + GENERATE reads this block — missing constraints become missing spec + sections downstream. +- "I can call advance_phase without check_gate" → NO. Gate first, always. + +## Non-exits + +This skill does not use explicit process termination. A hard block reports +the reason and returns control to the orchestrator; the orchestrator decides +whether to surface to the user. diff --git a/skills/execute-fleet/SKILL.md b/skills/execute-fleet/SKILL.md new file mode 100644 index 0000000..b5082c0 --- /dev/null +++ b/skills/execute-fleet/SKILL.md @@ -0,0 +1,163 @@ +--- +name: execute-fleet +description: >- + Phase execution skill for licensed Atlas Fleet runs. Use when HANDOFF has + selected Atlas Fleet and the project should be executed across isolated + launcher worktrees with inbox-based result collection, verified evidence cards, + sequential integration merges, and one final PR. +user-invocable: false +allowed-tools: + - Read + - Bash + - Skill +--- + +# execute-fleet + +Atlas Fleet is the premium parallel sibling of `execute-task`. It keeps the +same proof discipline, but the orchestrator owns the scoreboard while workers +only build inside isolated worktrees. + +## Hard Gates + +Before the first wave, all gates must pass. If any gate fails, report the gap +and stop; do not fall back to solo execution from inside this skill. + +1. `mcp__plugin_prd-taskmaster_go__detect_capabilities()` reports + `tier: "premium"` and atlas-launcher MCP registration/aliveness. +2. `mcp__atlas-launcher__inbox_read` is callable for this session. +3. `.taskmaster/tasks/tasks.json` exists. +4. `.taskmaster/reports/task-complexity-report.json` exists. +5. `git status --short` is empty. Fleet starts only from a committed base. +6. The integration branch policy is clear: use `fleet-integration`; main is never auto-touched. + +**SOLE-WRITER RULE:** only this orchestrator writes +`.taskmaster/tasks/tasks.json` and `.atlas-ai/state/pipeline.json`. Workers +must never edit those files. The orchestrator may update task state only +through TaskMaster or the plugin pipeline MCP, and only after verification. + +## Wave Loop + +Repeat until no runnable tasks remain: + +1. Call `mcp__plugin_prd-taskmaster_go__compute_fleet_waves(concurrency=<N>, tag=<tag>)`. + Use the returned frontier as the only dispatch source. If it reports a + deadlock, render status, mark the blocked set, and stop dispatching those + tasks. +2. For each chunk in the current wave, spawn exactly one worker: + `mcp__atlas-launcher__session_spawn(isolation="worktree", report_to=<this session>, prompt=<worker prompt>)`. + The prompt must include the full task JSON inline; never tell workers to + read shared `tasks.json`. +3. Inspect the spawn result. If `prompt_injected` is false, re-kick once with + `mcp__atlas-launcher__session_send` using the same worker prompt, then + confirm injection/readiness. If it is still false, treat that worker as + failed and apply the retry policy below. +4. Render the fleet status view after the wave starts and after each wave + transition. +5. Poll `mcp__atlas-launcher__inbox_read` for terminal worker messages. The + only accepted terminal status vocabulary is `DONE`, `DONE_WITH_CONCERNS`, + `NEEDS_CONTEXT`, or `BLOCKED`; any other terminal word is a protocol + failure and counts as a worker failure. +6. On a completion message, verify the branch, never the narration: + - The worker branch contains `.atlas-ai/cdd/task-<id>.json`. + - No `.atlas-ai/evidence/` file in that branch contains a non-zero + `Exit status N` line. + - The branch changed only its own worktree scope and did not edit + `.taskmaster/tasks/tasks.json` or `.atlas-ai/state/pipeline.json`. +7. Never mark a task done without the evidence card. Missing card means the worker + did not satisfy the contract, regardless of any `DONE` message. +8. Merge verified worker branches into `fleet-integration` sequentially, one + at a time. After each merge, run the checker/build gate expected for the + project before merging the next branch. +9. Mark the task done only after the merge gate passes: + `task-master set-status --id <id> --status done`. +10. Recompute waves after every accepted merge. Do not keep dispatching from a + stale frontier. + +## Worker Prompt Template + +Embed this template verbatim for each worker, replacing placeholders before +dispatch: + +```text +WORKER_CONTRACT_ORCHESTRATOR_REPORT_TO +You are an Atlas Fleet worker. Your orchestrator is <ORCHESTRATOR_ID>. Report every question, blocker, and terminal result to <REPORT_TO_SESSION>. + +WORKER_CONTRACT_FULL_TASK_JSON_INLINE +Your assigned task JSON is inline below. Treat this as the source of truth. Do not read shared .taskmaster/tasks/tasks.json. +<FULL_TASK_JSON> + +WORKER_CONTRACT_WORKTREE_BRANCH +Work only in this isolated worktree and branch: +worktree: <WORKTREE_PATH> +branch: <WORKER_BRANCH> + +WORKER_CONTRACT_CDD_CARD +Before reporting any terminal status, write this evidence card in your worktree: .atlas-ai/cdd/task-<id>.json. The card must list the checks you ran and the evidence paths that prove them. Evidence files under .atlas-ai/evidence/ must contain the FINAL verification run ONLY (one green run, one exit-status line) — intermediate TDD red runs go to .atlas-ai/logs/, never evidence/ (ship-check Gate 5 reads every Exit status line in evidence/ as final-state proof). + +WORKER_CONTRACT_TERMINAL_STATUS +End with exactly one terminal status: DONE | DONE_WITH_CONCERNS | NEEDS_CONTEXT | BLOCKED. +Report it via mcp__atlas-launcher__inbox_send(target_session=<REPORT_TO_SESSION>, message_type="task_handoff", payload=<JSON string with at least {"task_id": <id>, "status": "<terminal status>", "branch": "<worktree branch>", "evidence_card": ".atlas-ai/cdd/task-<id>.json"}>, sender_session=<your session name>). The launcher message_type allowlist is task_handoff | notification | data | request | heartbeat — terminal reports use task_handoff; the status lives INSIDE the payload JSON. + +WORKER_CONTRACT_HARD_RULES +Hard rules: never edit .taskmaster/tasks/tasks.json or .atlas-ai/state/pipeline.json; never git push; commit only in your own worktree branch. + +WORKER_CONTRACT_QUESTIONS_INBOX +Ask questions before building if context is missing: use mcp__atlas-launcher__inbox_send(target_session=<REPORT_TO_SESSION>, message_type="request", payload=<your question as a string>, sender_session=<your session name>). ("question"/"completion"/"blocker" are template intents, not runtime message types — see docs/INTEGRATION-prd-taskmaster.md in the atlas-launcher repo, contract v1.) +``` + +## Failure Paths + +- Silent/dead worker: if there is no inbox message and the session is gone, + re-queue the task ONCE with a fresh worker prompt. On the second failure, + mark the task `BLOCKED` in the orchestrator scoreboard and continue with + remaining tasks. +- `NEEDS_CONTEXT`: answer through `mcp__atlas-launcher__inbox_send`, then let + the same worker continue. If it cannot continue, count it under the same + retry cap. +- Worker `BLOCKED`: record the blocker, mark the task `BLOCKED`, and continue + with independent tasks. +- Merge conflict: do not force. Do not resolve by guessing. Create a fix task + that captures the conflict and continue with remaining non-conflicting work. +- Evidence failure: do not merge, do not mark done, and do not rewrite the + worker's evidence card on their behalf. + +## Status Rendering + +After every wave transition, render the terminal status view with the UX-SPEC +grammar. Use model plus index names such as `claude-1`, `codex-1`, and +`claude-2`. Keep the gate line in plain English every time. + +```text +┌─ atlas fleet ── wave 2 of 3 ──────────────── ▶ running 12m ┐ +│ wave 1 ✓ merged 3 tasks · 18m · integration green │ +│ wave 2 ▶ running │ +│ claude-1 task 6 API endpoints ▰▰▰▱ 3/4 │ +│ codex-1 task 7 UI components ▰▰▱▱ 2/4 │ +│ claude-2 task 9 DB migrations ✓ done — waiting │ +│ wave 3 ○ queued 4 tasks · starts when wave 2 merges │ +│ │ +│ Gate: a wave merges only after the checker approves it │ +│ and the integration branch builds green. │ +│ │ +│ watch: atlas fleet status logs: .atlas-ai/fleet/ │ +└────────────────────────────────────────────────────────────┘ +``` + +## Termination + +When all waves are done, or all remaining tasks are `BLOCKED`, switch to +`fleet-integration` and run: + +```bash +python3 skel/ship-check.py +``` + +If it exits non-zero, report the failing gate and stop. If it exits 0, emit +`SHIP_CHECK_OK` exactly once, then open one final PR from `fleet-integration`. +Do not print the token anywhere else. Do not merge the PR yourself. + +## Non-Exits + +This skill never kills the shell and never pushes. Halt conditions are reported +to the caller and, when relevant, to the launcher inbox. diff --git a/skills/execute-task/SKILL.md b/skills/execute-task/SKILL.md new file mode 100644 index 0000000..b463dc5 --- /dev/null +++ b/skills/execute-task/SKILL.md @@ -0,0 +1,323 @@ +--- +name: execute-task +description: >- + Execute the next TaskMaster task using the implementation plan with + verified execution + verification. Picks the next ready task, matches it to the plan step, + implements via a dispatched subagent, verifies subtasks with evidence, + marks the task done, and loops until every task is complete. + + Wraps the TaskMaster next -> in-progress -> done lifecycle with + GREEN / RED / BLUE verification and the plugin's triple-verification + rule. Autonomous by design — no user prompts inside the loop. +user-invocable: true +allowed-tools: + - Read + - Write + - Edit + - Bash + - Skill + - Agent +--- + +# execute-task + +The execution loop. Three sources converge: + +- **Plan** (HOW) — `docs/superpowers/plans/*.md` produced by GENERATE +- **TaskMaster** (WHAT) — `.taskmaster/tasks/tasks.json` with + dependencies and complexity scores +- **Evidence card** (PROOF) — acceptance cards per task, evidence-gated + +execute-task is the single skill that runs the full build from "tasks are +ready" to SHIP_CHECK_OK. It is autonomous — no AskUserQuestion inside the +loop. Any gap that would require user input is surfaced through the recon +escalation ladder (step 11) or the inbox (steps 4 and 8), never via a modal +prompt. + +## Entry + +This skill is invoked either: + +1. Directly by the user once HANDOFF has completed and a task-execution + mode (A/B/C) has been dispatched, **or** +2. By the `prd-taskmaster` orchestrator when `current_phase` is `EXECUTE`. + +On entry, confirm that: + +- `.atlas-ai/state/pipeline.json` exists and records `phase: EXECUTE` +- `.taskmaster/tasks/tasks.json` exists with at least one ready task +- `.atlas-ai/customizations/system-prompt-template.md` is present (may be + empty — absence is a setup bug, empty is fine) + +If any of the above are missing, report the gap and halt. Do NOT attempt to +bootstrap the missing artifact from inside this loop — that is the +orchestrator's job. + +## Cycle (per iteration) + +Each pass through this cycle moves exactly one TaskMaster task from `pending` +to `done`. Do the 13 steps in order. Do not skip. + +1. **Heartbeat check**: verify the execute-task heartbeat timer is running. + If missing, register one via `CronCreate("execute-task-heartbeat", "* * * * *", "echo heartbeat")`. + Abort the iteration if the timer cannot be created — a missing heartbeat + means a missing stuck-session detector, and that is load-bearing. + +2. **Inbox reconciliation**: read `.atlas-ai/state/pipeline.json`, + `.taskmaster/tasks/tasks.json`, and the current TodoWrite list. + Diff them. If the three are stale by more than 5 tasks (i.e. TodoWrite + says 10 done but tasks.json says 3 done), report the diff and halt — do + not paper over bookkeeping drift by silently reconciling. + +3. **Pick next task**: run the TaskMaster next command with the plugin's + project-root pointer. Use exactly this invocation: + + ```bash + task-master next --format json + ``` + + Parse the JSON result. + - If no ready tasks and all tasks are `done`, run `.atlas-ai/ship-check.py`, + emit SHIP_CHECK_OK on success, exit the loop. + - If no ready tasks but pending tasks exist, the dependency graph is + deadlocked — report and halt. + +4. **Load plan step**: search for the matching task ID in this priority + order, halting only after all three fail: + + 1. `docs/superpowers/plans/*.md` (the superpowers GENERATE default output) + 2. `.taskmaster/docs/plan.md` (the prd-taskmaster HANDOFF default output, + whose path is also recorded in + `pipeline.json:phase_evidence.HANDOFF.plan_file_path`) + 3. Any custom path declared in + `pipeline.json:phase_evidence.HANDOFF.plan_file_path` (in case + a future handoff variant writes elsewhere) + + If none of the three contains the matching task ID, the task was + invented downstream of the plan — mark the task `blocked`, inbox the + parent orchestrator with `message_type="blocker"`, and continue to the + next iteration. + + (Codified 2026-06-04 — yesterday's ai-human-tasker run had its plan at + `.taskmaster/docs/plan.md` only, while this step previously read + `docs/superpowers/plans/*.md` exclusively. The controller silently + improvised; a cold-start successor would have hit the `blocked` path on + every task.) + +5. **Generate evidence card**: convert the task's `subtasks` field into a + `testing_plan`. Each subtask becomes a verifiable check with a concrete + evidence path (file, command output, or test name). Write the card to + `.atlas-ai/cdd/task-<id>.json`. A task without subtasks is treated as a + single RED card. + +6. **Set in-progress**: run `task-master set-status --id <N> --status in-progress` + from the current project root. This flip is + observable by watchers and anchors the iteration in TaskMaster itself. + +7. **Dispatch implementer subagent** — NEVER in-session. The controller + must: + + - Provide the FULL task text to the subagent. Never tell the subagent to + "read tasks.json" — per spec §12, the controller serialises the task + into the dispatch prompt. + - Inject the plugin customisation block at `.atlas-ai/customizations/system-prompt-template.md` + into the subagent's system prompt. If the file is empty, inject nothing + and continue. + - Tier the model by TaskMaster complexity score: + - `1-4 fast` — use the fast tier (Haiku-class) + - `5-7 standard` — use the standard tier (Sonnet-class) + - `8-10 capable` — use the capable tier (Opus-class) + - Wait for the subagent to return a terminal status: `DONE`, + `DONE_WITH_CONCERNS`, `NEEDS_CONTEXT`, or `BLOCKED`. + + Rationale: complexity-tiered dispatch keeps the dollars-per-task curve + sensible. A complexity-2 boilerplate task does not need Opus; a + complexity-9 architectural task should not be given to Haiku. + +8. **Route by status**: the subagent's return status drives the next move. + + - **DONE** — proceed to the spec gate, then the quality gate. If both + pass, advance to step 9. + - **DONE_WITH_CONCERNS** — the subagent completed but flagged concerns. + Address each concern before advancing; re-dispatch if needed. + - **NEEDS_CONTEXT** — the subagent requested more context. Provide the + requested context and re-dispatch. Retry cap at 2 — if the subagent + still returns NEEDS_CONTEXT after two re-dispatches, escalate via the + recon ladder (step 11). + - **BLOCKED** — the subagent cannot proceed. Try one model-tier upgrade + first (e.g. standard -> capable). If still blocked, break the task + into smaller subtasks via `task-master expand --id <N>`. If still + blocked, set status=blocked, inbox parent, halt this iteration. + + Do NOT invent new status values. The four above are the only terminal + returns. Any other string from the subagent is a protocol violation and + should be logged + treated as BLOCKED. + +9. **Triple verification** — the plugin's core quality gate, per spec §11.4. + Three independent checks must agree. + + **Hard exit-code gate (MANDATORY — bypasses agreement count).** Before + invoking the three checkers, run `.atlas-ai/ship-check.py --dry-run`. If + it reports any non-zero `Exit status N` in evidence files, the task + FAILS regardless of how the agent narratives read. SHIP_CHECK_FAIL is + NOT a warning. Narrative claiming the exit code is "expected" or + "infrastructure noise" does NOT override this gate — write a separate + `task-fix-N` to address the underlying failure instead. Override only + via `--override SHIP_CHECK_OVERRIDE_ADMIN`, which is logged to + `execute-log.jsonl` as an audit event. (Codified 2026-06-04 after T12 + in ai-human-tasker was marked DONE while `pnpm test` exited 1 with 11 + failing tests.) + + The three checks (run only if the hard gate passes): + + - Plugin-native check: evidence file count vs declared subtask count + (from the evidence card in step 5). Missing evidence = fail. + - `/doubt` skill — adversarial doubt sweep on the claimed completion. + - `/validate` skill — deterministic validation pass (lint / tests / exit + codes). + - External `Opus subagent` sanity pass — asks a fresh subagent "would + you merge this?" with the task spec + diff + evidence. + + 3+ agree pass -> task passes. Disagreement -> halt this iteration, + surface to inbox. + +10. **Mark done + propagate state**: + a. Run `task-master set-status --id <N> --status done` for the parent + task. + b. **Subtask writeback**: for each subtask `S` in `task.subtasks` whose + evidence file (per the evidence card from step 5) exists, run + `task-master set-status --id <N>.<S> --status done`. Subtasks left + `pending` while the parent is `done` are a data-integrity violation + that breaks any tool computing progress from subtask state. + (Codified 2026-06-04 — yesterday's run left all 39 subtasks + `pending` despite 13/13 parent tasks `done`.) + c. Update `.atlas-ai/state/pipeline.json` per-task: call + `mcp__plugin_prd-taskmaster_go__update_pipeline_task_status(task_id=<N>, + status="done")` if the MCP tool is available. If not, fall back to + atomic read-modify-write using the pattern in + `mcp-server/pipeline.py:locked_update()` — read, append `<N>` to + `phase_evidence.EXECUTE.tasks_completed`, write to temp, rename. + Never leave pipeline.json and tasks.json mutually inconsistent. + (Codified 2026-06-04 — yesterday's run promised this write in + SKILL.md but never executed it. pipeline.json froze at HANDOFF + transition through all 85 minutes of execution.) + +11. **Check stepback triggers**: if 15 minutes have passed with no task + moving to done, OR 5 consecutive iterations have failed on the same + task class, the recon escalation ladder is MANDATORY. Climb the ladder + in this exact order, not out of order: + + `/stepback` -> `/research-before-coding` -> `/question` -> `pivot` + + - `/stepback` — reassess the architectural assumption. Was the plan + wrong? + - `/research-before-coding` — feed the blocker into the Perplexity + + Context7 + GitHub pipeline for fresh external context. + - `/question` — batch-research the unresolved unknowns in parallel. + - `pivot` — the plan step itself is unsound; kick the task back to the + plan author (inbox parent with `message_type="plan_pivot_requested"`). + + The ladder is append-only — if `/stepback` surfaces a fix, apply it and + return to step 3. Only climb if the prior rung did not yield progress. + +12. **Render gamify score** — emit the atlas-gamify one-line score for this + iteration (tasks done / tasks total, complexity-weighted). This is the + human-visible progress signal and also feeds the dogfood debrief. + +13. **Loop**: back to step 1 until SHIP_CHECK_OK or a halt condition fires. + +## Termination + +The termination sequence is strict — three steps, in order, no shortcuts: + +1. Run `.atlas-ai/ship-check.py`. If it does NOT exit 0, halt. Do NOT + emit any completion signal. Investigate the gate failure, fix, retry. +2. **MANDATORY**: invoke `Skill(skill: "sync")` to refresh the memory + bank (session-context/CLAUDE-*.md, MEMORY.md, capability inventory). + This MUST happen BEFORE the SHIP_CHECK_OK token is printed. + Orchestrators tail-watch the token; if the memory bank is stale when + they react, successor sessions inherit a wrong picture of the world. + (Codified 2026-06-04 — yesterday's ai-human-tasker run shipped 15.6k + LOC while `session-context/CLAUDE-activeContext.md` still said + "Scaffold complete. No application code yet".) +3. Print `SHIP_CHECK_OK` to stdout. This is the ONLY place in your + output where the token may appear — emit it nowhere else, to avoid + false-positive matches by log-watchers. + +The ship-check script is deterministic. Its gates are documented at the +top of `.atlas-ai/ship-check.py` after setup scaffolds it from the installed +`skel/ship-check.py`: + +- Gate 1: `pipeline.json current_phase == "EXECUTE"` +- Gate 2: every `master.tasks[].status == "done"` +- Gate 3: every task has an evidence card (`task-<id>.json` or combined variant) +- Gate 4: plan file exists at `.taskmaster/docs/plan.md` OR `docs/superpowers/plans/*.md` +- Gate 5 (HARD): no non-zero `Exit status N` line in any evidence file + +Gate 5 is the convergent must-do from the 2026-06-04 audit — a "PASS" +label on a non-zero-exit test is structurally impossible after this +script runs (modulo the explicit `--override SHIP_CHECK_OVERRIDE_ADMIN` +audit-logged bypass). + +Do not emit SHIP_CHECK_OK on a mere "DONE" keyword in a subagent reply. +Do not emit on "all tasks marked done" without the explicit ship-check. +Do not emit before `/sync` has been called. + +## Red flags + +These are the most common pressure points where the loop silently degrades +from "verified" to "performative". If you catch yourself thinking any of +them, stop and repair the gap. + +- "Close enough, mark it done" -> NO. Evidence OR nothing. +- "Let me skip the doubt step this time" -> NO. Triple verification is non-negotiable. +- "I'll retry with same model+prompt" (BLOCKED) -> NO. Escalate. +- "The task says done, don't check evidence files" -> NO. Task status must reflect evidence. + +## Observability + +Every iteration appends a structured row to +`.atlas-ai/state/execute-log.jsonl`. Field types are strict — text +narrative in a typed field is a logging bug, not compliance. The schema: + +- `iteration` (integer, or `"FINAL"` / `"OVERRIDE"` for terminal markers) +- `timestamp` (ISO 8601 string) +- `task_id` (string) +- `complexity` (integer or human label) +- `tier` (string: `"fast"` | `"standard"` | `"capable"`) +- `subagent_status` (string: `"DONE"` | `"DONE_WITH_CONCERNS"` | `"NEEDS_CONTEXT"` | `"BLOCKED"`) +- `retry_count` (integer) +- `triple_verify` (string: `"PASS"` / `"FAIL"` plus free-text rationale) +- `stepback_triggered` (boolean, REQUIRED — true iff `/stepback` was + invoked this iteration). Putting narrative-text in this field is a + violation; use `stepback_narrative` instead. +- `stepback_narrative` (string, nullable — explanation when + `stepback_triggered: true`; `null` otherwise) +- `ladder_rung` (string, nullable — which rung was reached if escalated) +- `gamify` (string — atlas-gamify one-line score) + +The stepback fields were split (2026-06-04) after a FINAL iteration entry +wrote a paragraph of narrative into the boolean `stepback` field and was +treated as compliance with the `stepback_mandatory` rule. Boolean trigger ++ nullable narrative is the correct schema. + +This log is the dogfood artifact — debrief tools consume it, the +orchestrator greps it, and future runs read it for retrospective analysis. + +## Composition + +- **Orchestrator handoff**: this skill is invoked post-HANDOFF. It does + not call `/handoff` — that direction is one-way. +- **Plan editing**: if the plan is unsound, the ladder escalates to + `pivot`, which inboxes the plan author. This skill does not mutate the + plan in place. +- **Ship-check**: `.atlas-ai/ship-check.py` is the terminal gate. This + skill calls it; it does not reimplement the checks. + +## Non-exits + +This skill uses no explicit process termination. A halt condition reports +the reason in the structured log and returns control to the caller (the +user or the orchestrator). Never kill the shell — the caller owns the +session lifecycle. diff --git a/skills/expand-tasks/SKILL.md b/skills/expand-tasks/SKILL.md new file mode 100644 index 0000000..431d7a9 --- /dev/null +++ b/skills/expand-tasks/SKILL.md @@ -0,0 +1,198 @@ +--- +name: expand-tasks +description: >- + Expand all TaskMaster tasks with deep research before coding begins. + Reads tasks.json, launches parallel research agents per task in waves using + the research-expander agent. Writes findings back to tasks.json. Part of the + prd-taskmaster toolkit. Use after PRD is parsed and before implementation. + Invoke with /expand-tasks. +user-invocable: true +allowed-tools: + - Read + - Write + - Edit + - Bash + - Task + - Skill + - Glob + - Grep + - AskUserQuestion +--- + +# Expand Tasks with Research v1.0 + +Expands TaskMaster tasks with research before coding begins. +Deterministic operations handled by `script.py`; AI handles judgment. + +**Script location**: `skills/expand-tasks/script.py` (relative to plugin root) +**Part of**: `prd-taskmaster` plugin +**Depends on**: `research-expander` agent (parallel research worker), any research +provider configured via `task-master models --set-research` or registered as an +MCP research tool. + +## When to Use + +Activate when user says: expand tasks, research tasks, research before coding for all, expand subtasks. +Do NOT activate for: single task research (use /research-before-coding), PRD generation (use /prd-taskmaster:go). + +## Prerequisites + +- TaskMaster `tasks.json` must exist (run `/prd-taskmaster:go` first) +- A research provider is configured — either (a) `task-master models --set-research <model> --<provider>` for any task-master provider family, or (b) an MCP research tool registered in `~/.claude.json` that Claude Code can call directly (for example `mcp__plugin_prd-taskmaster_go__*` tools or an external search/reason MCP) +- At least 1 task in `tasks.json` + +--- + +## Workflow (5 Steps) + +### Step 1: Preflight + +```bash +python3 skills/expand-tasks/script.py read-tasks +``` + +Returns JSON: `total`, `expanded`, `pending_expansion`, `tasks[]`. + +**If `pending_expansion` is 0**: Report all tasks already expanded. Exit skill. + +**If research provider is not configured**: Check via `task-master models` and verify a research role is set. If none, tell the user to configure one (`task-master models --set-research <model> --<provider>`) and exit. The skill does not assume any specific research backend — it uses whatever is configured. + +--- + +### Step 2: Choose Scope + +Use AskUserQuestion: +- **All tasks** (default): Expand every task that hasn't been researched yet +- **Specific tasks**: User provides task IDs (comma-separated) +- **By dependency level**: Expand tasks with no dependencies first, then next wave + +**AI judgment**: Recommend "All tasks" for initial expansion, "By dependency level" for incremental work. + +--- + +### Step 3: Generate Research Prompts + +For each task to expand: + +```bash +python3 skills/expand-tasks/script.py gen-prompt --task-id <ID> +``` + +Returns JSON with `prompt` field containing the full research agent prompt. + +**AI judgment**: Review the auto-generated prompt. Customize research questions if the task needs domain-specific queries. Add project context from the PRD or session-context files if relevant. + +--- + +### Step 4: Launch Parallel Research Agents + +Launch research agents in parallel waves. Each wave = up to 5 concurrent agents. + +**For each task**, spawn a Task agent using the dedicated `research-expander` +subagent type (defined in `agents/research-expander.md`): + +``` +Task( + subagent_type: "research-expander", + description: "Research Task <ID>: <title>", + run_in_background: true, + prompt: <prompt from Step 3> +) +``` + +**Wave strategy**: +- Wave 1: Tasks with no dependencies (they inform downstream tasks) — run in parallel +- Wave 2: Tasks depending on Wave 1 — run in parallel +- Wave 3+: Continue until all tasks covered — run in parallel per wave +- Max 5 agents per wave to avoid overwhelming the configured research backend + +**Wait for each wave to complete before launching the next.** Parallel dispatch +only happens WITHIN a wave; waves themselves are serial. + +--- + +### Step 5: Collect and Write Results + +As each `research-expander` agent completes, save its research output: + +1. Write agent output to a temp file: + ```bash + cat > /tmp/research-task-<ID>.md <<'EOF' + <agent output> + EOF + ``` + +2. Write research back to `tasks.json`: + ```bash + python3 skills/expand-tasks/script.py write-research --task-id <ID> --research /tmp/research-task-<ID>.md + ``` + +3. After all tasks are written, verify: + ```bash + python3 skills/expand-tasks/script.py status + ``` + +**AI judgment**: Review each research result for quality. If a result is too thin (< 5 lines of useful content) or clearly failed, re-run that specific task's research through a fresh `research-expander` invocation. + +--- + +## Research Agent Prompt Pattern + +The `gen-prompt` command generates prompts that follow the +research-before-coding pattern: + +1. Agent receives task context (title, description, dependencies, subtasks) +2. Agent runs 3-5 targeted queries against the user's configured research provider. The `research-expander` agent is tool-agnostic: it picks up whichever research tools are available in the current Claude Code session. This may be `task-master research`, an MCP search/reason tool from `~/.claude.json` (including any `mcp__plugin_prd-taskmaster_go__*` tools registered by this plugin), WebSearch as a last resort, or whatever the user has bound. The skill does not hard-code any specific research MCP. +3. Agent distills results into structured summary +4. Summary returns to main context (~25-40 lines per task) + +**Critical**: prefer structured research tools (`task-master research`, MCP +search/reason tools) over raw WebSearch/WebFetch when available — they produce +cleaner outputs with citations. + +--- + +## Error Handling + +| Error | Action | +|-------|--------| +| Research provider unreachable or rate-limited | Exit skill, tell user to verify `task-master models` research role is set and reachable | +| `research-expander` agent returns empty/failed | Re-run that specific task with different queries | +| `tasks.json` not found | Exit skill, tell user to run `/prd-taskmaster:go` first | +| Task already expanded | Skip silently unless user forces re-expansion | +| Agent timeout | Mark task as failed, continue with others | + +--- + +## Output + +After all tasks are expanded, the skill reports: +- Total tasks expanded +- Any failures that need retry +- Next recommended action (usually: begin implementation) + +--- + +## Integration with prd-taskmaster + +This skill fits between Step 8 (Parse & Expand Tasks) and Step 11 (Choose Next +Action) of the prd-taskmaster workflow. After PRD is parsed into tasks but +before execution begins. + +``` +/prd-taskmaster:go → generates PRD → parses into tasks + ↓ +/expand-tasks → research-expander agents run in Parallel waves → writes findings back to tasks.json + ↓ +Implementation begins (with research context in each task) +``` + +--- + +## Tips + +- Run after PRD generation but before any implementation +- Research results are stored in `research_notes` field of each task in `tasks.json` +- Re-running on already-expanded tasks is safe (will skip unless forced) +- For very large task lists (20+), consider expanding in dependency order to save context +- Each `research-expander` agent typically completes in ~30s depending on research backend and query depth; 15 tasks ≈ 3 waves ≈ 2-3 minutes total diff --git a/skills/generate/SKILL.md b/skills/generate/SKILL.md new file mode 100644 index 0000000..4cbefa2 --- /dev/null +++ b/skills/generate/SKILL.md @@ -0,0 +1,406 @@ +--- +name: generate +description: >- + Phase 2 of the prd-taskmaster pipeline: spec generation and task parsing. + Loads a template (comprehensive|minimal), fills it with DISCOVER-phase + constraints and answers, validates the spec (placeholders_found, grade + thresholds), parses the PRD into tasks via task-master, runs TaskMaster's + native complexity analysis, and expands every task into verifiable subtasks. + Autonomous-safe. Declares GENERATE complete so HANDOFF can follow. +user-invocable: false +allowed-tools: + - Read + - Write + - Edit + - Bash + - Skill +--- + +# Phase 2: Generate + +Declarative phase skill. Invoked by the prd-taskmaster orchestrator when +`current_phase` is `GENERATE`. Never called directly by a user. + +The one rule: **generate the spec, validate it catches placeholders, parse it +into tasks, expand every task into subtasks. Quality over speed.** + +## Entry gate + +1. Call `mcp__plugin_prd-taskmaster_go__check_gate(phase="GENERATE", evidence={})`. + If the call returns blocked evidence, do not print the raw JSON. Render + one plain-English line: + + ```text + ✗ Gate blocked: <first violation> + Fix: <one copy-pasteable action> + ``` + + A passed gate renders as `✓ Gate passed: <summary>`. The gate protects + against re-entering a completed phase or skipping ahead from DISCOVER. + + **Known issue (Mum dogfood feedback [10] — WORST):** check_gate(GENERATE) + currently checks `task_count > 0`, `subtask_coverage >= 1.0`, and + `validation_grade in (EXCELLENT, GOOD)` — all of which are GENERATE's + OWN OUTPUTS, not entry preconditions. First-time entry deadlocks. State + machine LEGAL_TRANSITIONS already prevents illegal entry — proceed past + this gate on first entry. Semantic fix in flight (see morning brief). +2. Read the DISCOVER output (discovery summary + `CONSTRAINTS CAPTURED` block + + scale classification). If any of these are missing, report and stop — the + gate should have caught this, but belt-and-braces. + +## Generate checklist + +Copy into your response before running the procedure: + +``` +GENERATE CHECKLIST: +- [ ] Template loaded (comprehensive|minimal) +- [ ] Spec written with discovery answers (no bare placeholders remaining) +- [ ] CONSTRAINT CHECK: every DISCOVER constraint appears in the spec +- [ ] SCOPE CHECK: task count matches scale (Solo 8-12, Team 12-20, Enterprise 20-30) +- [ ] Validation score: ___ / ___ (grade: ___) +- [ ] placeholders_found: ___ (bare placeholders = 0 required) +- [ ] Warnings addressed or acknowledged +- [ ] Tasks parsed: ___ tasks created +- [ ] Complexity analyzed via TaskMaster: Y/N +- [ ] All tasks expanded into subtasks: Y/N +``` + +## Step 1: Choose and load template + +Decide based on discovery depth: + +- **Comprehensive**: 4+ detailed answers, complex project, Team / Enterprise scale +- **Minimal**: thin answers, user wants speed, Solo scale + +**MCP (preferred)**: `mcp__plugin_prd-taskmaster_go__load_template(type="comprehensive")` + +**CLI fallback**: `python3 script.py load-template --type comprehensive` + +The template is the canonical shape — do not invent your own. If the template +load fails, report and stop. Do not paper over with a home-rolled skeleton. + +## Step 2: Generate spec at `.taskmaster/docs/prd.md` + +Fill the template with discovery answers. AI judgment required: + +- Replace ALL placeholders with actual content pulled from DISCOVER. +- Expand with project-specific details — do not leave template prose verbatim. +- Add technical depth proportional to what the user provided in discovery. +- Generate domain-appropriate sections (pentest = threat model, app = user + stories, business = success metrics, learning = assessment criteria). +- Document assumptions where discovery was thin — explicitly, not silently. + +### CONSTRAINT CHECK (MANDATORY) + +Verify EVERY constraint from the DISCOVER phase `CONSTRAINTS CAPTURED` block +appears in the spec. If "must use Python" was a constraint, the spec MUST +reference Python. Missing constraints = spec bug. + +Emit the check explicitly: + +``` +CONSTRAINT CHECK: +- Tech stack (Python): FOUND in spec section "Technical Stack" +- Timeline (MVP in 2 weeks): FOUND in spec section "Milestones" +- ... +``` + +Every constraint must be marked FOUND. If any are MISSING, loop back and +fix the spec before proceeding. + +### SCOPE CHECK (MANDATORY) + +Use the scale classification from DISCOVER to set task count range: + +| Scale | Task Count | Subtask Depth | +|------------|-----------|------------------| +| Solo | 8–12 | 2–3 subtasks each | +| Team | 12–20 | 3–5 subtasks each | +| Enterprise | 20–30 | 5–8 subtasks each | + +If DISCOVER classified the project as Team but the spec implies 30 tasks, +that's a scope bug — narrow the spec or re-classify explicitly. + +### Domain-neutral vocabulary + +When the domain is unclear, default to neutral terms: + +| Software term | Neutral equivalent | When to use neutral | +|---------------|-------------------|---------------------| +| tests | verification criteria | pentest, business, learning | +| code | deliverable | business, learning | +| deploy | execute / deliver | business, learning | +| repo | workspace | non-software | +| PR | output / submission | non-software | + +If the domain IS software, use software terms. Neutral terms are for +non-software goals. + +### Deferred decisions — the `reason:` convention + +Every `[placeholder]`, `{{variable}}`, `[TBD]`, `[TODO]` must be either: + +(a) Replaced with real content, +(b) Removed entirely, or +(c) **Paired with a `reason:` explanation** on the same line or the next line + documenting why the decision is deferred. + +Per the v4 spec: placeholders with `reason:` attribution are allowed and +surfaced in the validation output as `deferred_decisions`. A bare placeholder +is a validation failure; an attributed one is a known deferred decision with +accountability. + +Examples: + +``` +# BAD — bare placeholder, fails validation: +Target latency: {{TBD}} + +# GOOD — attributed, appears in deferred_decisions: +Target latency: {{TBD}} reason: awaiting load-test results scheduled 2026-04-20 +``` + +Write the final spec to `.taskmaster/docs/prd.md`. This is the canonical +path — downstream tools read from here. + +## Step 3: Validate spec quality + +**MCP (preferred)**: `mcp__plugin_prd-taskmaster_go__validate_prd(input_path=".taskmaster/docs/prd.md")` + +**CLI fallback**: `python3 script.py validate-prd --input .taskmaster/docs/prd.md` + +Returns: `score`, `grade`, `checks`, `warnings`, `placeholders_found`. + +Render the validation result as the moment-3 scorecard: + +```text +┌─ atlas ── PHASE 3/4: GENERATE ─────────────────────────────┐ +What happened: The PRD was scored and parsed into tasks. +Evidence: + ✓ Grade: <GOOD|EXCELLENT> ▰▰▰▰▰▰▰▰▱▱ <score>/<max> (<percent>%) + ⚠ Warnings: "<warning text>" at <section or line>; quote every warning + ✓ Placeholder scan: 0 bare placeholders named by scan + ✓ Tasks: <count> parsed; subtasks expanded for every task +Gate passed: GOOD or better required — passed. +Next: choose the execution mode in handoff. +``` + +If the grade is below GOOD, render the same block with `✗ Grade:` and: + +```text +Gate blocked: GOOD or better is required before task parsing. +Fix: repair the named warnings and rerun validate-prd. +``` + +**Optional AI-augmented review** (opt-in): pass `--ai` (CLI) or `ai=True` (MCP) +to additionally invoke TaskMaster's configured main model for a holistic +quality review. The deterministic regex checks always run first — AI review +is additive, never a replacement. + +**Grading thresholds:** + +- EXCELLENT: 91%+ +- GOOD: 83–90% +- ACCEPTABLE: 75–82% +- NEEDS_WORK: <75% + +**Decision rules:** + +- If `placeholders_found > 0` (bare placeholders, not `reason:`-attributed): + print the exact placeholder names and locations, then fix before proceeding. + No exceptions. +- If grade is NEEDS_WORK: offer auto-fix or proceed-with-risk — do not silently + advance. Surface the decision. +- If grade is ACCEPTABLE or better AND placeholders_found == 0: proceed to + Step 4. + +## Step 4: Parse tasks via TaskMaster + +Calculate task count first: + +**MCP**: `mcp__plugin_prd-taskmaster_go__calc_tasks(requirements_count=<count>)` + +**CLI**: `python3 script.py calc-tasks --requirements <count>` + +Then parse (detect method from preflight): + +**MCP (preferred)**: `mcp__task-master-ai__parse_prd(input=".taskmaster/docs/prd.md", numTasks=<recommended>)` + +**MCP fallback**: `mcp__plugin_prd-taskmaster_go__tm_parse_prd(input_path=".taskmaster/docs/prd.md", num_tasks=<recommended>)` + +**CLI**: `task-master parse-prd --input .taskmaster/docs/prd.md --num-tasks <recommended>` + +The parse writes to `.taskmaster/tasks/tasks.json`. Verify the file exists +and contains the expected number of tasks before continuing. + +## Step 5: Analyze complexity via TaskMaster + +Use TaskMaster's native complexity analysis instead of home-rolled +classification: + +**MCP (preferred)**: `mcp__task-master-ai__analyze_complexity` (analyzes all tasks) + +**MCP fallback**: `mcp__plugin_prd-taskmaster_go__tm_analyze_complexity` (wraps the CLI) + +**CLI**: `task-master analyze-complexity` + +**Important — output location**: `analyze-complexity` does NOT emit JSON to +stdout. It writes structured analysis to +`.taskmaster/reports/task-complexity-report.json` and prints a human-readable +table to stdout. To read the structured result, read the report file: + +```bash +cat .taskmaster/reports/task-complexity-report.json | jq . +``` + +Do not try to parse the stdout table — it's colour-coded ASCII and will break +consumers. TaskMaster's built-in analysis is more accurate than anything +hand-rolled because it has full context of the task graph and dependencies. + +## Step 6: Expand tasks into subtasks (MANDATORY) + +Every task MUST be expanded into subtasks before HANDOFF. Subtasks are +verifiable checkpoints — without them, tasks are black boxes that either +pass or fail with no intermediate proof. + +### Use `task-master expand --all`, NOT per-id parallel calls + +Per-id parallel calls (e.g. `task-master expand --id=1 & task-master expand +--id=2 &`) hit a non-atomic read-modify-write race on +`.taskmaster/tasks/tasks.json`: every parallel writer reads the same starting +snapshot, adds its own subtasks, and writes the whole file back. The last +writer wins and earlier writes are silently lost — the AI call reports +success, the subtasks were generated, but they never landed on disk. +Detected in the v4 Shade dogfood 2026-04-13. + +`task-master expand --all` is the correct path. It is internally serial +(one task at a time, one file write at a time) and therefore atomic across +the whole batch. + +**Invocation (CLI required path):** + +```bash +# Preferred: research-enriched expansion (when a research provider is configured) +task-master expand --all --research + +# Fallback: structural-only (still valuable; always available) +task-master expand --all +``` + +**MCP note on `expand_task`**: task-master's MCP currently exposes +`expand_task(id=...)` per-task only. Do NOT call `expand_task` in parallel +across IDs — same race, same data loss. If you must use the MCP, call +`expand_task` serially for every task, or shell out to +`task-master expand --all` via Bash. + +### Patience under slow providers + +Under `claude-code` (Claude Max rate-limited) or local `ollama`, `--all` +can run for 5–15 minutes on a 12-task project. Do NOT time out aggressively. +Use `.taskmaster/tasks/tasks.json` mtime as the liveness signal: + +- **mtime updated within last 60s** → work is landing, keep waiting +- **mtime stale for 120s+** → investigate (rate limit, provider crash, network) +- **Never conclude STUCK from a single capture** — always compare two + snapshots 30–60s apart + +### Verify coverage (read tasks.json DIRECTLY, not `task-master list`) + +`task-master list --format json` has been observed to return a different +top-level schema from `tasks.json`, causing consumers to report 0/N +coverage even when all tasks have subtasks on disk (v4 dogfood LEARNING +#15). Always read the canonical file directly: + +```bash +python3 -c " +import json +d = json.load(open('.taskmaster/tasks/tasks.json')) +# tasks.json is tag-grouped (master, defaults, feature branches) — walk all tags +all_tasks = [] +if 'master' in d and isinstance(d['master'], dict): + all_tasks = d['master'].get('tasks', []) +elif 'tasks' in d: + all_tasks = d['tasks'] +else: + for v in d.values(): + if isinstance(v, dict) and 'tasks' in v: + all_tasks.extend(v['tasks']) + +counts = [len(t.get('subtasks', [])) for t in all_tasks] +covered = sum(1 for c in counts if c > 0) +total = len(all_tasks) +no_subs = [t['id'] for t in all_tasks if not t.get('subtasks')] + +if no_subs: + print(f'WARNING: {covered}/{total} tasks expanded. Missing: {no_subs}. Re-run task-master expand --all.') +else: + print(f'OK: All {total} tasks expanded ({sum(counts)} subtasks total).') +" +``` + +### Idempotent recovery + +If any task still shows 0 subtasks after `--all` completes (rate-limit hiccup, +provider timeout, partial run), re-run the same command: + +```bash +task-master expand --all --research +``` + +`--all` only re-expands tasks that are still in `pending` state with 0 +subtasks, so a second invocation is safe and recovers gracefully. Do NOT +work around it with parallel per-id calls — that is the exact pattern +that causes silent data loss. + +## Evidence gate + +**Gate: spec validation grade is ACCEPTABLE+ AND placeholders_found == 0 AND +tasks parsed AND complexity analyzed AND all tasks have subtasks in +`.taskmaster/tasks/tasks.json`.** + +Emit a compact one-block status: + +``` +Generate: + spec: .taskmaster/docs/prd.md (grade: <grade>, score: <n>/<total>) + placeholders_found: <n> (bare), <m> deferred_decisions + tasks parsed: <n> + complexity report: .taskmaster/reports/task-complexity-report.json + subtask coverage: <n>/<n> tasks expanded (<total> subtasks) +``` + +## Exit gate + +After the evidence gate passes: + +1. Call `mcp__plugin_prd-taskmaster_go__advance_phase(expected_current="GENERATE", target="HANDOFF", evidence={"validation_grade": "<EXCELLENT|GOOD|ACCEPTABLE>", "task_count": <int>, "subtask_coverage": <float 0-1>, "placeholders_found": <int>})`. + The call atomically transitions `pipeline.json` from GENERATE to HANDOFF. + The `expected_current` field is the compare-and-swap guard; + `evidence` is stored under `phase_evidence[HANDOFF]` for audit. +2. Return control to the orchestrator (`prd-taskmaster` skill). Do NOT invoke + HANDOFF directly — the orchestrator re-reads `current_phase` and routes. + +## Red flags (stop and report, do not paper over) + +- "The validation says placeholders_found=3 but the content reads fine — + I'll advance anyway" → NO. Bare placeholders are a hard fail. Fix or + attribute with `reason:`. +- "`task-master expand --all` is slow, let me run `expand_task` in parallel + across IDs to speed it up" → NO. That is the exact race that silently + drops subtasks. Serial `--all` or serial per-id only. +- "A constraint from DISCOVER isn't in the spec — I'll add it to the + handoff note instead" → NO. Constraints live in the spec. Fix the spec. +- "Complexity analyze output looked odd, I'll skip reading the JSON report" + → NO. Read `.taskmaster/reports/task-complexity-report.json` directly; + the stdout table is decoration. +- "I can call advance_phase without check_gate" → NO. Gate first, always. +- "The template prose is close enough, I'll ship it verbatim" → NO. The + template is a shape, not content. Fill every section with project-specific + material. + +## Non-exits + +This skill does not use explicit process termination. A hard block reports +the reason and returns control to the orchestrator; the orchestrator decides +whether to surface to the user. diff --git a/skills/go/SKILL.md b/skills/go/SKILL.md new file mode 100644 index 0000000..70dca5a --- /dev/null +++ b/skills/go/SKILL.md @@ -0,0 +1,42 @@ +--- +name: go +description: >- + Zero-config goal-to-tasks engine. Takes any goal (software, pentest, business, learning), + runs adaptive discovery, generates a validated spec, parses into TaskMaster tasks, creates an + implementation plan, and executes with built-in verified execution. Use when user says "PRD", + "product requirements", "I want to build", or any goal-driven phrase. +user-invocable: true +allowed-tools: + - Read + - Skill + - Bash + - AskUserQuestion +--- + +# go (orchestrator) + +Pure routing. Reads pipeline state, dispatches to the correct phase skill. + +## Flow + +1. Call `mcp__plugin_prd-taskmaster_go__preflight()` — get environment state +2. Call `mcp__plugin_prd-taskmaster_go__current_phase()` — get pipeline state +3. Route via Skill tool: + - current_phase is null or SETUP → invoke `/prd-taskmaster:setup` + - current_phase is DISCOVER → invoke `/prd-taskmaster:discover` + - current_phase is GENERATE → invoke `/prd-taskmaster:generate` + - current_phase is HANDOFF → invoke `/prd-taskmaster:handoff` + - current_phase is EXECUTE → invoke `/prd-taskmaster:execute-task` + +4. After phase skill returns, re-check current_phase. If it advanced, route to the next phase. If not, report the blocker. + +## Stateless routing + +This skill does NOT hold procedure. Each phase skill owns its own logic. The orchestrator survives context loss because every phase skill reads `current_phase()` on entry. + +## Red flags + +These thoughts mean STOP, you're rationalising: +- "I know which phase we're in, skip preflight" → NO. Preflight is cheap. +- "The phase skill already ran, I don't need to re-check" → NO. Context might have died. +- "I can just do the work myself" → NO. Dispatch to the phase skill. diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md new file mode 100644 index 0000000..bc59e81 --- /dev/null +++ b/skills/handoff/SKILL.md @@ -0,0 +1,458 @@ +--- +name: handoff +description: >- + Phase 3 of the prd-taskmaster pipeline: smart mode selection and user + handoff. Detects installed capabilities (superpowers, loop runner, + task-master-ai, playwright, research providers), recommends ONE execution + mode (A/B/C) with reasoned justification, appends the task-execution + workflow to CLAUDE.md, surfaces a structured AskUserQuestion multi-option + picker for user agency, and dispatches the chosen mode. Atlas Fleet + is selectable only when detect_capabilities returns tier=premium (licensed + atlas-launcher detected); otherwise it is a locked Atlas Pro ($29/mo) teaser. + Plan Mode is NOT used (spec section 13.5): AskUserQuestion is the sole + user-agency mechanism. Declares HANDOFF complete so EXECUTE can follow. +user-invocable: false +allowed-tools: + - Read + - Skill + - AskUserQuestion +--- + +# Phase 3: Handoff + +Declarative phase skill. Invoked by the prd-taskmaster orchestrator when +`current_phase` is `HANDOFF`. Never called directly by a user. + +The one rule: **detect what the user has, recommend ONE free mode by default, +give the user a structured choice, dispatch the chosen mode. Atlas Fleet +executes only on tier=premium; otherwise it is a locked teaser.** + +## Entry gate + +1. Call `mcp__plugin_prd-taskmaster_go__check_gate(phase="HANDOFF", evidence={})`. + If the call returns blocked evidence, do not print the raw JSON. Render + one plain-English line: + + ```text + ✗ Gate blocked: <first violation> + Fix: <one copy-pasteable action> + ``` + + A passed gate renders as `✓ Gate passed: <summary>`. The gate protects + against re-entering a completed phase or skipping ahead from GENERATE. + + **Known issue (Mum dogfood feedback [4]/[10]):** check_gate(HANDOFF) + requires `user_mode_choice` and `plan_file_exists` — both produced by + HANDOFF itself. On first entry, evidence=`{}` will fail. State machine + LEGAL_TRANSITIONS already prevents illegal entry — proceed past this + gate on first entry. Semantic fix in flight (see morning brief). +2. Read the GENERATE outputs — `.taskmaster/docs/prd.md`, `.taskmaster/tasks/tasks.json`, + `.taskmaster/reports/task-complexity-report.json`. If any are missing, + report and stop. The gate should have caught this, but belt-and-braces. + +## Handoff checklist + +Copy into your response before running the procedure: + +``` +HANDOFF CHECKLIST: +- [ ] Capabilities detected (tier + per-capability flags) +- [ ] Recommended mode: ___ (reason: ___) +- [ ] Summary displayed (spec location, task count, capabilities) +- [ ] CLAUDE.md task workflow appended (idempotent) +- [ ] AskUserQuestion mode picker surfaced (or prose fallback if hook-blocked) +- [ ] User choice dispatched (Plan & Drive / Auto-Execute / Verified Loop, or Atlas Fleet when tier=premium) +- [ ] Debrief scaffold emitted (optional, silently tolerated) +- [ ] Handoff complete +``` + +## Step 1: Detect capabilities + +**MCP (preferred)**: `mcp__plugin_prd-taskmaster_go__detect_capabilities()` + +**CLI fallback**: `python3 script.py detect-capabilities` + +Returns a `tier` field (`"free"` or `"premium"`) plus per-capability flags. +Key signals: + +| Capability | What It Enables | +|------------|----------------| +| superpowers plugin | Modes A, C (brainstorm, plans, subagents) | +| task-master-ai (CLI or MCP) | Mode B (native auto-execute loop) | +| loop runner (`ralph-loop` capability key) | Verified Loop, the evidence-gated single-session loop | +| atlas-launcher MCP (licensed) | Atlas Fleet, selectable only on tier=premium | +| atlas-loop / atlas-cdd skills | legacy internal seeds — superseded by atlas-launcher detection | +| Research model (task-master or MCP) | Deep research per task | +| Playwright MCP | Tier S browser verification | + +**Atlas Fleet unlocks on `tier: "premium"` only** — i.e. a licensed +`atlas-launcher` MCP registration detected by `detect_atlas_launcher()`. Local +`atlas-loop`/`atlas-cdd` skills do NOT unlock it. See Step 2 and the Atlas Fleet +section below. + +## Step 2: Recommend ONE mode + +Decision logic (first match wins): + +- `superpowers` + loop runner present → **Verified Loop** (recommended free) +- `superpowers` only → **Plan & Drive** (free, manual drive) +- `task-master-ai` only → **Auto-Execute** (free, native TaskMaster loop) +- Fallback → **Plan & Drive** (free) + +External-tool modes (E–J: Cursor, RooCode, Codex, Gemini, CodeRabbit, Aider) +are offered as alternatives via the `alternative_modes` field, not primary +recommendations. **Atlas Fleet is recommended iff `tier == "premium"` AND the task +graph parallelizes (>= 2 independent dependency chains — check `fleet-waves` +output: any wave with >= 2 chunks). Premium + serial graph: recommend the best +free mode and say why ("your tasks form a single dependency chain — Verified +Loop is the right tool here"); Fleet stays selectable but not default. Free +tier: Atlas Fleet is a locked Atlas Pro ($29/mo) teaser, never selectable, regardless of +which local plugins are installed.** + +### Plan & Drive (internal Mode A) + +``` +Recommended: Plan & Drive + superpowers:writing-plans creates your implementation plan + Plan references TaskMaster task IDs from tasks.json + You drive execution manually +``` + +### Auto-Execute (internal Mode B) + +``` +Recommended: TaskMaster Auto-Execute + MCP: mcp__task-master-ai__next_task -> implement -> set_task_status(id, "done") + CLI: task-master next -> implement -> task-master set-status --id N --status done + Native TaskMaster execution loop (no external orchestrator required) +``` + +### Verified Loop (internal Mode C, recommended free) + +``` +Recommended: Verified Loop + superpowers:writing-plans → implementation plan referencing tasks.json IDs + loop runner wraps each task: + next_task → set_task_status("in-progress") → research if <80% confident + → subagent-driven-development → execution gate (Tier A+ evidence) + → post-doubt check → log to .claude/verification-log.md + → set_task_status("done") → TodoWrite → repeat + Completion: doubt agent reviews verification log before promise satisfied. +``` + +### Atlas Fleet (internal D; selectable on tier=premium; 🔒 locked teaser on free) + +Use `detect_capabilities.license_status` with `tier` when rendering this mode. +`tier=premium` means atlas-launcher is registered and the saved license is +`active` or `grace`. Launcher present with no license, an invalid license, or +an expired license stays `tier=free` and renders the locked Atlas Pro teaser. + +``` +🔒 Atlas Fleet Atlas Pro · $29/mo + Parallel multi-session execution across Claude, Codex, and Gemini: + your task graph split into dependency waves of isolated git worktrees + checker-gated merges into one integration branch, one final PR + durable inbox result collection (verified, not narrated) + evidence card per task; one SHIP_CHECK_OK at the end + Walk away, come back to proof. + + Unlock: https://atlas-ai.au/pro (the free modes above stay free forever) +``` + +**When `tier == "premium"` and `license_status.status == "active"`**: Atlas Fleet is +a real, selectable mode — dispatching it invokes +`/prd-taskmaster:execute-fleet` (the wave orchestrator skill). Show the +unlocked card: + +``` +▸ Atlas Fleet ★ Pro · license active + <N> waves · estimate from your dependency graph (actual time varies) · walk-away +``` + +**When `tier == "premium"` and `license_status.status == "grace"`**: Atlas Fleet is +selectable, but surface the countdown from `license_status.days_remaining`: + +``` +▸ Atlas Fleet ★ Pro · license grace + <N> waves · estimate from your dependency graph (actual time varies) · <days> days remaining · renew at https://atlas-ai.au/pro +``` + +**When `tier == "free"`**: Atlas Fleet is a locked teaser — not selectable, never +executed. If atlas-launcher is present with no license, an expired license, or +an invalid license, keep the price and unlock URL inline. If the user selects it +while locked, respond with: + +> "Atlas Fleet is part of Atlas Pro ($29/mo). On this project it would split +> your tasks into parallel waves across isolated worktrees with checker-gated +> merges and one final PR (estimate from your dependency graph — actual time +> varies). Unlock at https://atlas-ai.au/pro — your spec and +> tasks are saved. Meanwhile, everything else is free forever: please pick one +> of the free modes below." + +Then **re-invoke the mode picker (AskUserQuestion) with Atlas Fleet removed from +the options.** + +### Alternative modes E–J (external AI tools) + +`detect_capabilities` returns `alternative_modes` when these tools are +installed. Users can pick any of them instead of Modes A–D. All are +tool-agnostic wrappers around the same `.taskmaster/tasks/tasks.json`. + +| Mode | Tool | Invocation | +|---|---|---| +| **E** | Cursor Composer | `cursor --open .taskmaster/tasks/tasks.json`, @-ref in Composer | +| **F** | RooCode | VS Code command palette → `RooCode: Run tasks.json` | +| **G** | Codex CLI | `task-master next --format json \| codex implement` (free via ChatGPT) | +| **H** | Gemini CLI | `gemini --file .taskmaster/tasks/tasks.json implement next` (free via Google) | +| **I** | CodeRabbit | Implement via A–H, open PR, CodeRabbit reviews per task. Combines with other modes. | +| **J** | Aider | `aider --read .taskmaster/tasks/tasks.json` — pair-programming style | + +## Step 3: Append task workflow to CLAUDE.md + +Use the deterministic subcommand — do **not** do raw Read+Edit. This path is +idempotent, takes a timestamped backup when modifying an existing file, and +uses HTML-comment sentinels so re-runs are no-ops. + +1. The workflow content is the same every run — write it to a tempfile: + + ```markdown + ## Task Execution Workflow (prd-taskmaster) + + When implementing tasks, prefer task-master-ai MCP tools over the CLI: + 1. `mcp__task-master-ai__next_task()` or `task-master next` — get next ready task + 2. `set_task_status(id, "in-progress")` — note hyphen; underscore is rejected + 3. Implement the task (follow the plan step linked to this task) + 4. `set_task_status(id, "done")` — mark complete + 5. Update TodoWrite with progress + 6. Repeat from step 1 + + Valid statuses: `pending`, `in-progress`, `done`, `review`, `blocked`, `deferred`, `cancelled`. + + ### Progress Tracking + - Update TodoWrite BEFORE and AFTER each task + - Cannot proceed to next task without updating TodoWrite + - TodoWrite = user visibility. TaskMaster = source of truth. + ``` + +2. Run the append command. No Plan Mode preview — the subcommand itself is + idempotent (HTML-comment sentinels gate the write), so the information is + surfaced to the user via the Step-4 summary and the AskUserQuestion + options (Step 5) *before* dispatch, not via a plan dialog: + + ```bash + python3 $SKILL_DIR/script.py append-workflow \ + --target ./CLAUDE.md \ + --content-file /tmp/pdtm-workflow-section.md + ``` + + The JSON response reports one of: + - `action: "created"` — no prior CLAUDE.md, fresh file with markers + - `action: "skipped"` (reason: `markers_present`) — already wired, no-op + - `action: "appended"` — existing CLAUDE.md untouched except for the + appended marker block; `backup_path` points at + `CLAUDE.md.prd-taskmaster-backup-<ts>` + + If the user wants a preview before the write, surface the planned content + inside the AskUserQuestion options or as an informational paragraph in the + Step 4 summary — describe what would be written without invoking any + plan-dialog tool. + +## Step 4: Display summary + +Emit a compact block before the mode picker so the user has full context: + +``` +Spec Generated: .taskmaster/docs/prd.md +Validation: <GRADE> (<score>/<max>) +Tasks: <count> tasks parsed with dependencies (see .taskmaster/tasks/tasks.json) +Complexity: analyzed via TaskMaster (.taskmaster/reports/task-complexity-report.json) +Research: <expanded|skipped> + +Capabilities: + [check] TaskMaster (MCP|CLI) + [check|circle] Playwright (browser verification) + [check|circle] Research provider + [check|circle] Loop runner + [check|circle] Atlas Fleet (premium: selectable · free/no license/expired license: locked at $29/mo) +``` + +## Step 5: Mandatory AskUserQuestion for mode selection + +HANDOFF is the moment of user agency. Prose recommendations are skippable; +tool calls are not. You **MUST** invoke `AskUserQuestion` in this step. This +is hard-enforced — prose-only fallback is a bug, not a shortcut. + +AskUserQuestion gives the user an explicit, structured, machine-readable +choice. That's the durable handoff record. The user's selection is logged +programmatically and downstream steps dispatch on it directly — no parsing +natural-language affirmatives, no ambiguity. + +### Sequence + +1. **Emit a handoff summary** covering: + - PRD path + validation grade + - Task count + complexity breakdown + - Recommended free mode + one-line reason + - Alternative modes available (E–J when detected, collapsed under "Use + another tool…") + - Locked Atlas Fleet teaser with real `license_status`, the Atlas Pro + $29/mo price, and atlas-ai.au/pro URL when locked + - A "next step" description scoped to the recommended mode (e.g. for Mode + B: "run `task-master next`" with the first ready task ID) + +2. **Call `AskUserQuestion`** with a multi-option question listing each + available execution mode. Use the user-facing names (internal IDs in + parentheses are for this skill only — never shown to the user): + - **Plan & Drive** (internal Mode A) — get the plan, implement it yourself + - **Auto-Execute** (internal Mode B) — TaskMaster's native loop, lighter verification + - **Verified Loop** (internal Mode C) — evidence-gated single-session loop + (recommended when superpowers + a loop runner are present) + - **🔒 Atlas Fleet** (internal D) — Atlas Pro $29/mo, parallel multi-session + - "Use another tool…" — expands the applicable alternatives from E–J + - "Show me more detail before I decide" — loops back to Step 4 summary + + Mark the recommended free mode as the default. Atlas Fleet is never default + while locked. Atlas Fleet may be the default only when tier=premium AND the + graph parallelizes. Selecting Atlas Fleet while locked (free tier) returns the upgrade response (see the Atlas Fleet block + in Step 2) and re-prompts with only the free modes (plus any applicable + alternatives). + + **Locked default rule:** Fleet is never default while locked. The default + must be one of the free modes: Plan & Drive, Auto-Execute, or Verified Loop. + +3. **Dispatch the chosen mode:** + - **Plan & Drive handoff**: invoke `superpowers:writing-plans` with spec path + `.taskmaster/docs/prd.md` + - **Auto-Execute handoff**: show the `task-master next` command + the first + ready task ID surfaced from `.taskmaster/tasks/tasks.json` + - **Verified Loop handoff**: write `.claude/atlas-loop-prompt.md` describing the + task-execution contract, then invoke `/goal` with the condition: + + `"SHIP_CHECK_OK has been emitted by .atlas-ai/ship-check.py AND all tasks in .taskmaster/tasks/tasks.json show status=done AND /sync has been invoked this session"` + + The `/goal` session continues until the Haiku evaluator verifies the + condition. Each iteration runs the execute-task 13-step cycle and + checks the condition after step 13. `/sync` MUST be the last action + before SHIP_CHECK_OK is emitted (per execute-task Termination). + (Migrated from the legacy loop-runner invocation 2026-06-04 — Claude Code's + built-in `/goal` evaluator structurally solves the controller-wears- + different-hats triple-verify rot caught in the 2026-06-03 audit.) + - **Atlas Fleet handoff (tier=premium)**: invoke `/prd-taskmaster:execute-fleet` — it owns the wave loop, worker dispatch, verification, merges, and SHIP_CHECK_OK termination. (free tier: upgrade response only, re-prompt) + +### Hook-blocked fallback (graceful degradation) + +If `PreToolUse:AskUserQuestion` is hook-blocked (automated / orchestrator / +fleet session), fall back to a prose option table preserving the same +semantics — labels, descriptions, locked Atlas Pro teaser, recommended +mode marked. **Surface the hook block as an `[AI]` insight block** so the +parent orchestrator can detect the fallback: + +> `[AI] Hook blocked AskUserQuestion — a PreToolUse hook disables interactive +> questions for this session (automated mode). Surfacing the mode picker in +> prose instead. A parent orchestrator should either lift the hook for skills +> with requires_user_agency:true or supply the mode selection as part of the +> spawn directive.` + +The prose fallback MUST NOT invoke any plan-mode dialog. AskUserQuestion is +the sole user-agency mechanism in this skill — when it is unavailable, the +prose table is the documented fallback. + +### Hard-coded programmatic path (for tests and fleet orchestrators) + +The skill's deterministic layer exposes +`python3 script.py handoff-gate --recommended <A|B|C>` (when implemented). +This emits the full mode option set as structured JSON on stdout, enabling +tests and external orchestrators to drive the handoff without the LLM layer. +Use this when you need deterministic, LLM-skippable handoff enforcement. + +### Anti-pattern: prose-only prompt + +**DO NOT** say "Ready to proceed with Mode X? (or type 'options')" as your +only gate. That is a prose prompt the model can skip or satisfy with a fake +affirmative. The v4 dogfood (LEARNING #16 → #20) surfaced this exact pattern +as a user-agency hole. `AskUserQuestion` is the fix. + +## Step 6: Auto-scaffold dogfood debrief + +Every successful HANDOFF calls the deterministic debrief scaffolder as its +final act, so the run does not leave only artifacts (PRD, tasks.json, +complexity report) with no record of what actually happened: + +```bash +SLUG="$(basename "$PWD")" +python3 "$SKILL_DIR/script.py" debrief \ + --slug "$SLUG" \ + --grade "$VALIDATION_GRADE" \ + --output-dir docs/v4-release 2>/dev/null || true +``` + +- Uses the project's directory name as slug (stable, matches human convention). +- Embeds the validation grade captured in Step 4's summary (`EXCELLENT 56/57`, etc.). +- Defaults to `.taskmaster/{tasks/tasks.json, reports/task-complexity-report.json, docs/prd.md}` + — no path flags needed in the common case. +- Silently tolerates failure (`|| true`) — a missing complexity report or + gitignored `docs/v4-release/` must never block a handoff that otherwise + succeeded. +- Output path is returned as `output_path` in the JSON response; surface it + to the user as "Debrief scaffolded at: <path>. Judgment sections (worked / + broke / meta) left as TODO — fill them in before the memory fades." + +If `docs/v4-release/` doesn't exist in the target project (most projects +won't — this is a prd-taskmaster convention), skip the call or let it fail +silently. The scaffold is only useful for projects that retain it. + +## Evidence Gate + +**Gate: capabilities detected AND a mode recommended AND CLAUDE.md workflow +appended AND AskUserQuestion surfaced (or prose fallback with `[AI]` insight +if hook-blocked) AND the user's choice dispatched.** + +Emit a compact one-block status: + +``` +Handoff: + capabilities tier: <free|premium> + recommended mode: <A|B|C> + CLAUDE.md: <created|appended|skipped> + picker: <AskUserQuestion|prose-fallback> + user choice: <Plan & Drive|Auto-Execute|Verified Loop|Atlas Fleet-teased> + dispatched: <skill/command invoked, or "Atlas Pro re-prompt"> + debrief: <path or "skipped"> +``` + +## Exit gate + +After the evidence gate passes: + +1. Call `mcp__plugin_prd-taskmaster_go__advance_phase(expected_current="HANDOFF", target="EXECUTE", evidence={"user_mode_choice": "<A|B|C>", "plan_file_exists": True, "capabilities_tier": "<free|premium>"})`. + The call atomically transitions `pipeline.json` from HANDOFF to EXECUTE. + The `expected_current` field is the compare-and-swap guard; + `evidence` is stored under `phase_evidence[EXECUTE]` for audit. +2. Return control to the orchestrator (`prd-taskmaster` skill). Do NOT invoke + EXECUTE directly — the orchestrator re-reads `current_phase` and routes. + +## Red flags (stop and report, do not paper over) + +- "The user typed 'yeah sure' so I'll treat that as Mode C approval" → NO. + Use `AskUserQuestion`. A structured choice is the durable record; prose + affirmatives are skippable. +- "AskUserQuestion is hook-blocked so I'll just pick Mode C myself" → NO. + Fall back to the prose option table + `[AI]` insight block. The user still + picks; you just surface the choice in a different shape. +- "Atlas Fleet is available locally because atlas-loop is installed, + so I'll execute it" → NO. Atlas Fleet requires tier=premium from + detect_capabilities. +- "The CLAUDE.md append had markers already, so I'll skip Step 3 entirely" + → NO. `action: "skipped"` is the expected idempotent outcome; emit it and + proceed. Skipping the call means you don't know the state. +- "I can call advance_phase without the evidence gate passing" → NO. Gate + first, always. +- "The debrief scaffolder failed so I'll abort the whole handoff" → NO. The + scaffolder is silently tolerant (`|| true`) — a missing `docs/v4-release/` + is not a handoff failure. + +## Non-exits + +This skill does not use explicit process termination. A hard block reports +the reason and returns control to the orchestrator; the orchestrator decides +whether to surface to the user. diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md new file mode 100644 index 0000000..6b668b1 --- /dev/null +++ b/skills/setup/SKILL.md @@ -0,0 +1,206 @@ +--- +name: setup +description: >- + Phase 0 of the prd-taskmaster pipeline. Verifies task-master is installed, + the project is initialized, the provider stack is configured (DETECT-FIRST — + never overwrite a working user config), and the AI pipeline actually runs. + Autonomous: zero user questions unless a hard block is hit. Declares the + Setup phase complete so DISCOVER can follow. +user-invocable: false +allowed-tools: + - Read + - Bash + - Skill +--- + +# Phase 0: Setup + +Declarative phase skill. Invoked by the prd-taskmaster orchestrator when +`current_phase` is null or `SETUP`. Never called directly by a user. + +## Entry gate + +1. Call `mcp__plugin_prd-taskmaster_go__check_gate(phase="SETUP", evidence={})`. + If the call returns blocked evidence, do not print the raw JSON. Render + one plain-English line: + + ```text + ✗ Gate blocked: <first violation> + Fix: <one copy-pasteable action> + ``` + + A passed gate renders as `✓ Gate passed: <summary>`. The gate protects + against re-entering a completed phase or skipping ahead. + + **Known issue (Mum dogfood feedback [4]):** check_gate semantics are + structurally an EXIT gate (verifies evidence sufficient to advance) but + wired here as an ENTRY gate. On first entry, evidence=`{}` will fail + the SETUP gate's `validate_setup.ready=true` requirement (which Step 4 + below produces). State machine LEGAL_TRANSITIONS (`None: ["SETUP"]`) + already prevents illegal entry — proceed past this gate on first entry + and rely on the exit gate for evidence verification. Semantic fix in + flight (see morning brief). + +## Procedure (5 steps, abort on hard failure) + +### Step 1: Installation check + +Run `which task-master-ai`. + +If the binary is not found, report: + +``` +┌─ atlas ── PHASE 1/4: PREFLIGHT ────────────────────────────┐ +What happened: TaskMaster is missing. +Evidence: + ✗ TaskMaster CLI: command `task-master-ai` was not found +Next: install the peer tool, then run /atlas again. +✗ Gate blocked: TaskMaster CLI is required for local task generation. +Fix: npm install -g task-master-ai +``` + +Abort the phase. Do NOT auto-install. + +### Step 2: Project init + +Check whether the current project has a `.taskmaster/` directory. + +If missing, run `task-master init --yes`. If present, continue. + +### Step 2.5: Customisation bootstrap (REQUIRED — closes execute-task deadlock) + +`execute-task` requires `.atlas-ai/customizations/system-prompt-template.md` +to exist as a precondition (its Entry gate halts otherwise). It cannot +create the file from inside the loop — the failure mode is a hard halt with +no recovery path. + +This step ensures the file exists BEFORE execute-task ever runs: + +```bash +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +PLUGIN_SKEL="$SKILL_DIR/../../skel/customizations" +mkdir -p .atlas-ai/customizations +if [ ! -f .atlas-ai/customizations/system-prompt-template.md ]; then + if [ -d "$PLUGIN_SKEL" ]; then + cp -n "$PLUGIN_SKEL"/*.md .atlas-ai/customizations/ # -n: no-clobber, copy starter pack + else + : > .atlas-ai/customizations/system-prompt-template.md # empty is fine per execute-task Entry gate + fi +fi +``` + +The starter pack (`domain-vocabulary.md`, `system-prompt-template.md`, +`task-enrichment-rules.md`, `verification-preferences.md`) is editable — +users tune them to project-specific terminology. Empty is acceptable; the +file simply must exist. + +Also scaffold `.atlas-ai/ship-check.py` if it doesn't already exist: + +```bash +if [ ! -f .atlas-ai/ship-check.py ]; then + if [ -f "$SKILL_DIR/../../skel/ship-check.py" ]; then + cp "$SKILL_DIR/../../skel/ship-check.py" .atlas-ai/ship-check.py + chmod +x .atlas-ai/ship-check.py + else + : > .atlas-ai/ship-check.py + fi +fi +``` + +(Codified 2026-06-04 — yesterday's run halted at execute-task Entry +because `system-prompt-template.md` was missing; the file had to be +manually `touch`-ed from outside the loop.) + +### Step 3: Provider configuration — DETECT-FIRST + +**Read `task-master models` output BEFORE setting anything.** This is the +load-bearing rule. A working user config must NOT be overwritten silently. + +| `task-master models` output | Action | +|---|---| +| Main / Research / Fallback all populated with a supported provider | SKIP — go to Step 4. | +| Main set, Research/Fallback empty | Partial mutate — fill the empty roles only. | +| All three empty (fresh install) | Full configure — use the default stack below. | +| Provider flagged unsupported / deprecated | Ask the user before mutating. | + +**Why DETECT-FIRST:** v4 dogfood (2026-04-13, LEARNING #9) caught the skill +overwriting a working `gemini-cli / gemini-3-pro-preview` config because the +procedure wasn't branch-aware. Detect first, mutate only the empty slots. + +**Default stack (fresh install only):** + +```bash +task-master models --set-main gemini-3-pro-preview --gemini-cli +task-master models --set-research gemini-3-pro-preview --gemini-cli +task-master models --set-fallback gemini-3-flash-preview --gemini-cli +``` + +Why Gemini CLI: ~113× more token-efficient than sonnet on parse-prd, free via +any Google account, no API key. One provider, three roles, zero cost. + +**Alternatives:** Claude Max (`--claude-code sonnet/opus/haiku`), any of the +12 task-master provider families, or a registered MCP research tool for the +Research role. + +### Step 4: Probe test + +If tasks already exist, call the MCP tool +`mcp__plugin_prd-taskmaster_go__validate_setup` or run +`task-master analyze-complexity --id 1`. + +If no tasks exist yet (fresh project), skip the probe — Step 3's provider +configuration is sufficient evidence the pipeline is wired. + +### Step 5: Status block + +Emit this fixed-order status block. Each check has one success criterion. + +``` +┌─ atlas ── PHASE 1/4: PREFLIGHT ────────────────────────────┐ +What happened: Preflight checked the local project and tools. +Evidence: + ✓ TaskMaster CLI: `task-master-ai --version` returned <version> + ✓ Project files: `.taskmaster/` exists or `task-master init --yes` completed + ✓ Provider stack: main=<main-provider>, research=<research-provider> + ○ Probe run: skipped for a fresh project with no tasks yet +Gate passed: setup evidence is ready for discovery. +Next: start discovery and capture the goal constraints. +``` + +Blocked example: + +```text +┌─ atlas ── PHASE 1/4: PREFLIGHT ────────────────────────────┐ +What happened: Preflight found a missing peer tool. +Evidence: + ✗ TaskMaster CLI: `task-master-ai` was not found +Gate blocked: TaskMaster CLI is required before discovery can run. +Fix: npm install -g task-master-ai +Next: run /atlas again after the install completes. +``` + +## Exit gate + +After Steps 1–5 report green: + +1. Call `mcp__plugin_prd-taskmaster_go__advance_phase(expected_current="SETUP", target="DISCOVER", evidence={"validate_setup": <Step 4 result dict>, "provider_configured": True})`. + The call atomically transitions `pipeline.json` from SETUP to DISCOVER. + The `expected_current` field is the compare-and-swap guard; + `evidence` is stored under `phase_evidence[DISCOVER]` for audit. +2. Return control to the orchestrator (`prd-taskmaster` skill). Do NOT invoke + DISCOVER directly — the orchestrator re-reads `current_phase` and routes. + +## Red flags (stop and report, do not paper over) + +- "The config is set but looks wrong — I'll fix it" → NO. Report and ask. +- "No tasks exist so I'll skip the whole provider step" → NO. Provider must be + configured before DISCOVER runs (otherwise `parse-prd` fails later). +- "I'll auto-install task-master via npm" → NO. Installation is a user action; + this skill only detects and reports. +- "I can call advance_phase without check_gate" → NO. Gate first, always. + +## Non-exits + +This skill does not use explicit process termination. A hard block reports +the reason and returns control to the orchestrator; the orchestrator decides +whether to surface to the user. diff --git a/templates/taskmaster-prd-comprehensive.md b/templates/taskmaster-prd-comprehensive.md index a804c75..188a2b5 100644 --- a/templates/taskmaster-prd-comprehensive.md +++ b/templates/taskmaster-prd-comprehensive.md @@ -641,11 +641,19 @@ CREATE INDEX idx_users_2fa_enabled ON users(two_factor_enabled); - Complexity: Small (3h) - Dependencies: None - Owner: Backend team + - Acceptance Criteria: + - [ ] Migration runs without errors on clean database + - [ ] All required columns and indexes present + - [ ] Rollback migration verified - [x] Task 1.2: Implement 2FA secret generation (REQ-002) - Complexity: Small (2h) - Dependencies: Task 1.1 - Owner: Backend team + - Acceptance Criteria: + - [ ] Generates cryptographically secure TOTP secrets + - [ ] Unit tests cover generation and storage + - [ ] Secrets never logged or exposed in errors - [x] Task 1.3: Implement TOTP validation logic (REQ-003) - Complexity: Medium (5h) @@ -978,6 +986,53 @@ Explicitly NOT included in this release: --- +--- + +## Appendix: Phase-Aware Task Metadata (phaseConfig) + +After TaskMaster parses this PRD into tasks, run: + +```bash +python3 ~/.claude/skills/prd-taskmaster/script.py enrich-tasks +``` + +This adds `phaseConfig` to each task in `.taskmaster/tasks/tasks.json`: + +```json +{ + "phaseConfig": { + "complexity": "SIMPLE | MEDIUM | COMPLEX | RESEARCH | VALIDATION", + "requiresCDD": true, + "requiresResearch": false, + "lifecycle": ["planning", "implementation", "testing", "review"], + "cddCardId": null, + "acceptanceCriteria": [ + "Implementation matches requirements described in task", + "All automated tests pass with no regressions", + "Code reviewed before merge" + ] + } +} +``` + +### Complexity Guidelines for This PRD + +When writing task descriptions, use these signals to influence auto-classification: + +| Complexity | Use When | Examples | +|------------|----------|---------| +| `SIMPLE` | Single file change, no deps, < 3h | "Add button to UI", "Update config value" | +| `MEDIUM` | 2+ subtasks or 30+ word description | "Build API endpoint with tests" | +| `COMPLEX` | Architecture, security, auth, migrations | "Design database schema", "Implement OAuth" | +| `RESEARCH` | Research/investigate/evaluate keywords | "Research SMS providers", "Evaluate caching strategies" | +| `VALIDATION` | USER-TEST checkpoints | Automatically detected | + +### Evidence Gate + +Tasks with `requiresCDD: true` (MEDIUM, COMPLEX, RESEARCH) must have an evidence card defined before implementation begins. The `/phase-executor` skill enforces this gate automatically. + +--- + **End of PRD** *This PRD is optimized for taskmaster AI task generation. All requirements include task breakdown hints, complexity estimates, and dependency mapping to enable effective automated task planning.* diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_atomic_ops.py b/tests/core/test_atomic_ops.py new file mode 100644 index 0000000..f78f4ba --- /dev/null +++ b/tests/core/test_atomic_ops.py @@ -0,0 +1,53 @@ +import os +import threading +from pathlib import Path + +from prd_taskmaster.lib import atomic_write, locked_update, emit_json_error + + +def test_atomic_write_creates_file(tmp_path): + target = tmp_path / "out.txt" + atomic_write(target, "hello") + assert target.read_text() == "hello" + + +def test_atomic_write_replaces_existing(tmp_path): + target = tmp_path / "out.txt" + target.write_text("old") + atomic_write(target, "new") + assert target.read_text() == "new" + + +def test_atomic_write_survives_mid_write_crash_simulation(tmp_path): + """Atomic write: tmp file exists but not moved should not corrupt target.""" + target = tmp_path / "out.txt" + target.write_text("original") + # simulate a tmp file left behind from aborted atomic_write + (tmp_path / f"out.txt.tmp.{os.getpid()}").write_text("partial") + # a fresh atomic_write should still produce clean result + atomic_write(target, "final") + assert target.read_text() == "final" + + +def test_locked_update_serializes_concurrent_writes(tmp_path): + target = tmp_path / "counter.txt" + target.write_text("0") + + def incr(): + locked_update(target, lambda s: str(int(s.strip() or "0") + 1)) + + threads = [threading.Thread(target=incr) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert int(target.read_text().strip()) == 10, "All 10 increments must land (no lost updates)" + + +def test_emit_json_error_returns_dict_not_exit(): + """Functions must return dicts, NOT call sys.exit (per spec §13.3).""" + result = emit_json_error("test error", code=42) + assert result["ok"] is False + assert result["error"] == "test error" + assert result["code"] == 42 diff --git a/tests/core/test_capabilities.py b/tests/core/test_capabilities.py new file mode 100644 index 0000000..104e43a --- /dev/null +++ b/tests/core/test_capabilities.py @@ -0,0 +1,370 @@ +"""Tests for prd_taskmaster.mode_recommend — detect_taskmaster, detect_capabilities, validate_setup. + +Retargeted from the plugin's capabilities.py (now prd_taskmaster/mode_recommend.py). +Direct function calls (no sys.exit, pure dicts). Uses monkeypatch for env isolation. +""" + +import json + +import pytest + +from prd_taskmaster import capabilities as cli_capabilities +from prd_taskmaster import mode_recommend +from prd_taskmaster.mode_recommend import detect_taskmaster, detect_capabilities, validate_setup + + +# ─── detect_taskmaster ──────────────────────────────────────────────────────── + + +class TestDetectTaskmaster: + """detect_taskmaster() — MCP/CLI/none detection.""" + + def test_detect_taskmaster_finds_cli(self, monkeypatch, tmp_path): + """Returns method='cli' when a task-master binary is found in PATH.""" + + # Create a fake task-master script that outputs a version line. + fake_bin = tmp_path / "task-master" + fake_bin.write_text("#!/bin/sh\necho 0.99.0\n") + fake_bin.chmod(0o755) + + monkeypatch.setenv("PATH", str(tmp_path)) + # Ensure no MCP config files exist in tmp_path (CWD isolation) + monkeypatch.chdir(tmp_path) + + result = detect_taskmaster() + + assert result["ok"] is True + assert result["method"] == "cli" + assert result["path"] is not None + + def test_detect_taskmaster_returns_none_when_missing(self, monkeypatch, tmp_path): + """Returns method='none' when no CLI or MCP config is found.""" + monkeypatch.setenv("PATH", str(tmp_path)) # empty dir — no binaries + monkeypatch.chdir(tmp_path) + + result = detect_taskmaster() + + assert result["ok"] is True + assert result["method"] == "none" + assert result["path"] is None + + def test_detect_taskmaster_finds_mcp_from_dotmcp_json(self, monkeypatch, tmp_path): + """Returns method='mcp' when .mcp.json contains a task-master server entry.""" + mcp_config = { + "mcpServers": { + "task-master-ai": {"command": "npx", "args": ["task-master-ai"]} + } + } + (tmp_path / ".mcp.json").write_text(json.dumps(mcp_config)) + monkeypatch.setenv("PATH", str(tmp_path)) # no CLI binary + monkeypatch.chdir(tmp_path) + + result = detect_taskmaster() + + assert result["ok"] is True + assert result["method"] == "mcp" + + def test_detect_taskmaster_always_returns_ok_true(self, monkeypatch, tmp_path): + """ok is always True — no sys.exit, result is always a dict.""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = detect_taskmaster() + + assert isinstance(result, dict) + assert result["ok"] is True + assert result["method"] in ("mcp", "cli", "none") + + +# ─── detect_capabilities ────────────────────────────────────────────────────── + + +class TestDetectCapabilities: + """detect_capabilities() — tier, flags, and tool inventory.""" + + def test_detect_capabilities_returns_tier_and_flags(self, monkeypatch, tmp_path): + """Returns expected top-level keys including has_atlas_premium and tier.""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = detect_capabilities() + + assert result["ok"] is True + assert result["tier"] in ("free", "premium") + assert "has_atlas_premium" in result + assert "has_superpowers" in result + assert "has_taskmaster" in result + assert "capabilities" in result + assert isinstance(result["capabilities"], dict) + + def test_detect_capabilities_has_atlas_premium_key(self, monkeypatch, tmp_path): + """has_atlas_premium MUST be a top-level key (adversarial spec requirement).""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = detect_capabilities() + + # has_atlas_premium must exist at top level, not just inside 'capabilities' + assert "has_atlas_premium" in result + assert isinstance(result["has_atlas_premium"], bool) + + def test_detect_capabilities_free_tier_without_atlas_skills(self, monkeypatch, tmp_path): + """Reports free tier when atlas-loop and atlas-cdd skills are absent.""" + # Use a home dir that has no .claude/skills directory at all + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = detect_capabilities() + + assert result["ok"] is True + assert result["tier"] == "free" + assert result["has_atlas_premium"] is False + + def test_detect_capabilities_atlas_skills_without_launcher_license_stay_free(self, monkeypatch, tmp_path): + """Atlas skill markers alone do not unlock licensed Atlas Fleet.""" + skills_dir = tmp_path / ".claude" / "skills" + for skill in ("atlas-loop", "atlas-cdd"): + skill_dir = skills_dir / skill + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(f"# {skill} skill\n") + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = detect_capabilities() + + assert result["ok"] is True + assert result["tier"] == "free" + assert result["has_atlas_premium"] is False + + def test_detect_capabilities_returns_recommended_mode(self, monkeypatch, tmp_path): + """recommended_mode and recommended_reason are always present.""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = detect_capabilities() + + assert "recommended_mode" in result + assert "recommended_reason" in result + assert result["recommended_mode"] in ("A", "B", "C", "D", "E", "F", "G", "H", "I", "J") + + def test_run_detect_capabilities_premium_requires_launcher_and_active_license( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cli_capabilities, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + cli_capabilities.license, + "get_status", + lambda: {"status": "active", "days_remaining": None, "detail": "license active"}, + ) + + result = cli_capabilities.run_detect_capabilities() + + assert result["tier"] == "premium" + assert result["recommended_mode"] == "D" + assert result["license_status"]["status"] == "active" + + def test_run_detect_capabilities_launcher_without_license_stays_free( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cli_capabilities, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + cli_capabilities.license, + "get_status", + lambda: {"status": "invalid", "days_remaining": None, "detail": "no license saved"}, + ) + + result = cli_capabilities.run_detect_capabilities() + + assert result["tier"] == "free" + assert result["recommended_mode"] != "D" + assert result["license_status"]["status"] == "invalid" + + def test_run_detect_capabilities_license_without_launcher_stays_free( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + cli_capabilities, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": False}, + ) + monkeypatch.setattr( + cli_capabilities.license, + "get_status", + lambda: {"status": "grace", "days_remaining": 3, "detail": "license in grace"}, + ) + + result = cli_capabilities.run_detect_capabilities() + + assert result["tier"] == "free" + assert result["recommended_mode"] != "D" + assert result["license_status"]["status"] == "grace" + + def test_mode_recommend_launcher_without_license_does_not_recommend_fleet( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + mode_recommend, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + mode_recommend.license, + "get_status", + lambda: {"status": "expired", "days_remaining": 0, "detail": "license expired"}, + ) + + result = mode_recommend.detect_capabilities() + + assert result["tier"] == "free" + assert result["recommended_mode"] != "D" + assert result["license_status"]["status"] == "expired" + + def test_mode_recommend_launcher_with_grace_license_recommends_fleet( + self, + monkeypatch, + tmp_path, + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + mode_recommend, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + mode_recommend.license, + "get_status", + lambda: {"status": "grace", "days_remaining": 5, "detail": "license in grace"}, + ) + + result = mode_recommend.detect_capabilities() + + assert result["tier"] == "premium" + assert result["recommended_mode"] == "D" + assert result["license_status"]["status"] == "grace" + + +# ─── validate_setup ─────────────────────────────────────────────────────────── + + +class TestValidateSetup: + """validate_setup() — 6-check Phase 0 diagnostic.""" + + def test_validate_setup_returns_6_checks(self, monkeypatch, tmp_path): + """checks list MUST have exactly 6 entries (spec §5).""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = validate_setup() + + assert result["ok"] is True + assert "checks" in result + assert len(result["checks"]) == 6 + + def test_validate_setup_check_ids_match_spec(self, monkeypatch, tmp_path): + """Each check has the correct id (spec §5 check IDs).""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = validate_setup() + + ids = [c["id"] for c in result["checks"]] + assert ids == ["binary", "version", "project", "config", "provider_main", "provider_research"] + + def test_validate_setup_each_check_has_fix_hint(self, monkeypatch, tmp_path): + """Every check dict has a 'fix' key (may be None when passed).""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = validate_setup() + + for check in result["checks"]: + assert "id" in check + assert "passed" in check + assert "fix" in check + + def test_validate_setup_fails_without_binary(self, monkeypatch, tmp_path): + """binary check fails when task-master is not in PATH.""" + monkeypatch.setenv("PATH", str(tmp_path)) # empty dir — no binary + monkeypatch.chdir(tmp_path) + + result = validate_setup() + + binary_check = next(c for c in result["checks"] if c["id"] == "binary") + assert binary_check["passed"] is False + assert binary_check["fix"] is not None + assert result["ready"] is False + + def test_validate_setup_passes_with_full_project(self, monkeypatch, tmp_path): + """ready=True when CLI exists, project initialized, and main model configured.""" + # Create fake task-master binary + fake_bin = tmp_path / "bin" / "task-master" + fake_bin.parent.mkdir(parents=True) + fake_bin.write_text("#!/bin/sh\necho 0.99.0\n") + fake_bin.chmod(0o755) + + # Create .taskmaster/ with config.json + tm_dir = tmp_path / "project" / ".taskmaster" + tm_dir.mkdir(parents=True) + config = { + "models": { + "main": {"modelId": "claude-sonnet-4-5"}, + "research": {"modelId": "claude-opus-4"}, + "fallback": {"modelId": "claude-haiku-3"}, + } + } + (tm_dir / "config.json").write_text(json.dumps(config)) + + monkeypatch.setenv("PATH", str(tmp_path / "bin")) + monkeypatch.chdir(tmp_path / "project") + + result = validate_setup() + + assert result["ok"] is True + assert result["ready"] is True + assert result["critical_failures"] == 0 + + def test_validate_setup_critical_failures_count(self, monkeypatch, tmp_path): + """critical_failures counts non-warning failed checks only.""" + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = validate_setup() + + critical_ids = [ + c["id"] for c in result["checks"] + if not c["passed"] and c.get("severity") != "warning" + ] + assert result["critical_failures"] == len(critical_ids) diff --git a/tests/core/test_cli.py b/tests/core/test_cli.py new file mode 100644 index 0000000..c1c5c30 --- /dev/null +++ b/tests/core/test_cli.py @@ -0,0 +1,138 @@ +"""Representative end-to-end CLI tests for the prd_taskmaster package. + +Each test invokes the root script.py shim via subprocess and asserts on key +fields of the emitted JSON, proving the CLI wiring and pure cores behave. +""" + +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "script.py" +TEMPLATES = REPO_ROOT / "templates" + + +def run_cli(*args, expect_exit=0): + """Run the CLI shim and return parsed JSON stdout.""" + proc = subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + ) + assert proc.returncode == expect_exit, ( + f"exit={proc.returncode} expected={expect_exit}\nstdout={proc.stdout}\nstderr={proc.stderr}" + ) + return json.loads(proc.stdout) + + +def test_calc_tasks_small_clamps_to_10(): + data = run_cli("calc-tasks", "--requirements", "3") + assert data["ok"] is True + assert data["recommended"] == 10 + assert data["raw_calculation"] == 5 + + +def test_calc_tasks_mid_range(): + data = run_cli("calc-tasks", "--requirements", "12") + assert data["recommended"] == 18 + assert data["requirements_count"] == 12 + + +def test_calc_tasks_large_clamps_to_40(): + data = run_cli("calc-tasks", "--requirements", "40") + assert data["recommended"] == 40 + + +def test_load_template_minimal(): + data = run_cli("load-template", "--type", "minimal") + assert data["ok"] is True + assert data["type"] == "minimal" + assert data["line_count"] > 0 + assert data["path"].endswith("taskmaster-prd-minimal.md") + assert str(TEMPLATES) in data["path"] + + +def test_load_template_comprehensive(): + data = run_cli("load-template", "--type", "comprehensive") + assert data["ok"] is True + assert data["type"] == "comprehensive" + assert "Executive Summary" in data["content"] + + +def test_validate_prd_returns_grade(tmp_path): + prd = tmp_path / "prd.md" + prd.write_text( + "# Product Requirements: Widget API\n\n" + "## Problem\nTeams lack a way to sync widgets.\n\n" + "## Goals\n- REQ-001: The API must return widget lists in under 200ms p95.\n\n" + "## Out of Scope\n- Billing.\n" + ) + data = run_cli("validate-prd", "--input", str(prd)) + assert data["ok"] is True + assert data["grade"] in {"EXCELLENT", "GOOD", "ACCEPTABLE", "NEEDS_WORK"} + assert data["checks_total"] == 13 + assert 0 <= data["percentage"] <= 100 + + +def test_validate_prd_missing_file_fails(): + data = run_cli("validate-prd", "--input", "does-not-exist.md", expect_exit=1) + assert data["ok"] is False + assert "not found" in data["error"] + + +def test_validate_tasks_valid_file(tmp_path): + tasks = { + "master": { + "tasks": [ + { + "id": 1, + "title": "Build the widget sync endpoint", + "description": "Implement the REST endpoint for syncing widgets", + "details": "Wire the route, handler, and serializer", + "testStrategy": "Unit and integration tests for the endpoint", + "priority": "high", + "status": "pending", + "dependencies": [], + "subtasks": [ + {"id": 1, "title": "Define route", "description": "Add the URL route", "status": "pending", "dependencies": []}, + {"id": 2, "title": "Write handler", "description": "Implement handler logic", "status": "pending", "dependencies": [1]}, + ], + } + ] + } + } + tasks_file = tmp_path / "tasks.json" + tasks_file.write_text(json.dumps(tasks)) + data = run_cli("validate-tasks", "--input", str(tasks_file)) + assert data["ok"] is True + assert data["task_count"] == 1 + assert data["subtask_count"] == 2 + + +def test_enrich_tasks_adds_phase_config(tmp_path): + tasks = { + "master": { + "tasks": [ + { + "id": 1, + "title": "Research auth frameworks", + "description": "Evaluate options", + "priority": "high", + "status": "pending", + "subtasks": [], + } + ] + } + } + tasks_file = tmp_path / "tasks.json" + tasks_file.write_text(json.dumps(tasks)) + data = run_cli("enrich-tasks", "--input", str(tasks_file)) + assert data["ok"] is True + assert data["enriched"] == 1 + written = json.loads(tasks_file.read_text()) + task = written["master"]["tasks"][0] + assert task["phaseConfig"]["complexity"] == "RESEARCH" + assert "acceptanceCriteria" in task["phaseConfig"] diff --git a/tests/core/test_dogfood_fixes.py b/tests/core/test_dogfood_fixes.py new file mode 100644 index 0000000..bd4f151 --- /dev/null +++ b/tests/core/test_dogfood_fixes.py @@ -0,0 +1,130 @@ +"""Regression tests for the four 2026-06-11 dogfood findings +(docs/dogfood-phase-b-2026-06-11.md).""" + +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from prd_taskmaster.capabilities import run_detect_capabilities +from prd_taskmaster.lib import CommandError +from prd_taskmaster.providers import run_configure_providers +from prd_taskmaster.taskmaster import init_taskmaster +from prd_taskmaster.tasks import run_calc_tasks + + +# ── Finding 1 (P0): init must protect an existing .mcp.json ──────────────── + +def _fake_task_master(bin_dir: Path, clobber: bool) -> None: + """A fake task-master binary; optionally clobbers .mcp.json like the real init.""" + script = bin_dir / "task-master" + body = "#!/bin/sh\n" + if clobber: + body += "echo '{\"mcpServers\": {\"task-master-ai\": {}}}' > .mcp.json\n" + body += "exit 0\n" + script.write_text(body) + script.chmod(script.stat().st_mode | stat.S_IEXEC) + + +def test_init_restores_clobbered_mcp_json(tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _fake_task_master(bin_dir, clobber=True) + monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}") + monkeypatch.chdir(tmp_path) + + original = '{\n "go": {"command": "python3"}\n}\n' + (tmp_path / ".mcp.json").write_text(original) + + result = init_taskmaster() + assert result["ok"] is True + assert result["mcp_json_protected"] is True + assert (tmp_path / ".mcp.json").read_text() == original + + +def test_init_reports_unprotected_when_no_clobber(tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _fake_task_master(bin_dir, clobber=False) + monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}") + monkeypatch.chdir(tmp_path) + + original = '{"go": {}}' + (tmp_path / ".mcp.json").write_text(original) + + result = init_taskmaster() + assert result["ok"] is True + assert result["mcp_json_protected"] is False + assert (tmp_path / ".mcp.json").read_text() == original + + +def test_init_without_preexisting_mcp_json(tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _fake_task_master(bin_dir, clobber=True) + monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}") + monkeypatch.chdir(tmp_path) + + result = init_taskmaster() + # no original file -> nothing to protect; whatever init wrote stays + assert result["ok"] is True + assert result["mcp_json_protected"] is False + + +# ── Finding 2 (P1): configure-providers points at the protected init ──────── + +def test_configure_providers_recommends_protected_init(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) # no .taskmaster/config.json here + with pytest.raises(CommandError) as exc: + run_configure_providers() + assert exc.value.extra["recommended_action"] == "init_taskmaster" + assert "init-taskmaster" in exc.value.extra["fix"] + + +# ── Finding 3 (P2): calc-tasks respects the discovery scale band ─────────── + +def test_calc_tasks_solo_band_clamps_down(): + r = run_calc_tasks(14, scale="solo") + assert r["recommended"] == 12 # raw 21 clamped to solo [8, 12] + assert r["scale"] == "solo" + + +def test_calc_tasks_enterprise_band_clamps_up(): + r = run_calc_tasks(3, scale="enterprise") + assert r["recommended"] == 20 # raw 5 clamped to enterprise [20, 30] + + +def test_calc_tasks_no_scale_keeps_legacy_formula(): + r = run_calc_tasks(14) + assert r["recommended"] == 21 + assert "scale" not in r + + +def test_calc_tasks_rejects_unknown_scale(): + with pytest.raises(CommandError): + run_calc_tasks(10, scale="galactic") + + +# ── Finding 4 (P2): detection emits tier + a reason aligned with handoff ─── + +def test_detect_capabilities_has_tier_and_reason(): + r = run_detect_capabilities() + assert r["tier"] in ("free", "premium") + assert r["recommended_mode"] in ("A", "B", "C", "D") + assert len(r["recommended_reason"]) > 10 + + +def test_detect_capabilities_loop_runner_flips_to_verified_loop(monkeypatch, tmp_path): + # Fake a HOME with superpowers + ralph-loop installed -> Verified Loop (C) + home = tmp_path / "home" + (home / ".claude" / "plugins" / "superpowers").mkdir(parents=True) + (home / ".claude" / "skills" / "ralph-loop").mkdir(parents=True) + (home / ".claude" / "skills" / "ralph-loop" / "SKILL.md").write_text("x") + monkeypatch.setenv("HOME", str(home)) + r = run_detect_capabilities() + assert r["recommended_mode"] == "C" + assert "Verified Loop" in r["recommended_reason"] diff --git a/tests/core/test_engine_preflight.py b/tests/core/test_engine_preflight.py new file mode 100644 index 0000000..7527867 --- /dev/null +++ b/tests/core/test_engine_preflight.py @@ -0,0 +1,24 @@ +"""Batched engine_preflight (dogfood finding 2026-06-11: skill UX shells out +to script.py repeatedly — one MCP call should cover the whole Phase 1).""" + +import json + +from prd_taskmaster.batch import run_engine_preflight + + +def test_engine_preflight_batches_all_phase1_signals(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + r = run_engine_preflight() + assert r["ok"] is True + # one call returns every Phase-1 signal + for key in ("preflight", "taskmaster", "providers", "capabilities", "summary"): + assert key in r, f"missing {key}" + assert isinstance(r["summary"], list) and r["summary"], "human-readable summary lines" + assert r["preflight"]["has_taskmaster"] is False # fresh dir + + +def test_engine_preflight_never_mutates_without_project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + run_engine_preflight() + # no .taskmaster, no .env, nothing created in a bare dir (read-only probe) + assert list(tmp_path.iterdir()) == [] diff --git a/tests/core/test_fleet.py b/tests/core/test_fleet.py new file mode 100644 index 0000000..dfc229b --- /dev/null +++ b/tests/core/test_fleet.py @@ -0,0 +1,204 @@ +"""Unit and CLI tests for the Atlas Fleet wave scheduler.""" + +import json +import subprocess +import sys +from pathlib import Path + +from prd_taskmaster.fleet import compute_waves, ready_set + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / "script.py" + + +def _task(task_id, status="pending", dependencies=None): + return { + "id": task_id, + "status": status, + "dependencies": [] if dependencies is None else dependencies, + } + + +def test_compute_waves_linear_chain(): + tasks = [ + _task(1), + _task(2, dependencies=[1]), + _task(3, dependencies=[2]), + ] + + assert ready_set(tasks) == [1] + assert compute_waves(tasks) == { + "waves": [[1], [2], [3]], + "blocked": [], + "deadlocked": False, + } + + +def _run_cli(cwd, *args): + proc = subprocess.run( + [sys.executable, str(SCRIPT), *args], + cwd=cwd, + capture_output=True, + text=True, + ) + return proc.returncode, proc.stdout, proc.stderr + + +def _write_tasks(tmp_path, payload, current_tag=None): + tasks_dir = tmp_path / ".taskmaster" / "tasks" + tasks_dir.mkdir(parents=True) + (tasks_dir / "tasks.json").write_text(json.dumps(payload)) + if current_tag is not None: + (tmp_path / ".taskmaster" / "state.json").write_text( + json.dumps({"currentTag": current_tag}) + ) + + +def test_fleet_waves_cli_uses_current_tag(tmp_path): + _write_tasks( + tmp_path, + { + "alpha": { + "tasks": [ + _task(1), + _task(2, dependencies=[1]), + _task(3, status="done"), + ] + } + }, + current_tag="alpha", + ) + + code, out, err = _run_cli(tmp_path, "fleet-waves") + + assert code == 0, err + data = json.loads(out) + assert data == { + "ok": True, + "tag": "alpha", + "waves": [[1], [2]], + "blocked": [], + "deadlocked": False, + "ready": [1], + "concurrency": 3, + } + + +def test_fleet_waves_cli_supports_flat_tasks_with_tag(tmp_path): + _write_tasks( + tmp_path, + { + "tasks": [ + _task(1), + _task(2, dependencies=[1]), + ] + }, + ) + + code, out, err = _run_cli(tmp_path, "fleet-waves", "--tag", "release") + + assert code == 0, err + data = json.loads(out) + assert data["ok"] is True + assert data["tag"] == "release" + assert data["waves"] == [[1], [2]] + assert data["ready"] == [1] + + +def test_fleet_waves_cli_concurrency_override(tmp_path): + _write_tasks( + tmp_path, + { + "master": { + "tasks": [ + _task(1), + _task(2), + _task(3), + ] + } + }, + ) + + code, out, err = _run_cli(tmp_path, "fleet-waves", "--concurrency", "2") + + assert code == 0, err + data = json.loads(out) + assert data["concurrency"] == 2 + assert data["ready"] == [1, 2, 3] + assert data["waves"] == [[1, 2], [3]] + + +def test_fleet_waves_cli_missing_tasks_json_fails(tmp_path): + code, out, err = _run_cli(tmp_path, "fleet-waves") + + assert code == 1, err + data = json.loads(out) + assert data["ok"] is False + assert ".taskmaster/tasks/tasks.json" in data["error"] + assert "not found" in data["error"] + + +def test_compute_waves_diamond_dependency_preserves_input_order(): + tasks = [ + _task(1), + _task(3, dependencies=[1]), + _task(2, dependencies=[1]), + _task(4, dependencies=[2, 3]), + ] + + assert compute_waves(tasks) == { + "waves": [[1], [3, 2], [4]], + "blocked": [], + "deadlocked": False, + } + + +def test_compute_waves_deadlock_cycle_lists_blocked_members(): + tasks = [ + _task(1, dependencies=[2]), + _task(2, dependencies=[1]), + ] + + assert compute_waves(tasks) == { + "waves": [], + "blocked": [1, 2], + "deadlocked": True, + } + + +def test_compute_waves_chunks_frontier_by_concurrency_in_input_order(): + tasks = [ + _task(5), + _task(1), + _task(4), + _task(2), + _task(3), + ] + + assert compute_waves(tasks, max_concurrency=2) == { + "waves": [[5, 1], [4, 2], [3]], + "blocked": [], + "deadlocked": False, + } + + +def test_compute_waves_empty_graph(): + assert compute_waves([]) == { + "waves": [], + "blocked": [], + "deadlocked": False, + } + + +def test_compute_waves_all_done_graph(): + tasks = [ + _task(1, status="done"), + _task(2, status="done", dependencies=[1]), + ] + + assert ready_set(tasks) == [] + assert compute_waves(tasks) == { + "waves": [], + "blocked": [], + "deadlocked": False, + } diff --git a/tests/core/test_fleet_config.py b/tests/core/test_fleet_config.py new file mode 100644 index 0000000..c4c2f0a --- /dev/null +++ b/tests/core/test_fleet_config.py @@ -0,0 +1,88 @@ +"""B-Task 8 (REQ-010): .atlas-ai/fleet.json routing config for Atlas Fleet.""" + +import json + +import pytest + +from prd_taskmaster.fleet import load_fleet_config, resolve_backend + + +def test_defaults_without_config_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg = load_fleet_config() + assert cfg["max_concurrency"] == 3 + assert cfg["experimental_backends"] is False + assert set(cfg["routing"]) == {"fast", "standard", "capable"} + assert all(v.startswith("claude:") for v in cfg["routing"].values()) + + +def test_config_file_overrides(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "max_concurrency": 5, + "routing": {"fast": "codex:gpt-5.2-codex"}, + "experimental_backends": True, + })) + cfg = load_fleet_config() + assert cfg["max_concurrency"] == 5 + assert cfg["experimental_backends"] is True + assert cfg["routing"]["fast"] == "codex:gpt-5.2-codex" + # unspecified tiers keep defaults + assert cfg["routing"]["capable"].startswith("claude:") + + +def test_flag_off_forces_claude_regardless_of_routing(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "routing": {"fast": "codex:gpt-5.2-codex", "capable": "gemini:pro"}, + "experimental_backends": False, + })) + cfg = load_fleet_config() + assert resolve_backend("fast", cfg).startswith("claude:") + assert resolve_backend("capable", cfg).startswith("claude:") + + +def test_flag_on_honours_routing(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "routing": {"fast": "codex:gpt-5.2-codex"}, + "experimental_backends": True, + })) + cfg = load_fleet_config() + assert resolve_backend("fast", cfg) == "codex:gpt-5.2-codex" + assert resolve_backend("standard", cfg).startswith("claude:") # default kept + + +def test_malformed_config_falls_back_to_defaults(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text("{not json") + cfg = load_fleet_config() + assert cfg["max_concurrency"] == 3 + assert cfg["experimental_backends"] is False + + +def test_invalid_values_ignored(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "max_concurrency": 0, # invalid (<1) -> default + "routing": "not-a-dict", # invalid -> default + "unknown_key": "ignored", + })) + cfg = load_fleet_config() + assert cfg["max_concurrency"] == 3 + assert isinstance(cfg["routing"], dict) + + +def test_unknown_tier_resolves_to_standard(): + cfg = {"routing": {"standard": "claude:sonnet"}, "experimental_backends": True} + assert resolve_backend("mystery", cfg) == "claude:sonnet" diff --git a/tests/core/test_fleet_detection.py b/tests/core/test_fleet_detection.py new file mode 100644 index 0000000..1df8fa2 --- /dev/null +++ b/tests/core/test_fleet_detection.py @@ -0,0 +1,191 @@ +"""Tests for Atlas Fleet launcher detection.""" + +import json + +import pytest + +from prd_taskmaster import license as license_module +from prd_taskmaster.capabilities import run_detect_capabilities +from prd_taskmaster.mode_recommend import detect_atlas_launcher, detect_capabilities + + +FLEET_REASON = "Atlas Fleet — atlas-launcher detected (parallel multi-session execution)" + + +def _make_executable(path): + path.write_text("#!/bin/sh\nexit 0\n") + path.chmod(0o755) + + +@pytest.mark.parametrize( + ("binary_name", "expected_installed"), + [ + (None, False), + ("atlas-launcher", True), + ("atlas", True), + ], +) +@pytest.mark.parametrize( + ("home_config", "expected_registered"), + [ + (None, False), + ({"mcpServers": {"atlas-launcher": {"command": "atlas-launcher"}}}, True), + ], +) +def test_detect_atlas_launcher_binary_and_config_matrix( + monkeypatch, + tmp_path, + binary_name, + expected_installed, + home_config, + expected_registered, +): + home_dir = tmp_path / "home" + project_dir = tmp_path / "project" + bin_dir = tmp_path / "bin" + home_dir.mkdir() + project_dir.mkdir() + bin_dir.mkdir() + + if binary_name: + _make_executable(bin_dir / binary_name) + if home_config: + (home_dir / ".claude.json").write_text(json.dumps(home_config)) + + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.chdir(project_dir) + + assert detect_atlas_launcher() == { + "installed": expected_installed, + "mcp_registered": expected_registered, + } + + +def test_detect_atlas_launcher_ignores_malformed_home_config(monkeypatch, tmp_path): + home_dir = tmp_path / "home" + project_dir = tmp_path / "project" + bin_dir = tmp_path / "bin" + home_dir.mkdir() + project_dir.mkdir() + bin_dir.mkdir() + (home_dir / ".claude.json").write_text("{not valid json") + + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.chdir(project_dir) + + assert detect_atlas_launcher() == { + "installed": False, + "mcp_registered": False, + } + + +def test_detect_atlas_launcher_ignores_empty_project_mcp_json(monkeypatch, tmp_path): + home_dir = tmp_path / "home" + project_dir = tmp_path / "project" + bin_dir = tmp_path / "bin" + home_dir.mkdir() + project_dir.mkdir() + bin_dir.mkdir() + (project_dir / ".mcp.json").write_text("") + + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.chdir(project_dir) + + assert detect_atlas_launcher() == { + "installed": False, + "mcp_registered": False, + } + + +@pytest.mark.parametrize( + "project_config", + [ + {"atlas-launcher": {"command": "atlas-launcher"}}, + {"mcpServers": {"atlas-launcher": {"command": "atlas-launcher"}}}, + ], +) +def test_detect_atlas_launcher_finds_project_mcp_json_only( + monkeypatch, + tmp_path, + project_config, +): + home_dir = tmp_path / "home" + project_dir = tmp_path / "project" + bin_dir = tmp_path / "bin" + home_dir.mkdir() + project_dir.mkdir() + bin_dir.mkdir() + (project_dir / ".mcp.json").write_text(json.dumps(project_config)) + + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.chdir(project_dir) + + assert detect_atlas_launcher() == { + "installed": False, + "mcp_registered": True, + } + + +def test_detect_capabilities_flips_both_paths_to_premium_with_launcher_mcp( + monkeypatch, + tmp_path, +): + home_dir = tmp_path / "home" + project_dir = tmp_path / "project" + bin_dir = tmp_path / "bin" + home_dir.mkdir() + project_dir.mkdir() + bin_dir.mkdir() + (home_dir / ".claude.json").write_text(json.dumps({ + "mcpServers": { + "atlas-launcher": {"command": "atlas-launcher"}, + }, + })) + + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.chdir(project_dir) + # Premium now requires launcher AND an active/grace license (REQ-006). + monkeypatch.setattr( + license_module, + "get_status", + lambda *a, **k: {"status": "active", "days_remaining": None, "detail": "license active"}, + ) + + for result in (detect_capabilities(), run_detect_capabilities()): + assert result["tier"] == "premium" + assert result["recommended_mode"] == "D" + assert result["recommended_reason"] == FLEET_REASON + + +def test_detect_capabilities_preserves_free_tier_with_clean_home( + monkeypatch, + tmp_path, +): + home_dir = tmp_path / "home" + project_dir = tmp_path / "project" + bin_dir = tmp_path / "bin" + home_dir.mkdir() + project_dir.mkdir() + bin_dir.mkdir() + + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.chdir(project_dir) + + plugin_result = detect_capabilities() + cli_result = run_detect_capabilities() + + assert plugin_result["tier"] == "free" + assert plugin_result["recommended_mode"] == "A" + assert plugin_result["recommended_reason"] == "Plan & Drive — universal fallback" + + assert cli_result["tier"] == "free" + assert cli_result["recommended_mode"] == "A" + assert cli_result["recommended_reason"] == ( + "Plan & Drive — universal fallback, no execution tooling detected" + ) diff --git a/tests/core/test_kpi_snapshot.py b/tests/core/test_kpi_snapshot.py new file mode 100644 index 0000000..65bd05e --- /dev/null +++ b/tests/core/test_kpi_snapshot.py @@ -0,0 +1,62 @@ +import json +import os +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "scripts" / "ops" / "kpi-snapshot.sh" + + +def _run_snapshot(env_updates=None): + env = os.environ.copy() + for key in ( + "CF_ACCOUNT_ID", + "CF_API_TOKEN", + "ATLAS_D1_DB", + "KPI_QUERY_CMD", + "KPI_FIXTURE_JSON", + "LICENSE_TELEMETRY_DEPLOYED", + ): + env.pop(key, None) + if env_updates: + env.update(env_updates) + return subprocess.run( + ["bash", str(SCRIPT)], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_kpi_snapshot_outputs_table_from_fixture_json(): + fixture = { + "A1": {"last_7d": 7, "last_30d": 30, "all_time": 300}, + "A3": {"last_7d": 4, "last_30d": 12, "all_time": 80}, + "AC1": {"last_7d": 3, "last_30d": 10, "all_time": 60}, + "AC2": {"last_7d": 2, "last_30d": 8, "all_time": 40}, + "C1": {"last_7d": "5%", "last_30d": "7%", "all_time": "9%"}, + } + + result = _run_snapshot({"KPI_FIXTURE_JSON": json.dumps(fixture), "LICENSE_TELEMETRY_DEPLOYED": "1"}) + + assert result.returncode == 0, result.stderr + assert "Metric" in result.stdout + assert "last-7d" in result.stdout + assert "last-30d" in result.stdout + assert "all-time" in result.stdout + for label in ("A1 installs", "A3 weekly actives", "AC1 reach_execute", "AC2 ship_check_ok", "C1 conversion"): + assert label in result.stdout + assert "300" in result.stdout + assert "9%" in result.stdout + + +def test_kpi_snapshot_unconfigured_exits_zero_with_not_configured_rows(): + result = _run_snapshot() + + assert result.returncode == 0, result.stderr + assert result.stdout.count("not configured") >= 4 + assert "C1 conversion" in result.stdout + assert "n/a (license telemetry not deployed)" in result.stdout diff --git a/tests/core/test_license.py b/tests/core/test_license.py new file mode 100644 index 0000000..4c8d894 --- /dev/null +++ b/tests/core/test_license.py @@ -0,0 +1,266 @@ +"""Tests for offline Atlas license parsing, verification, and persistence.""" + +import base64 +import ast +import json +import os +from pathlib import Path + +from prd_taskmaster import _ed25519 +from prd_taskmaster import license + + +REPO_ROOT = Path(__file__).resolve().parents[2] +VECTORS = json.loads((REPO_ROOT / "tests" / "license" / "test_vectors.json").read_text()) +GRACE_SECONDS = 14 * 24 * 60 * 60 + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +class TestEd25519Verify: + def test_accepts_rfc8032_section_7_1_vector_1(self): + public = bytes.fromhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + ) + message = b"" + signature = bytes.fromhex( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ) + + assert _ed25519.verify(public, message, signature) is True + + def test_rejects_tampered_rfc8032_signature(self): + public = bytes.fromhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + ) + signature = bytearray.fromhex( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ) + signature[0] ^= 1 + + assert _ed25519.verify(public, b"", bytes(signature)) is False + + def test_rejects_malformed_lengths(self): + public = bytes.fromhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + ) + signature = bytes.fromhex( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ) + + assert _ed25519.verify(public[:-1], b"", signature) is False + assert _ed25519.verify(public, b"", signature[:-1]) is False + + +class TestParseKey: + def test_valid_key_returns_payload_bytes_and_signature(self): + vector = VECTORS["vectors"][0] + + parsed = license.parse_key(vector["key"]) + + assert parsed["ok"] is True + assert parsed["payload"] == vector["payload"] + assert parsed["payload_bytes"] == json.dumps( + vector["payload"], + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + assert isinstance(parsed["signature"], bytes) + assert len(parsed["signature"]) == 64 + + def test_malformed_keys_return_invalid_result(self): + payload = { + "exp": 1717286400, + "iat": 1717196400, + "lid": "lic_bad", + "plan": "pro-monthly", + "sub": "0" * 64, + "v": 1, + } + payload_segment = _b64url(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()) + signature_segment = _b64url(b"x" * 64) + cases = [ + "BAD-" + payload_segment + "." + signature_segment, + "ATLAS-not*base64." + signature_segment, + "ATLAS-" + _b64url(b"not json") + "." + signature_segment, + "ATLAS-" + _b64url(json.dumps({"v": 1}).encode()) + "." + signature_segment, + "ATLAS-" + _b64url(json.dumps({**payload, "v": 2}, sort_keys=True, separators=(",", ":")).encode()) + "." + signature_segment, + ] + + for key in cases: + parsed = license.parse_key(key) + assert parsed["ok"] is False + assert parsed["status"] == "invalid" + assert parsed["detail"] + assert parsed["payload"] is None or isinstance(parsed["payload"], dict) + + +class TestSignatureVerification: + def test_fixture_signatures_verify_against_fixture_public_key(self, monkeypatch): + public_key = bytes.fromhex(VECTORS["public_key_hex"]) + monkeypatch.setattr(license, "PUBLIC_KEY", public_key) + + expected_by_name = { + "valid": True, + "expired": True, + "tampered-payload": False, + "signature-mismatch": False, + } + + for vector in VECTORS["vectors"]: + parsed = license.parse_key(vector["key"]) + assert parsed["ok"] is True + assert license.verify_signature( + parsed["payload_bytes"], + parsed["signature"], + license.PUBLIC_KEY, + ) is expected_by_name[vector["name"]] + + def test_verify_signature_returns_false_for_wrong_public_key(self): + parsed = license.parse_key(VECTORS["vectors"][0]["key"]) + + assert license.verify_signature( + parsed["payload_bytes"], + parsed["signature"], + b"\x00" * 32, + ) is False + + +class TestLicenseStatus: + def test_active_before_grace_window(self, monkeypatch): + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = VECTORS["vectors"][0] + now = vector["payload"]["exp"] - GRACE_SECONDS + + status = license.get_status(vector["key"], now=now) + + assert status["status"] == "active" + assert status["days_remaining"] is None + assert status["detail"] == "license active" + + def test_grace_after_period_end_and_before_expiry(self, monkeypatch): + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = VECTORS["vectors"][0] + now = vector["payload"]["exp"] - (7 * 24 * 60 * 60) + + status = license.get_status(vector["key"], now=now) + + assert status["status"] == "grace" + assert status["days_remaining"] == 7 + assert "grace" in status["detail"] + + def test_exp_is_end_of_grace_not_start_of_second_grace(self, monkeypatch): + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = VECTORS["vectors"][0] + + status = license.get_status(vector["key"], now=vector["payload"]["exp"] + 1) + + assert status["status"] == "expired" + assert status["days_remaining"] == 0 + assert "expired" in status["detail"] + + def test_invalid_signature_returns_invalid_status(self, monkeypatch): + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = next(v for v in VECTORS["vectors"] if v["name"] == "signature-mismatch") + + status = license.get_status(vector["key"], now=VECTORS["now"]) + + assert status["status"] == "invalid" + assert status["days_remaining"] is None + assert "signature" in status["detail"] + + def test_malformed_key_returns_invalid_status(self): + status = license.get_status("not-a-license-key", now=VECTORS["now"]) + + assert status["status"] == "invalid" + assert status["days_remaining"] is None + assert status["detail"] + + +class TestLicensePersistence: + def test_save_license_creates_private_license_file_and_loads_it(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = VECTORS["vectors"][0] + now = vector["payload"]["exp"] - GRACE_SECONDS + + result = license.save_license(vector["key"], now=now) + + assert result["ok"] is True + license_dir = tmp_path / ".atlas-ai" + license_file = license_dir / "license.json" + assert license_file.is_file() + if os.name == "posix": + assert (license_dir.stat().st_mode & 0o777) == 0o700 + assert (license_file.stat().st_mode & 0o777) == 0o600 + + loaded = license.load_license() + assert loaded is not None + assert loaded["ok"] is True + assert loaded["payload"] == vector["payload"] + assert loaded["key"] == vector["key"] + + def test_save_license_rejects_signature_mismatch(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + bad = next(v for v in VECTORS["vectors"] if v["name"] == "signature-mismatch") + + result = license.save_license(bad["key"], now=VECTORS["now"]) + + assert result["ok"] is False + assert result["status"] == "invalid" + assert not (tmp_path / ".atlas-ai" / "license.json").exists() + + def test_load_license_returns_none_for_missing_or_corrupt_file(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + assert license.load_license() is None + + license_dir = tmp_path / ".atlas-ai" + license_dir.mkdir() + (license_dir / "license.json").write_text("{not json") + + assert license.load_license() is None + + +class TestLicenseModuleSurface: + def test_license_modules_import_only_stdlib_and_package_modules(self): + allowed = { + "base64", + "hashlib", + "json", + "math", + "os", + "pathlib", + "re", + "time", + "typing", + "prd_taskmaster", + } + for relpath in ("prd_taskmaster/license.py", "prd_taskmaster/_ed25519.py"): + tree = ast.parse((REPO_ROOT / relpath).read_text()) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + + assert imported <= allowed + + def test_license_public_api_surface_is_explicit(self): + public = {name for name in vars(license) if not name.startswith("_")} + + assert public == { + "PUBLIC_KEY", + "parse_key", + "verify_signature", + "get_status", + "save_license", + "load_license", + } diff --git a/tests/core/test_license_activate.py b/tests/core/test_license_activate.py new file mode 100644 index 0000000..771924f --- /dev/null +++ b/tests/core/test_license_activate.py @@ -0,0 +1,99 @@ +"""Tests for the license-activate CLI command.""" + +import json +from argparse import Namespace +from pathlib import Path + +import pytest + +from prd_taskmaster.cli import DISPATCH, build_parser +from prd_taskmaster import license +from prd_taskmaster.license_activate import cmd_license_activate + + +REPO_ROOT = Path(__file__).resolve().parents[2] +VECTORS = json.loads((REPO_ROOT / "tests" / "license" / "test_vectors.json").read_text()) +GRACE_SECONDS = 14 * 24 * 60 * 60 + + +class TestLicenseActivateParser: + def test_parser_accepts_license_activate_key(self): + parser = build_parser() + + args = parser.parse_args(["license-activate", "ATLAS-example.signature"]) + + assert args.command == "license-activate" + assert args.license_key == "ATLAS-example.signature" + assert args.json is False + + def test_parser_accepts_json_escape_hatch(self): + parser = build_parser() + + args = parser.parse_args(["license-activate", "--json", "ATLAS-example.signature"]) + + assert args.command == "license-activate" + assert args.license_key == "ATLAS-example.signature" + assert args.json is True + + def test_parser_requires_license_key(self): + parser = build_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["license-activate"]) + + def test_dispatch_registers_license_activate_handler(self): + assert "license-activate" in DISPATCH + assert DISPATCH["license-activate"].__name__ == "cmd_license_activate" + + +class TestLicenseActivateCommand: + def test_valid_key_persists_and_renders_status_block(self, monkeypatch, tmp_path, capsys): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = VECTORS["vectors"][0] + now = vector["payload"]["exp"] - GRACE_SECONDS + monkeypatch.setattr(license._time, "time", lambda: now) + + cmd_license_activate(Namespace(license_key=vector["key"], json=False)) + + out = capsys.readouterr().out + assert "┌─ atlas ── license-activate ─┐" in out + assert "What happened: Atlas Pro license activated." in out + assert "Evidence:" in out + assert "plan pro-monthly" in out + assert "status active" in out + assert "Next: run `prd-taskmaster detect-capabilities` to confirm premium tier." in out + assert not out.lstrip().startswith("{") + assert license.load_license()["key"] == vector["key"] + + def test_grace_key_json_escape_hatch(self, monkeypatch, tmp_path, capsys): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + vector = VECTORS["vectors"][0] + now = vector["payload"]["exp"] - (7 * 24 * 60 * 60) + monkeypatch.setattr(license._time, "time", lambda: now) + + with pytest.raises(SystemExit) as exc: + cmd_license_activate(Namespace(license_key=vector["key"], json=True)) + + assert exc.value.code == 0 + data = json.loads(capsys.readouterr().out) + assert data["ok"] is True + assert data["status"] == "grace" + assert data["days_remaining"] == 7 + + def test_invalid_key_renders_status_block_and_exits_nonzero(self, monkeypatch, tmp_path, capsys): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + bad = next(v for v in VECTORS["vectors"] if v["name"] == "signature-mismatch") + + with pytest.raises(SystemExit) as exc: + cmd_license_activate(Namespace(license_key=bad["key"], json=False)) + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "┌─ atlas ── license-activate ─┐" in out + assert "What happened: Atlas Pro license was not activated." in out + assert "status invalid" in out + assert "signature mismatch" in out + assert not (tmp_path / ".atlas-ai" / "license.json").exists() diff --git a/tests/core/test_license_refresh.py b/tests/core/test_license_refresh.py new file mode 100644 index 0000000..bfbf0c1 --- /dev/null +++ b/tests/core/test_license_refresh.py @@ -0,0 +1,237 @@ +"""Tests for silent Atlas license refresh.""" + +import json +import socket +import urllib.error +import urllib.request +from pathlib import Path + +from prd_taskmaster import capabilities +from prd_taskmaster import license + + +REPO_ROOT = Path(__file__).resolve().parents[2] +VECTORS = json.loads((REPO_ROOT / "tests" / "license" / "test_vectors.json").read_text()) +VALID = next(v for v in VECTORS["vectors"] if v["name"] == "valid") +BAD_SIGNATURE = next(v for v in VECTORS["vectors"] if v["name"] == "signature-mismatch") +DAY = 24 * 60 * 60 + + +class _Response: + def __init__(self, body: dict): + self._body = json.dumps(body).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return self._body + + +def _activate_fixture_license(monkeypatch, tmp_path, *, now: int) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + result = license.save_license(VALID["key"], now=now) + assert result["ok"] is True + + +def test_should_refresh_only_for_active_or_grace_license_within_window(monkeypatch): + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + parsed = license.parse_key(VALID["key"]) + exp = VALID["payload"]["exp"] + + assert license.should_refresh(parsed, now=exp - (20 * DAY)) is True + assert license.should_refresh(parsed, now=exp - (22 * DAY)) is False + assert license.should_refresh(parsed, now=exp - (7 * DAY)) is True + assert license.should_refresh(parsed, now=exp + 1) is False + assert license.should_refresh(license.parse_key(BAD_SIGNATURE["key"]), now=exp - (7 * DAY)) is False + + +def test_should_refresh_respects_last_attempt_throttle(monkeypatch): + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + exp = VALID["payload"]["exp"] + now = exp - (7 * DAY) + parsed = license.parse_key(VALID["key"]) + + throttled = dict(parsed, last_refresh_attempt=now - 60) + stale = dict(parsed, last_refresh_attempt=now - DAY - 1) + + assert license.should_refresh(throttled, now=now) is False + assert license.should_refresh(stale, now=now) is True + + +def test_refresh_if_needed_posts_lid_verifies_and_saves(monkeypatch, tmp_path): + now = VALID["payload"]["exp"] - (7 * DAY) + _activate_fixture_license(monkeypatch, tmp_path, now=now) + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["body"] = json.loads(request.data.decode("utf-8")) + captured["content_type"] = request.headers.get("Content-type") + captured["timeout"] = timeout + return _Response({"ok": True, "key": VALID["key"]}) + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = license.refresh_if_needed(now=now) + + assert result == "refreshed" + assert captured == { + "url": "https://api.atlas-ai.au/license/refresh", + "method": "POST", + "body": {"lid": VALID["payload"]["lid"]}, + "content_type": "application/json", + "timeout": 5, + } + stored = json.loads((tmp_path / ".atlas-ai" / "license.json").read_text()) + assert stored["key"] == VALID["key"] + assert stored["last_refresh_attempt"] == now + + +def test_refresh_rejects_badly_signed_wire_key(monkeypatch, tmp_path): + now = VALID["payload"]["exp"] - (7 * DAY) + _activate_fixture_license(monkeypatch, tmp_path, now=now) + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda request, timeout: _Response({"ok": True, "key": BAD_SIGNATURE["key"]}), + ) + + result = license.refresh_if_needed(now=now) + + stored = json.loads((tmp_path / ".atlas-ai" / "license.json").read_text()) + assert result == "failed" + assert stored["key"] == VALID["key"] + + +def test_refresh_failure_paths_are_non_fatal(monkeypatch, tmp_path): + now = VALID["payload"]["exp"] - (7 * DAY) + failures = [ + urllib.error.HTTPError( + "https://api.atlas-ai.au/license/refresh", + 403, + "Forbidden", + {}, + None, + ), + socket.timeout("timed out"), + urllib.error.URLError("offline"), + ConnectionError("offline"), + ] + + for index, failure in enumerate(failures): + home = tmp_path / str(index) + _activate_fixture_license(monkeypatch, home, now=now) + + def fake_urlopen(request, timeout, exc=failure): + raise exc + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = license.refresh_if_needed(now=now) + + stored = json.loads((home / ".atlas-ai" / "license.json").read_text()) + assert result == "failed" + assert stored["key"] == VALID["key"] + + +def test_refresh_opt_out_skips_before_network_or_attempt_file(monkeypatch, tmp_path): + now = VALID["payload"]["exp"] - (7 * DAY) + _activate_fixture_license(monkeypatch, tmp_path, now=now) + monkeypatch.setenv("ATLAS_TELEMETRY", "0") + + def fail_if_called(request, timeout): + raise AssertionError("refresh should not call the network when opted out") + + monkeypatch.setattr(urllib.request, "urlopen", fail_if_called) + + result = license.refresh_if_needed(now=now) + + stored = json.loads((tmp_path / ".atlas-ai" / "license.json").read_text()) + assert result == "skipped" + assert "last_refresh_attempt" not in stored + + +def test_capabilities_refreshes_once_when_needed(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + calls = [] + + monkeypatch.setattr( + capabilities, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + capabilities.license, + "get_status", + lambda: {"status": "active", "days_remaining": None, "detail": "license active"}, + ) + monkeypatch.setattr(capabilities.license, "load_license", lambda: {"payload": VALID["payload"], "ok": True}) + monkeypatch.setattr(capabilities.license, "should_refresh", lambda parsed: True) + monkeypatch.setattr(capabilities.license, "refresh_if_needed", lambda: calls.append("refresh")) + + result = capabilities.run_detect_capabilities() + + assert calls == ["refresh"] + assert result["tier"] == "premium" + + +def test_capabilities_skips_refresh_when_not_needed(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + calls = [] + + monkeypatch.setattr( + capabilities, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + capabilities.license, + "get_status", + lambda: {"status": "active", "days_remaining": None, "detail": "license active"}, + ) + monkeypatch.setattr(capabilities.license, "load_license", lambda: {"payload": VALID["payload"], "ok": True}) + monkeypatch.setattr(capabilities.license, "should_refresh", lambda parsed: False) + monkeypatch.setattr(capabilities.license, "refresh_if_needed", lambda: calls.append("refresh")) + + capabilities.run_detect_capabilities() + + assert calls == [] + + +def test_capabilities_survives_refresh_exception(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + monkeypatch.setattr( + capabilities, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + monkeypatch.setattr( + capabilities.license, + "get_status", + lambda: {"status": "active", "days_remaining": None, "detail": "license active"}, + ) + monkeypatch.setattr(capabilities.license, "load_license", lambda: {"payload": VALID["payload"], "ok": True}) + monkeypatch.setattr(capabilities.license, "should_refresh", lambda parsed: True) + + def boom(): + raise RuntimeError("refresh exploded") + + monkeypatch.setattr(capabilities.license, "refresh_if_needed", boom) + + result = capabilities.run_detect_capabilities() + + assert result["ok"] is True + assert result["tier"] == "premium" diff --git a/tests/core/test_parallel.py b/tests/core/test_parallel.py new file mode 100644 index 0000000..2a1bbd8 --- /dev/null +++ b/tests/core/test_parallel.py @@ -0,0 +1,111 @@ +"""Roundtrip tests for the parallel research bridge via the unified CLI.""" + +import json +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / "script.py" + + +def _run(cwd, *args): + proc = subprocess.run( + [sys.executable, str(SCRIPT), *args], + cwd=cwd, capture_output=True, text=True, + ) + return proc.returncode, proc.stdout, proc.stderr + + +def _seed_tasks(tmp_path): + tasks_dir = tmp_path / ".taskmaster" / "tasks" + tasks_dir.mkdir(parents=True) + payload = { + "master": { + "tasks": [ + {"id": 1, "title": "Build auth", "description": "API key", + "details": "key check", "testStrategy": "unit", + "status": "pending", "subtasks": []}, + {"id": 2, "title": "List endpoint", "description": "GET /widgets", + "details": "paginated", "testStrategy": "integration", + "status": "pending", "dependencies": [1], + "subtasks": [{"id": 1, "title": "a", "description": "", "details": ""}, + {"id": 2, "title": "b", "description": "", "details": ""}]}, + ] + } + } + (tasks_dir / "tasks.json").write_text(json.dumps(payload)) + return tasks_dir / "tasks.json" + + +def test_parallel_plan_emits_packets(tmp_path): + _seed_tasks(tmp_path) + code, out, err = _run(tmp_path, "parallel-plan") + assert code == 0, err + data = json.loads(out) + assert data["ok"] is True + assert data["count"] == 2 + assert {p["id"] for p in data["packets"]} == {1, 2} + assert "RESEARCH" in data["packets"][0]["prompt"] + + +def test_parallel_plan_missing_only_skips_expanded(tmp_path): + _seed_tasks(tmp_path) + code, out, _ = _run(tmp_path, "parallel-plan", "--missing-only") + data = json.loads(out) + # task 2 already has >=2 subtasks, so only task 1 needs research + assert data["count"] == 1 + assert data["packets"][0]["id"] == 1 + + +def test_parallel_apply_roundtrip(tmp_path): + tasks_file = _seed_tasks(tmp_path) + results = [ + {"id": 1, "complexityScore": 6, "recommendedSubtasks": 3, + "reasoning": "moderate", "researchNotes": "use hmac", + "subtasks": [ + {"title": "Parse header", "description": "x", "details": "y", "dependencies": []}, + {"title": "Validate key", "description": "x", "details": "y", "dependencies": [1]}, + ]}, + ] + res_file = tmp_path / "results.json" + res_file.write_text(json.dumps(results)) + + code, out, err = _run(tmp_path, "parallel-apply", "--input", str(res_file)) + assert code == 0, err + data = json.loads(out) + assert data["ok"] is True + assert data["applied"] == [1] + + # tasks.json now has the new subtasks merged for task 1 + merged = json.loads(tasks_file.read_text()) + task1 = merged["master"]["tasks"][0] + assert len(task1["subtasks"]) == 2 + assert task1["subtasks"][0]["title"] == "Parse header" + assert "use hmac" in task1["details"] + + # a complexity report was written + report = tmp_path / ".taskmaster" / "reports" / "task-complexity-report.json" + assert report.is_file() + rep = json.loads(report.read_text()) + assert rep["complexityAnalysis"][0]["taskId"] == 1 + + +def test_parallel_extract_inject_roundtrip(tmp_path): + tasks_file = _seed_tasks(tmp_path) + flat = tmp_path / "flat.json" + code, out, err = _run(tmp_path, "parallel-extract", "--output", str(flat)) + assert code == 0, err + assert json.loads(out)["count"] == 2 + + # mutate the flat file then inject back + data = json.loads(flat.read_text()) + data["tasks"][0]["title"] = "Build auth (edited)" + flat.write_text(json.dumps(data)) + + code, out, err = _run(tmp_path, "parallel-inject", "--input", str(flat)) + assert code == 0, err + assert json.loads(out)["injected"] == 2 + + merged = json.loads(tasks_file.read_text()) + assert merged["master"]["tasks"][0]["title"] == "Build auth (edited)" diff --git a/tests/core/test_pipeline_smoke.py b/tests/core/test_pipeline_smoke.py new file mode 100644 index 0000000..f2f4cef --- /dev/null +++ b/tests/core/test_pipeline_smoke.py @@ -0,0 +1,75 @@ +"""Smoke tests for the imported stateful core: pipeline state machine, +gate checks, and the ship-check gate logic. + +Each test runs in an isolated tmp_path via monkeypatch.chdir so the relative +path constants in pipeline.py / shipcheck.py resolve under the temp dir. +""" + +from prd_taskmaster import pipeline, shipcheck + + +def test_fresh_current_phase_is_none(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state = pipeline.current_phase() + assert state["ok"] is True + assert state["current_phase"] is None + assert state["phases_completed"] == [] + + +def test_advance_phase_none_to_setup_succeeds(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = pipeline.advance_phase(None, "SETUP", {"validate_setup": {"ready": True}}) + assert result["ok"] is True + assert result["new_phase"] == "SETUP" + # The state file now reflects SETUP. + assert pipeline.current_phase()["current_phase"] == "SETUP" + + +def test_advance_phase_stale_expected_current_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # Fresh state is None; claiming the current phase is DISCOVER is stale. + result = pipeline.advance_phase("DISCOVER", "GENERATE", {}) + assert result["ok"] is False + assert "stale" in result["error"] + assert result["actual"] is None + + +def test_advance_phase_illegal_transition_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # From None only None->SETUP is legal; None->GENERATE is illegal. + result = pipeline.advance_phase(None, "GENERATE", {}) + assert result["ok"] is False + assert "illegal transition" in result["error"] + assert result["legal"] == ["SETUP"] + + +def test_check_gate_generate_passes_when_evidence_good(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = pipeline.check_gate("GENERATE", { + "validation_grade": "GOOD", + "task_count": 5, + "subtask_coverage": 1.0, + }) + assert result["gate_passed"] is True + assert result["violations"] == [] + + +def test_check_gate_generate_fails_on_needs_work(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = pipeline.check_gate("GENERATE", { + "validation_grade": "NEEDS_WORK", + "task_count": 5, + "subtask_coverage": 1.0, + }) + assert result["gate_passed"] is False + assert any("validation_grade" in v for v in result["violations"]) + + +def test_run_ship_check_empty_dir_not_passed_no_exit(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # Must not raise / not sys.exit; gate 1 (pipeline.json) fails in an empty dir. + result = shipcheck.run_ship_check(cwd=str(tmp_path)) + assert result["passed"] is False + assert result["exit_code"] == 1 + assert any("pipeline.json" in f for f in result["failures"]) + assert result["stdout"] is None diff --git a/tests/core/test_pipeline_state.py b/tests/core/test_pipeline_state.py new file mode 100644 index 0000000..bd6cfe8 --- /dev/null +++ b/tests/core/test_pipeline_state.py @@ -0,0 +1,137 @@ +import pytest +from pathlib import Path + +from prd_taskmaster.pipeline import current_phase, advance_phase, check_gate, preflight, ATLAS_AI_DIR + +PHASES = ["SETUP", "DISCOVER", "GENERATE", "HANDOFF", "EXECUTE"] + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_current_phase_none_when_no_state_file(project): + result = current_phase() + assert result["current_phase"] is None + assert result["phases_completed"] == [] + + +def test_advance_phase_from_none_to_SETUP(project): + result = advance_phase(expected_current=None, target="SETUP", evidence={"installed": True}) + assert result["ok"] is True + assert result["new_phase"] == "SETUP" + after = current_phase() + assert after["current_phase"] == "SETUP" + + +def test_advance_phase_rejects_illegal_transition(project): + advance_phase(expected_current=None, target="SETUP", evidence={}) + result = advance_phase(expected_current="SETUP", target="HANDOFF", evidence={}) + assert result["ok"] is False + assert "illegal transition" in result["error"].lower() + + +def test_advance_phase_CAS_rejects_stale_expected_current(project): + advance_phase(expected_current=None, target="SETUP", evidence={}) + # Concurrent session advances to DISCOVER + advance_phase(expected_current="SETUP", target="DISCOVER", evidence={}) + # First session still thinks we're in SETUP — should fail + result = advance_phase(expected_current="SETUP", target="DISCOVER", evidence={}) + assert result["ok"] is False + assert "stale" in result["error"].lower() or "cas" in result["error"].lower() + + +def test_check_gate_SETUP_requires_validate_setup_ready(project): + missing = check_gate(phase="SETUP", evidence={}) + assert missing["gate_passed"] is False + assert "validate_setup" in missing["violations"][0].lower() or "ready" in missing["violations"][0].lower() + + ok = check_gate(phase="SETUP", evidence={"validate_setup": {"ready": True, "critical_failures": 0}}) + assert ok["gate_passed"] is True + + +def test_preflight_returns_recommended_action(project): + result = preflight() + assert "recommended_action" in result + assert result["recommended_action"] in [ + "recover", + "run_setup", + "generate_prd", + "parse_prd", + "resume", + "resume_existing_tasks", + "select_taskmaster_tag", + "complete", + ] + + +def test_preflight_reads_standard_taskmaster_state_and_recommends_pending_tag(project): + """Existing projects store TaskMaster state in .taskmaster, not .atlas-ai/taskmaster.""" + tm = project / ".taskmaster" + (tm / "docs").mkdir(parents=True) + (tm / "tasks").mkdir(parents=True) + (tm / "docs" / "prd.md").write_text("# Existing PRD\n") + (tm / "state.json").write_text( + '{"currentTag":"production-agent","migrationNoticeShown":true}' + ) + (tm / "tasks" / "tasks.json").write_text( + """ + { + "master": { + "tasks": [ + {"id": 1, "title": "done", "status": "done"}, + {"id": 2, "title": "next", "status": "pending"} + ] + }, + "production-agent": { + "tasks": [ + {"id": 1, "title": "done", "status": "done"} + ] + } + } + """ + ) + + result = preflight() + + assert result["has_taskmaster"] is True + assert result["taskmaster_root"] == ".taskmaster" + assert result["tasks_path"] == ".taskmaster/tasks/tasks.json" + assert result["prd_path"] == ".taskmaster/docs/prd.md" + assert result["current_tag"] == "production-agent" + assert result["task_count"] == 1 + assert result["pending_task_count"] == 0 + assert result["tag_counts"]["master"] == {"total": 2, "pending": 1, "done": 1} + assert result["recommended_tag"] == "master" + assert result["recommended_action"] == "select_taskmaster_tag" + + +def test_preflight_recommends_resume_when_current_tag_has_pending_tasks(project): + tm = project / ".taskmaster" + (tm / "docs").mkdir(parents=True) + (tm / "tasks").mkdir(parents=True) + (tm / "docs" / "prd.md").write_text("# Existing PRD\n") + (tm / "state.json").write_text('{"currentTag":"master"}') + (tm / "tasks" / "tasks.json").write_text( + '{"master":{"tasks":[{"id":11,"title":"next","status":"pending"}]}}' + ) + + result = preflight() + + assert result["current_tag"] == "master" + assert result["task_count"] == 1 + assert result["pending_task_count"] == 1 + assert result["recommended_tag"] == "master" + assert result["recommended_action"] == "resume_existing_tasks" + + +def test_pipeline_json_written_atomically(project, monkeypatch): + """Verify pipeline.json write goes through atomic_write.""" + advance_phase(expected_current=None, target="SETUP", evidence={"installed": True}) + pipeline_file = project / ".atlas-ai" / "state" / "pipeline.json" + assert pipeline_file.exists() + # no leftover .tmp files + tmp_files = list(pipeline_file.parent.glob("*.tmp.*")) + assert tmp_files == [], f"Leftover tmp files: {tmp_files}" diff --git a/tests/core/test_quickstart_walkthrough.py b/tests/core/test_quickstart_walkthrough.py new file mode 100644 index 0000000..ee34087 --- /dev/null +++ b/tests/core/test_quickstart_walkthrough.py @@ -0,0 +1,125 @@ +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content) + path.chmod(0o755) + + +def _make_fake_repo(tmp_path: Path) -> Path: + repo = tmp_path / "fake-repo" + (repo / "prd_taskmaster").mkdir(parents=True) + (repo / "SKILL.md").write_text("# fake skill\n") + (repo / "script.py").write_text("print('atlas')\n") + (repo / "prd_taskmaster" / "__init__.py").write_text("") + (repo / "prd_taskmaster" / "telemetry.py").write_text("def send_event(event):\n pass\n") + (repo / "install.sh").write_text("#!/usr/bin/env bash\n") + return repo + + +def _make_fake_bin(tmp_path: Path, fake_repo: Path) -> tuple[Path, Path]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + npm_log = tmp_path / "npm.log" + npm_log.write_text("") + bash_path = shutil.which("bash") or "/bin/bash" + for cmd in ("cat", "chmod", "cp", "date", "dirname", "head", "mkdir", "mktemp", "rm"): + resolved = shutil.which(cmd) + if resolved: + (bin_dir / cmd).symlink_to(resolved) + _write_executable( + bin_dir / "git", + f"#!{bash_path}\n" + "set -euo pipefail\n" + "if [[ \"${1:-}\" == \"clone\" ]]; then\n" + " dest=\"${@: -1}\"\n" + " mkdir -p \"$dest\"\n" + f" cp -R {fake_repo}/. \"$dest/\"\n" + " exit 0\n" + "fi\n" + "echo unexpected git invocation: $* >&2\n" + "exit 1\n", + ) + _write_executable( + bin_dir / "npm", + f"#!{bash_path}\n" + "set -euo pipefail\n" + "printf '%s\\n' \"$*\" >> \"$NPM_LOG\"\n" + "if [[ \"$*\" == \"install -g task-master-ai\" ]]; then\n" + " cat > \"$(dirname \"$0\")/task-master-ai\" <<'TMEOF'\n" + f"#!{bash_path}\n" + "if [[ \"${1:-}\" == \"--version\" ]]; then echo task-master-ai 1.2.3; else echo task-master-ai ok; fi\n" + "TMEOF\n" + " chmod +x \"$(dirname \"$0\")/task-master-ai\"\n" + "fi\n", + ) + return bin_dir, npm_log + + +def test_install_sh_installs_taskmaster_peer_and_prints_first_run_box(tmp_path): + fake_repo = _make_fake_repo(tmp_path) + bin_dir, npm_log = _make_fake_bin(tmp_path, fake_repo) + home = tmp_path / "home" + home.mkdir() + env = os.environ.copy() + env.update( + { + "ATLAS_TELEMETRY": "0", + "CI": "1", + "HOME": str(home), + "NPM_LOG": str(npm_log), + "PATH": str(bin_dir), + "SKILL_DIR": str(home / "skill"), + } + ) + + result = subprocess.run( + [shutil.which("bash") or "/bin/bash", str(REPO_ROOT / "install.sh")], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "install -g task-master-ai" in npm_log.read_text() + assert "✓ Peer dependency: task-master-ai" in result.stdout + assert "✓ Self-test: task-master-ai --version" in result.stdout + assert "Done in " in result.stdout + assert 'Try: /atlas or say "I want to build..."' in result.stdout + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required for walkthrough") +def test_quickstart_walkthrough_script_runs_deterministic_path(): + script = REPO_ROOT / "tests" / "scripts" / "quickstart-walkthrough.sh" + + result = subprocess.run( + [shutil.which("bash") or "/bin/bash", str(script)], + cwd=REPO_ROOT, + env={**os.environ, "QUICKSTART_LIVE": "0"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "quickstart deterministic path ok" in result.stdout + + +def test_readme_quickstart_matches_installer_claims(): + readme = (REPO_ROOT / "README.md").read_text() + install = (REPO_ROOT / "install.sh").read_text() + + assert 'Try: /atlas or say "I want to build..."' in readme + assert "sets up the task-master-ai peer" in readme + assert "✓ Peer dependency: task-master-ai" in install + for flag in ("--check-update", "--no-telemetry", "--version", "--help"): + assert flag in install diff --git a/tests/core/test_render.py b/tests/core/test_render.py new file mode 100644 index 0000000..22917dd --- /dev/null +++ b/tests/core/test_render.py @@ -0,0 +1,80 @@ +from prd_taskmaster.render import ( + SYMBOLS, + USER_PHASES, + render_gate, + render_phase_header, + render_status_block, +) + + +def test_render_phase_header_uses_atlas_phase_bar(): + header = render_phase_header(1, "PREFLIGHT") + + assert header.startswith("┌─ atlas ── PHASE 1/4: PREFLIGHT ") + assert header.endswith("┐") + assert len(header) <= 64 + + +def test_render_phase_header_maps_internal_pipeline_phases(): + assert USER_PHASES["SETUP"] == ("PREFLIGHT", 1) + assert USER_PHASES["DISCOVER"] == ("DISCOVERY", 2) + assert USER_PHASES["GENERATE"] == ("GENERATE", 3) + assert USER_PHASES["HANDOFF"] == ("HANDOFF", 4) + assert USER_PHASES["EXECUTE"] is None + + assert "PHASE 2/4: DISCOVERY" in render_phase_header("DISCOVER") + assert render_phase_header("EXECUTE") == "" + + +def test_render_gate_passed_has_summary_and_no_raw_json(): + output = render_gate({ + "gate_passed": True, + "phase": "GENERATE", + "summary": "GOOD score with 12 parsed tasks", + "violations": [], + }) + + assert output == "✓ Gate passed: GOOD score with 12 parsed tasks" + assert "{" not in output + assert "}" not in output + assert '"gate_passed"' not in output + + +def test_render_gate_blocked_lists_violation_and_one_fix_without_raw_json(): + output = render_gate({ + "gate_passed": False, + "phase": "SETUP", + "violations": ["validate_setup must report ready=true with 0 critical failures"], + }) + + assert output.splitlines()[0] == ( + "✗ Gate blocked: validate_setup must report ready=true with 0 critical failures" + ) + assert output.splitlines()[1].startswith("Fix: ") + assert "{" not in output + assert "}" not in output + + +def test_render_status_block_orders_what_evidence_next_step(): + output = render_status_block( + "SETUP", + what_happened="Preflight checked local tools.", + evidence=["✓ Python 3 available", "○ TaskMaster not installed"], + next_step="Install task-master-ai, then run /atlas again.", + ) + + lines = output.splitlines() + assert lines[0].startswith("┌─ atlas ── PHASE 1/4: PREFLIGHT ") + assert lines[1] == "What happened: Preflight checked local tools." + assert lines[2] == "Evidence:" + assert lines[3] == " ✓ Python 3 available" + assert lines[4] == " ○ TaskMaster not installed" + assert lines[5] == "Next: Install task-master-ai, then run /atlas again." + + +def test_symbols_include_unicode_and_ascii_fallbacks(): + assert SYMBOLS["pass"] == ("✓", "PASS") + assert SYMBOLS["blocked"] == ("✗", "BLOCKED") + assert SYMBOLS["warn"] == ("⚠", "WARN") + assert SYMBOLS["pending"] == ("○", "PENDING") + assert SYMBOLS["next"] == ("▶", "NEXT") diff --git a/tests/core/test_ship_check.py b/tests/core/test_ship_check.py new file mode 100644 index 0000000..39c8e4a --- /dev/null +++ b/tests/core/test_ship_check.py @@ -0,0 +1,176 @@ +"""Tests for skel/ship-check.py — the deterministic SHIP_CHECK_OK emitter. + +Rewritten against the LIVE gate contract (the plugin's old test asserted a +phantom schema: state=="complete", evidence_files on tasks, tasks under +.atlas-ai/taskmaster/, plan at .atlas-ai/ralph-loop-prompt.md — none of which +the live skel/ship-check.py uses). + +Live contract (see skel/ship-check.py docstring): + Gate 1 — .atlas-ai/state/pipeline.json has current_phase == "EXECUTE" + (no `state` field). Missing → stderr "pipeline.json missing", fail. + Gate 2 — .taskmaster/tasks/tasks.json: every master.tasks[].status == "done". + Gate 3 — for each task id, a CDD card at .atlas-ai/cdd/task-<id>.json (or a + combined card whose filename contains the id). + Gate 4 — plan at .taskmaster/docs/plan.md OR docs/superpowers/plans/*.md. + Gate 5 (HARD) — no non-zero "Exit status N" in any .atlas-ai/evidence/ file. + Bypass via --override SHIP_CHECK_OVERRIDE_ADMIN. + Success → stdout exactly "SHIP_CHECK_OK\n" (+ " [OVERRIDE]"), exit 0. + Failure → nothing on stdout, FAIL detail on stderr, exit 1. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from prd_taskmaster.shipcheck import run_ship_check + +REPO_ROOT = Path(__file__).resolve().parents[2] +SHIP_CHECK = REPO_ROOT / "skel" / "ship-check.py" + + +def _run(cwd: Path, *extra: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(SHIP_CHECK), *extra], + cwd=str(cwd), + capture_output=True, + text=True, + check=False, + ) + + +def _green(tmp_path: Path) -> None: + """Build an all-gates-green project tree under tmp_path.""" + atlas = tmp_path / ".atlas-ai" + (atlas / "state").mkdir(parents=True) + (atlas / "state" / "pipeline.json").write_text( + json.dumps({"current_phase": "EXECUTE"}) + ) + tm = tmp_path / ".taskmaster" / "tasks" + tm.mkdir(parents=True) + (tm / "tasks.json").write_text( + json.dumps( + { + "master": { + "tasks": [ + {"id": 1, "status": "done"}, + {"id": 2, "status": "done"}, + ] + } + } + ) + ) + cdd = atlas / "cdd" + cdd.mkdir(parents=True) + (cdd / "task-1.json").write_text(json.dumps({"id": 1})) + (cdd / "task-2.json").write_text(json.dumps({"id": 2})) + docs = tmp_path / ".taskmaster" / "docs" + docs.mkdir(parents=True) + (docs / "plan.md").write_text("# Plan\n") + + +def test_ship_check_fails_on_empty_state(tmp_path: Path) -> None: + r = _run(tmp_path) + assert r.returncode != 0 + assert "SHIP_CHECK_OK" not in r.stdout + assert "pipeline.json missing" in r.stderr + + +def test_ship_check_passes_on_all_gates_green(tmp_path: Path) -> None: + _green(tmp_path) + r = _run(tmp_path) + assert r.returncode == 0, f"stderr={r.stderr!r} stdout={r.stdout!r}" + assert "SHIP_CHECK_OK" in r.stdout + + +def test_ship_check_fails_when_task_not_done(tmp_path: Path) -> None: + """Gate 2: a task with status != done blocks the gate.""" + _green(tmp_path) + tasks_path = tmp_path / ".taskmaster" / "tasks" / "tasks.json" + tasks_path.write_text( + json.dumps( + { + "master": { + "tasks": [ + {"id": 1, "status": "done"}, + {"id": 2, "status": "in-progress"}, + ] + } + } + ) + ) + r = _run(tmp_path) + assert r.returncode != 0 + assert "SHIP_CHECK_OK" not in r.stdout + assert "task 2 not done" in r.stderr + + +def test_ship_check_fails_when_done_task_has_no_cdd_card(tmp_path: Path) -> None: + """Gate 3: a done task without a CDD card blocks the gate.""" + _green(tmp_path) + # Remove task-2's CDD card. + (tmp_path / ".atlas-ai" / "cdd" / "task-2.json").unlink() + r = _run(tmp_path) + assert r.returncode != 0 + assert "SHIP_CHECK_OK" not in r.stdout + assert "task 2: no CDD card" in r.stderr + + +def test_ship_check_gate5_blocks_on_nonzero_exit_and_override_passes(tmp_path: Path) -> None: + """Gate 5 (HARD): a non-zero 'Exit status N' in evidence blocks; the + override token bypasses it.""" + _green(tmp_path) + evidence = tmp_path / ".atlas-ai" / "evidence" + evidence.mkdir(parents=True) + (evidence / "run.log").write_text("pnpm test\nExit status 1\n") + + # Without override → blocked. + r = _run(tmp_path) + assert r.returncode != 0 + assert "SHIP_CHECK_OK" not in r.stdout + assert "Exit status 1" in r.stderr + + # With the override token → passes, OVERRIDE suffix on stdout. + r2 = _run(tmp_path, "--override", "SHIP_CHECK_OVERRIDE_ADMIN") + assert r2.returncode == 0, f"stderr={r2.stderr!r} stdout={r2.stdout!r}" + assert "SHIP_CHECK_OK" in r2.stdout + assert "[OVERRIDE]" in r2.stdout + + +# ─── Python API agreement (prd_taskmaster.shipcheck.run_ship_check) ────────── + + +def test_run_ship_check_api_agrees_on_green(tmp_path: Path, monkeypatch) -> None: + """The importable run_ship_check() must agree with the standalone script + on the all-green case.""" + _green(tmp_path) + monkeypatch.chdir(tmp_path) + result = run_ship_check() + assert result["passed"] is True, f"failures={result['failures']!r}" + assert result["exit_code"] == 0 + assert result["stdout"] == "SHIP_CHECK_OK" + assert result["failures"] == [] + + +def test_run_ship_check_api_agrees_on_empty(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + result = run_ship_check() + assert result["passed"] is False + assert result["exit_code"] == 1 + assert result["stdout"] is None + assert any("pipeline.json missing" in f for f in result["failures"]) + + +def test_ship_check_passes_with_flat_tasks_format(tmp_path): + """Gate 2 must accept the flat {"tasks": [...]} format the engine itself + produces in Manual Mechanics Mode (dogfood finding, 2026-06-11).""" + _green(tmp_path) # tagged all-green baseline... + # ...then overwrite tasks.json with the FLAT format + (tmp_path / ".taskmaster" / "tasks" / "tasks.json").write_text(json.dumps( + {"tasks": [{"id": 1, "status": "done"}, {"id": 2, "status": "done"}]} + )) + r = _run(tmp_path) + assert r.returncode == 0, f"stderr={r.stderr!r}" + assert "SHIP_CHECK_OK" in r.stdout diff --git a/tests/core/test_taskmaster_wrapper.py b/tests/core/test_taskmaster_wrapper.py new file mode 100644 index 0000000..77829d7 --- /dev/null +++ b/tests/core/test_taskmaster_wrapper.py @@ -0,0 +1,13 @@ +from prd_taskmaster.taskmaster import init_taskmaster, _build_env + + +def test_build_env_sets_project_root(): + env = _build_env() + assert "TASK_MASTER_PROJECT_ROOT" not in env + + +def test_init_taskmaster_graceful_on_missing_binary(monkeypatch): + monkeypatch.setenv("PATH", "/nonexistent") + result = init_taskmaster() + assert result["ok"] is False + assert "not found" in result["error"].lower() or "task-master" in result["error"].lower() diff --git a/tests/core/test_validation_grade.py b/tests/core/test_validation_grade.py new file mode 100644 index 0000000..8eef5a0 --- /dev/null +++ b/tests/core/test_validation_grade.py @@ -0,0 +1,20 @@ +"""Minimal coverage for prd_taskmaster.validation.run_validate_prd. + +The plugin's test_validation.py (14-check) was DROPPED — it asserted the +plugin's now-removed validation.py. The package's skill validation (13-check) +is canonical and is exercised by tests/core/test_cli.py. This adds one direct +assertion that run_validate_prd returns a graded dict. +""" +from prd_taskmaster.validation import run_validate_prd + + +def test_run_validate_prd_returns_graded_dict(tmp_path): + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n\n## Executive Summary\nA test PRD.\n") + result = run_validate_prd(str(prd)) + assert isinstance(result, dict) + assert result["ok"] is True + assert "grade" in result + assert "checks" in result + assert isinstance(result["checks"], list) and len(result["checks"]) > 0 + assert "score" in result and "percentage" in result diff --git a/tests/install/test_install_telemetry.sh b/tests/install/test_install_telemetry.sh new file mode 100644 index 0000000..18a1a7b --- /dev/null +++ b/tests/install/test_install_telemetry.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT_UNDER_TEST="${SCRIPT_UNDER_TEST:-${ROOT}/install.sh}" +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/atlas-install-telemetry.XXXXXX")" +BASH_BIN="$(command -v bash)" + +cleanup() { + rm -rf "${WORKDIR}" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +make_fake_repo() { + local repo="${WORKDIR}/fake-repo" + mkdir -p "${repo}/prd_taskmaster" + printf '# fake skill\n' > "${repo}/SKILL.md" + printf 'print("atlas")\n' > "${repo}/script.py" + printf '' > "${repo}/prd_taskmaster/__init__.py" + printf 'def send_event(event):\n pass\n' > "${repo}/prd_taskmaster/telemetry.py" + printf '#!/usr/bin/env bash\n' > "${repo}/install.sh" + chmod +x "${repo}/install.sh" + printf '%s\n' "${repo}" +} + +make_bin_dir() { + local bin_dir="$1" + local fake_repo="$2" + mkdir -p "${bin_dir}" + for cmd in bash cp mkdir mktemp rm chmod head date cat; do + if command -v "${cmd}" >/dev/null 2>&1; then + ln -sf "$(command -v "${cmd}")" "${bin_dir}/${cmd}" + fi + done + cat > "${bin_dir}/git" <<EOF +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "clone" ]]; then + dest="\${@: -1}" + mkdir -p "\${dest}" + cp -R "${fake_repo}/." "\${dest}/" + exit 0 +fi +echo "unexpected git invocation: \$*" >&2 +exit 1 +EOF + chmod +x "${bin_dir}/git" + cat > "${bin_dir}/task-master-ai" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + echo "task-master-ai 1.2.3" +else + echo "task-master-ai ok" +fi +EOF + chmod +x "${bin_dir}/task-master-ai" +} + +add_python_stub() { + local bin_dir="$1" + cat > "${bin_dir}/python3" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${PYTHON_LOG}" +exit 0 +EOF + chmod +x "${bin_dir}/python3" +} + +make_no_python_bin_dir() { + local bin_dir="$1" + local fake_repo="$2" + make_bin_dir "${bin_dir}" "${fake_repo}" +} + +run_install() { + local name="$1" + local bin_dir="$2" + shift 2 + local home="${WORKDIR}/home-${name}" + local log="${WORKDIR}/${name}.python.log" + mkdir -p "${home}" + : > "${log}" + HOME="${home}" \ + SKILL_DIR="${home}/skill" \ + PYTHON_LOG="${log}" \ + CI=1 \ + PATH="${bin_dir}" \ + "${BASH_BIN}" "${SCRIPT_UNDER_TEST}" "$@" >/dev/null + printf '%s\n' "${log}" +} + +fake_repo="$(make_fake_repo)" + +default_bin="${WORKDIR}/bin-default" +make_bin_dir "${default_bin}" "${fake_repo}" +add_python_stub "${default_bin}" +default_log="$(run_install default "${default_bin}")" +default_count="$(grep -c 'send_event("install")' "${default_log}" || true)" +[[ "${default_count}" == "1" ]] || fail "default install should invoke install telemetry once, got ${default_count}" + +flag_bin="${WORKDIR}/bin-flag" +make_bin_dir "${flag_bin}" "${fake_repo}" +add_python_stub "${flag_bin}" +flag_log="$(run_install flag "${flag_bin}" --no-telemetry)" +flag_count="$(grep -c 'send_event("install")' "${flag_log}" || true)" +[[ "${flag_count}" == "0" ]] || fail "--no-telemetry should suppress telemetry, got ${flag_count}" + +env_bin="${WORKDIR}/bin-env" +make_bin_dir "${env_bin}" "${fake_repo}" +add_python_stub "${env_bin}" +env_log="$(ATLAS_TELEMETRY=0 run_install env "${env_bin}")" +env_count="$(grep -c 'send_event("install")' "${env_log}" || true)" +[[ "${env_count}" == "0" ]] || fail "ATLAS_TELEMETRY=0 should suppress telemetry, got ${env_count}" + +no_python_bin="${WORKDIR}/bin-no-python" +make_no_python_bin_dir "${no_python_bin}" "${fake_repo}" +run_install no-python "${no_python_bin}" >/dev/null || fail "install should succeed when python3 is absent" + +printf 'install telemetry shell tests passed\n' diff --git a/tests/install/test_postinstall.py b/tests/install/test_postinstall.py new file mode 100644 index 0000000..dba27c8 --- /dev/null +++ b/tests/install/test_postinstall.py @@ -0,0 +1,60 @@ +"""Tests for npm postinstall telemetry behavior.""" + +import json +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def _run_postinstall(tmp_path, *, telemetry_env=None): + package = json.loads((ROOT / "package.json").read_text()) + script = package["scripts"]["postinstall"] + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "python.log" + log.write_text("") + (bin_dir / "pip").write_text("#!/usr/bin/env bash\nexit 0\n") + (bin_dir / "python3").write_text( + "#!/usr/bin/env bash\n" + "printf '%s\\n' \"$*\" >> \"${PYTHON_LOG}\"\n" + "exit 0\n" + ) + (bin_dir / "pip").chmod(0o755) + (bin_dir / "python3").chmod(0o755) + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_dir}:{env['PATH']}", + "PYTHON_LOG": str(log), + } + ) + if telemetry_env is not None: + env["ATLAS_TELEMETRY"] = telemetry_env + result = subprocess.run( + script, + cwd=ROOT, + env=env, + shell=True, + executable="/bin/bash", + capture_output=True, + text=True, + check=False, + ) + return result, log.read_text() + + +def test_package_postinstall_sends_install_event_once(tmp_path): + result, python_log = _run_postinstall(tmp_path) + + assert result.returncode == 0, result.stderr + assert python_log.count('send_event("install")') == 1 + + +def test_package_postinstall_respects_env_opt_out(tmp_path): + result, python_log = _run_postinstall(tmp_path, telemetry_env="0") + + assert result.returncode == 0, result.stderr + assert 'send_event("install")' not in python_log diff --git a/tests/install/test_readme_telemetry.py b/tests/install/test_readme_telemetry.py new file mode 100644 index 0000000..df0a236 --- /dev/null +++ b/tests/install/test_readme_telemetry.py @@ -0,0 +1,24 @@ +"""README telemetry disclosure accuracy tests.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_readme_telemetry_section_is_complete_and_positioned(): + readme = (ROOT / "README.md").read_text() + + quickstart = readme.index("## Quickstart") + telemetry = readme.index("## Telemetry") + faq = readme.index("## FAQ") + + assert quickstart < telemetry < faq + for field in ("install_id", "event", "version", "os"): + assert field in readme + for event_name in ("install", "atlas_invoked", "reach_execute", "ship_check_ok"): + assert event_name in readme + for opt_out in ("ATLAS_TELEMETRY=0", "config.json", "--no-telemetry"): + assert opt_out in readme + for forbidden in ("PII", "goal text", "code", "paths"): + assert forbidden in readme diff --git a/tests/license/__init__.py b/tests/license/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/license/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/license/test_ed25519_ref.py b/tests/license/test_ed25519_ref.py new file mode 100644 index 0000000..a55821d --- /dev/null +++ b/tests/license/test_ed25519_ref.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +ED25519_PATH = ROOT / "scripts" / "license" / "ed25519_ref.py" + + +def load_ed25519_ref(): + assert ED25519_PATH.exists(), "scripts/license/ed25519_ref.py is required" + spec = importlib.util.spec_from_file_location("ed25519_ref", ED25519_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +RFC_8032_ED25519_VECTORS = [ + { + "name": "TEST 1", + "secret": ( + "9d61b19deffd5a60ba844af492ec2cc4" + "4449c5697b326919703bac031cae7f60" + ), + "public": ( + "d75a980182b10ab7d54bfed3c964073a" + "0ee172f3daa62325af021a68f707511a" + ), + "message": "", + "signature": ( + "e5564300c360ac729086e2cc806e828a" + "84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46b" + "d25bf5f0595bbe24655141438e7a100b" + ), + }, + { + "name": "TEST 2", + "secret": ( + "4ccd089b28ff96da9db6c346ec114e0f" + "5b8a319f35aba624da8cf6ed4fb8a6fb" + ), + "public": ( + "3d4017c3e843895a92b70aa74d1b7ebc" + "9c982ccf2ec4968cc0cd55f12af4660c" + ), + "message": "72", + "signature": ( + "92a009a9f0d4cab8720e820b5f642540" + "a2b27b5416503f8fb3762223ebdb69da" + "085ac1e43e15996e458f3613d0f11d8c" + "387b2eaeb4302aeeb00d291612bb0c00" + ), + }, + { + "name": "TEST 3", + "secret": ( + "c5aa8df43f9f837bedb7442f31dcb7b1" + "66d38535076f094b85ce3a2e0b4458f7" + ), + "public": ( + "fc51cd8e6218a1a38da47ed00230f058" + "0816ed13ba3303ac5deb911548908025" + ), + "message": "af82", + "signature": ( + "6291d657deec24024827e69c3abe01a3" + "0ce548a284743a445e3680d7db5ac3ac" + "18ff9b538d16f290ae67f760984dc659" + "4a7c15e9716ed28dc027beceea1ec40a" + ), + }, + { + "name": "TEST SHA(abc)", + "secret": ( + "833fe62409237b9d62ec77587520911e" + "9a759cec1d19755b7da901b96dca3d42" + ), + "public": ( + "ec172b93ad5e563bf4932c70e1245034" + "c35467ef2efd4d64ebf819683467e2bf" + ), + "message": ( + "ddaf35a193617abacc417349ae204131" + "12e6fa4e89a97ea20a9eeee64b55d39a" + "2192992a274fc1a836ba3c23a3feebbd" + "454d4423643ce80e2a9ac94fa54ca49f" + ), + "signature": ( + "dc2a4459e7369633a52b1bf277839a00" + "201009a3efbf3ecb69bea2186c26b589" + "09351fc9ac90b3ecfdfbc7c66431e030" + "3dca179c138ac17ad9bef1177331a704" + ), + }, +] + + +def test_rfc_8032_vectors_derive_public_key_sign_and_verify(): + ed25519 = load_ed25519_ref() + + for vector in RFC_8032_ED25519_VECTORS: + secret = bytes.fromhex(vector["secret"]) + public = bytes.fromhex(vector["public"]) + message = bytes.fromhex(vector["message"]) + signature = bytes.fromhex(vector["signature"]) + + assert ed25519.secret_to_public(secret) == public, vector["name"] + assert ed25519.sign(secret, message) == signature, vector["name"] + assert ed25519.verify(public, message, signature), vector["name"] + + +def test_verify_rejects_changed_signature(): + ed25519 = load_ed25519_ref() + vector = RFC_8032_ED25519_VECTORS[0] + public = bytes.fromhex(vector["public"]) + message = bytes.fromhex(vector["message"]) + signature = bytearray.fromhex(vector["signature"]) + + signature[0] ^= 0x01 + + assert not ed25519.verify(public, message, bytes(signature)) diff --git a/tests/license/test_sign_key.py b/tests/license/test_sign_key.py new file mode 100644 index 0000000..f4c842a --- /dev/null +++ b/tests/license/test_sign_key.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SIGN_KEY_PATH = ROOT / "scripts" / "license" / "sign-key.py" +VECTORS_PATH = ROOT / "tests" / "license" / "test_vectors.json" +SPEC_PATH = ROOT / "docs" / "product" / "LICENSE-KEY-SPEC.md" +TS_SIGN_KEY_PATH = ROOT / "scripts" / "license" / "sign-key.ts" + + +def load_sign_key(): + assert SIGN_KEY_PATH.exists(), "scripts/license/sign-key.py is required" + spec = importlib.util.spec_from_file_location("atlas_sign_key", SIGN_KEY_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_canonical_payload_bytes_are_sorted_compact_json(): + sign_key = load_sign_key() + payload = { + "v": 1, + "sub": "b" * 64, + "plan": "pro-monthly", + "iat": 1_717_200_000, + "lid": "lic_test_001", + "exp": 1_719_720_000, + } + + canonical = sign_key.canonical_payload_bytes(payload) + + assert canonical == ( + b'{"exp":1719720000,"iat":1717200000,"lid":"lic_test_001",' + b'"plan":"pro-monthly","sub":"' + + b"b" * 64 + + b'","v":1}' + ) + assert json.loads(canonical.decode("utf-8")) == payload + + +def test_build_payload_hashes_email_and_preserves_required_fields(): + sign_key = load_sign_key() + + payload = sign_key.build_payload( + lid="lic_test_001", + email="buyer@example.com", + plan="pro-annual", + iat=1_717_200_000, + exp=1_719_720_000, + ) + + assert payload == { + "lid": "lic_test_001", + "sub": hashlib.sha256(b"buyer@example.com").hexdigest(), + "plan": "pro-annual", + "iat": 1_717_200_000, + "exp": 1_719_720_000, + "v": 1, + } + + +def test_encode_parse_round_trip_uses_atlas_prefix_and_unpadded_base64url(): + sign_key = load_sign_key() + payload = { + "lid": "lic_test_001", + "sub": hashlib.sha256(b"buyer@example.com").hexdigest(), + "plan": "pro-monthly", + "iat": 1_717_200_000, + "exp": 1_719_720_000, + "v": 1, + } + signature = bytes(range(64)) + + license_key = sign_key.encode_license_key(payload, signature) + decoded_payload, decoded_signature = sign_key.decode_license_key(license_key) + + assert re.fullmatch(r"ATLAS-[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", license_key) + assert "=" not in license_key + assert decoded_payload == payload + assert decoded_signature == signature + + +def test_cli_help_documents_signing_fields(): + assert SIGN_KEY_PATH.exists(), "scripts/license/sign-key.py is required" + + result = subprocess.run( + [sys.executable, str(SIGN_KEY_PATH), "sign", "--help"], + check=True, + capture_output=True, + text=True, + ) + + for expected in ( + "--private-key", + "--lid", + "--email", + "--plan", + "--iat", + "--exp", + ): + assert expected in result.stdout + + +def test_committed_vectors_verify_expected_verdicts(): + sign_key = load_sign_key() + assert VECTORS_PATH.exists(), "tests/license/test_vectors.json is required" + + data = json.loads(VECTORS_PATH.read_text(encoding="utf-8")) + + assert data["format"] == "atlas-license-key-v1" + assert {case["name"] for case in data["vectors"]} == { + "valid", + "expired", + "tampered-payload", + "signature-mismatch", + } + public_key = bytes.fromhex(data["public_key_hex"]) + for case in data["vectors"]: + result = sign_key.verify_license_key(case["key"], public_key, now=data["now"]) + assert result["ok"] is case["expected"]["ok"], case["name"] + assert result["reason"] == case["expected"]["reason"], case["name"] + + +def test_license_key_spec_mentions_payload_fields_and_interop_rules(): + assert SPEC_PATH.exists(), "docs/product/LICENSE-KEY-SPEC.md is required" + + spec = SPEC_PATH.read_text(encoding="utf-8") + for required in ( + "lid", + "sub", + "plan", + "iat", + "exp", + "v", + "ATLAS-<base64url(payload)>.<base64url(sig)>", + "canonical JSON", + "base64url", + "14 days", + "Ed25519", + "public key", + "byte-for-byte", + ): + assert required in spec + + +def test_typescript_signer_reproduces_reproducible_vector_keys(): + node = shutil.which("node") + if node is None: + import pytest + + pytest.skip("node is unavailable") + assert TS_SIGN_KEY_PATH.exists(), "scripts/license/sign-key.ts is required" + + data = json.loads(VECTORS_PATH.read_text(encoding="utf-8")) + for case in data["vectors"]: + if not case.get("reproducible"): + continue + result = subprocess.run( + [ + node, + str(TS_SIGN_KEY_PATH), + "sign-payload", + "--payload-json", + json.dumps(case["payload"], sort_keys=True, separators=(",", ":")), + ], + check=True, + capture_output=True, + text=True, + ) + assert result.stdout.strip() == case["key"], case["name"] + + +def test_private_key_material_is_ignored_and_not_committed(): + ignore_text = (ROOT / ".gitignore").read_text(encoding="utf-8") + for rule in ("*.key", "*.pem", "license-signing*"): + assert rule in ignore_text + + listed = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + forbidden = [ + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + re.compile(r"ATLAS_LICENSE_PRIVATE_KEY_HEX\\s*=\\s*['\"][0-9a-fA-F]{64}['\"]"), + re.compile(r"private_seed_hex['\"]?\\s*:\\s*['\"][0-9a-fA-F]{64}['\"]"), + ] + + offenders: list[str] = [] + for relpath in listed.stdout.splitlines(): + path = ROOT / relpath + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if any(pattern.search(text) for pattern in forbidden): + offenders.append(relpath) + + assert offenders == [] diff --git a/tests/license/test_vectors.json b/tests/license/test_vectors.json new file mode 100644 index 0000000..ba1c9b4 --- /dev/null +++ b/tests/license/test_vectors.json @@ -0,0 +1,75 @@ +{ + "format": "atlas-license-key-v1", + "now": 1717200000, + "public_key_hex": "1fc868c32afba550e6db6db038302a6bd83fbbd848a87191f3a03bdcccf7e88d", + "vectors": [ + { + "expected": { + "ok": true, + "reason": "valid" + }, + "key": "ATLAS-eyJleHAiOjE3MTcyODY0MDAsImlhdCI6MTcxNzE5NjQwMCwibGlkIjoibGljX3ZlY192YWxpZCIsInBsYW4iOiJwcm8tbW9udGhseSIsInN1YiI6IjExMzlmODQ0MWM0MDJkZGI1NzdjMGE5MTNmY2Q1NWZiZDk1OTkxNzY1ZDU0YzI1MTgzMzc2YjEyNThhNjBiNTciLCJ2IjoxfQ.WmWCSicU8CKP7zxRybsyXfb2_uZFzF7Ybhv_wflG-DUjDjczy0a7awmqSt0paGozTrCzGl7yqcc77ljG2lHSCw", + "name": "valid", + "payload": { + "exp": 1717286400, + "iat": 1717196400, + "lid": "lic_vec_valid", + "plan": "pro-monthly", + "sub": "1139f8441c402ddb577c0a913fcd55fbd95991765d54c25183376b1258a60b57", + "v": 1 + }, + "reproducible": true + }, + { + "expected": { + "ok": false, + "reason": "expired" + }, + "key": "ATLAS-eyJleHAiOjE3MTcxMTM2MDAsImlhdCI6MTcxNzAyNzIwMCwibGlkIjoibGljX3ZlY19leHBpcmVkIiwicGxhbiI6InByby1hbm51YWwiLCJzdWIiOiJjNzFlODc3OWM0ZTkxZjU3MTRhNDY2NWUwNTdmYzE3MTQ1ZTJjYmI5ZGE2NDRiZTg0OWRiYjhmYTNlZGEzNTAyIiwidiI6MX0.8borueelBbkFmj1GOiB8wID3mVynC_z-2IYQEFQ6Sn0b0ZZZsu_NdDQM83aF9J1DZeBEUAryoIGJ9Dh_OWDiDQ", + "name": "expired", + "payload": { + "exp": 1717113600, + "iat": 1717027200, + "lid": "lic_vec_expired", + "plan": "pro-annual", + "sub": "c71e8779c4e91f5714a4665e057fc17145e2cbb9da644be849dbb8fa3eda3502", + "v": 1 + }, + "reproducible": true + }, + { + "expected": { + "ok": false, + "reason": "signature mismatch" + }, + "key": "ATLAS-eyJleHAiOjE3MTcyODY0MDAsImlhdCI6MTcxNzE5NjQwMCwibGlkIjoibGljX3ZlY190YW1wZXJlZCIsInBsYW4iOiJwcm8tbW9udGhseSIsInN1YiI6IjExMzlmODQ0MWM0MDJkZGI1NzdjMGE5MTNmY2Q1NWZiZDk1OTkxNzY1ZDU0YzI1MTgzMzc2YjEyNThhNjBiNTciLCJ2IjoxfQ.WmWCSicU8CKP7zxRybsyXfb2_uZFzF7Ybhv_wflG-DUjDjczy0a7awmqSt0paGozTrCzGl7yqcc77ljG2lHSCw", + "name": "tampered-payload", + "payload": { + "exp": 1717286400, + "iat": 1717196400, + "lid": "lic_vec_tampered", + "plan": "pro-monthly", + "sub": "1139f8441c402ddb577c0a913fcd55fbd95991765d54c25183376b1258a60b57", + "v": 1 + }, + "reproducible": false + }, + { + "expected": { + "ok": false, + "reason": "signature mismatch" + }, + "key": "ATLAS-eyJleHAiOjE3MTcyODY0MDAsImlhdCI6MTcxNzE5NjQwMCwibGlkIjoibGljX3ZlY192YWxpZCIsInBsYW4iOiJwcm8tbW9udGhseSIsInN1YiI6IjExMzlmODQ0MWM0MDJkZGI1NzdjMGE5MTNmY2Q1NWZiZDk1OTkxNzY1ZDU0YzI1MTgzMzc2YjEyNThhNjBiNTciLCJ2IjoxfQ.hCBr4uhhVXYqG0YBudByqQb7_pq8OhHpoRUCORm0qjtMrVg1emzmMgTHJ7A-oGkhTgEFTq0UZhueltqPE7JeBA", + "name": "signature-mismatch", + "payload": { + "exp": 1717286400, + "iat": 1717196400, + "lid": "lic_vec_valid", + "plan": "pro-monthly", + "sub": "1139f8441c402ddb577c0a913fcd55fbd95991765d54c25183376b1258a60b57", + "v": 1 + }, + "reproducible": false + } + ] +} diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mcp/test_integration.py b/tests/mcp/test_integration.py new file mode 100644 index 0000000..5e887a7 --- /dev/null +++ b/tests/mcp/test_integration.py @@ -0,0 +1,222 @@ +"""Full MCP surface end-to-end integration (retargeted to the merged repo). + +Originally the plugin's tests/test_integration.py. Retargeted: + - imports now come from prd_taskmaster.* (pipeline, templates, mode_recommend) + and the MCP server at mcp-server/server.py; + - the plugin's stricter 14-check validate_prd is gone — the package's + canonical 13-check skill validation is asserted instead; + - validate_prd(ai=True) no longer produces an ai_review stub: the server + wrapper accepts `ai` for forward-compat but the deterministic path is + canonical. The test asserts graceful (non-crashing) completion. + +Guarded with importorskip("mcp") because importing the server pulls in the +mcp package. +""" +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import pytest + +pytest.importorskip("mcp") + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "mcp-server")) + +from prd_taskmaster import pipeline as P # noqa: E402 +from prd_taskmaster import templates as TPL # noqa: E402 +from prd_taskmaster.mode_recommend import ( # noqa: E402 + detect_capabilities, + validate_setup, +) +import server as S # noqa: E402 + + +# ─── Phase 0: SETUP gate ────────────────────────────────────────────────────── + + +def test_validate_setup_returns_structured_checks(tmp_path, monkeypatch): + """validate_setup must return per-check pass/fail with a fix hint on failures.""" + monkeypatch.chdir(tmp_path) + payload = validate_setup() + assert payload.get("ok") is True + assert "checks" in payload + assert len(payload["checks"]) >= 4 + for check in payload["checks"]: + assert "id" in check + assert "name" in check + assert "passed" in check + assert "detail" in check + for check in payload["checks"]: + if not check["passed"] and check.get("severity") != "warning": + assert check.get("fix"), f"Failing check {check['id']} has no fix hint" + + +def test_validate_setup_fix_commands_do_not_contain_broken_syntax(tmp_path, monkeypatch): + """REGRESSION: SETUP.md once documented `task-master models --set-main claude-code` + which fails because 'claude-code' is a provider flag, not a model ID. Any fix + command emitted must not reproduce this syntax. + """ + monkeypatch.chdir(tmp_path) + payload = validate_setup() + for check in payload.get("checks", []): + fix = check.get("fix") or "" + if "models --set-" in fix: + assert "--set-main claude-code" not in fix + assert "--set-research claude-code" not in fix + assert "--set-fallback claude-code" not in fix + + +# ─── Phase 1: PREFLIGHT ─────────────────────────────────────────────────────── + + +def test_preflight_fresh_tmp_dir(tmp_path, monkeypatch): + """Fresh dir with no .taskmaster/ reports has_taskmaster=False.""" + monkeypatch.chdir(tmp_path) + payload = P.preflight() + assert payload.get("ok") is True + assert payload.get("has_taskmaster") is False + assert payload.get("task_count", 0) == 0 + + +def test_preflight_empty_taskmaster_dir(tmp_path, monkeypatch): + """.taskmaster/ exists but empty — ambiguous state.""" + (tmp_path / ".taskmaster" / "docs").mkdir(parents=True) + (tmp_path / ".taskmaster" / "tasks").mkdir(parents=True) + + monkeypatch.chdir(tmp_path) + payload = P.preflight() + assert payload.get("has_taskmaster") is True + assert payload.get("prd_path") is None + assert payload.get("task_count", 0) == 0 + + +def test_preflight_prd_no_tasks(tmp_path, monkeypatch): + """Stale-PRD state: prd_path set but task_count == 0.""" + (tmp_path / ".taskmaster" / "docs").mkdir(parents=True) + (tmp_path / ".taskmaster" / "docs" / "prd.md").write_text("# Stale\n") + + monkeypatch.chdir(tmp_path) + payload = P.preflight() + assert payload.get("has_taskmaster") is True + assert payload.get("prd_path") is not None + assert payload.get("task_count", 0) == 0 + + +# ─── Phase 3: GENERATE — validate_prd integration ───────────────────────────── + + +def test_validate_prd_on_comprehensive_template(tmp_path): + """Integration: load_template -> validate_prd. An unfilled template must + not crash — it grades against the canonical 13-check skill validation.""" + load = TPL.run_load_template("comprehensive") + assert load.get("ok") is True + template_content = load.get("content", "") + assert len(template_content) > 100 + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".md", delete=False, dir=tmp_path + ) as f: + f.write(template_content) + tmp_prd_path = f.name + + try: + payload = S.validate_prd(tmp_prd_path) + assert payload.get("ok") is True + assert "grade" in payload + assert "checks" in payload + # Canonical skill validation: 13 checks (the plugin's 14th + # placeholder-attribution check was removed in the merge). + assert len(payload["checks"]) == 13 + finally: + Path(tmp_prd_path).unlink(missing_ok=True) + + +def test_validate_prd_grades_a_thin_prd(tmp_path): + """A thin PRD still grades cleanly (graded dict, never a crash).""" + prd = tmp_path / "prd.md" + prd.write_text( + "# PRD\n\n## Executive Summary\nT\n\n## Problem Statement\n" + "User impact: x. Business impact: y.\n\n## Goals\nMetric: target.\n\n" + "## User Stories\n### Story 1: X\n- [ ] a\n- [ ] b\n- [ ] c\n\n" + "## Functional Requirements\nREQ-001: Must have thing.\n\n" + "## Technical Considerations\nArchitecture: x.\n\n## Out of Scope\nNothing.\n" + ) + payload = S.validate_prd(str(prd)) + assert payload.get("ok") is True + assert "grade" in payload + assert "score" in payload and "percentage" in payload + assert isinstance(payload.get("placeholders_found"), int) + + +def test_validate_prd_ai_flag_degrades_gracefully_when_task_master_missing( + monkeypatch, tmp_path +): + """validate_prd(ai=True) must never hard-fail when task-master is unavailable. + LIVE behavior: the server wrapper accepts `ai` for forward-compat but runs + only the deterministic 13-check path (no ai_review stub). The call must + complete and return a graded dict.""" + prd = tmp_path / "prd.md" + prd.write_text( + "# PRD\n\n## Executive Summary\nA test.\n\n" + "## Problem Statement\nUser impact: test. Business impact: test.\n\n" + "## Goals\nMetric: target.\n\n" + "## Functional Requirements\nREQ-001: Must have feature. Priority: Must have.\n\n" + "## Technical Considerations\nArchitecture: test system design.\n\n" + "## Out of Scope\nNothing.\n" + ) + monkeypatch.setenv("PATH", "/nonexistent") + + payload = S.validate_prd(str(prd), ai=True) + assert payload.get("ok") is True, ( + f"validate_prd ai=True hard-failed with no task-master: {payload}" + ) + assert "grade" in payload, "Deterministic path must still have run" + + +# ─── Phase 4: detect_capabilities tier field ───────────────────────────────── + + +def test_detect_capabilities_returns_tier_field(tmp_path, monkeypatch): + """detect_capabilities returns a `tier` (free|premium) and recommended_mode.""" + monkeypatch.chdir(tmp_path) + payload = detect_capabilities() + assert payload.get("ok") is True + assert "tier" in payload, "detect_capabilities must return a tier field" + assert payload["tier"] in ("free", "premium") + assert "recommended_mode" in payload + assert payload["recommended_mode"] in ("A", "B", "C", "D", "E", "F", "G", "H", "I", "J") + + +# ─── MCP surface parity ─────────────────────────────────────────────────────── + + +def test_mcp_server_module_imports_cleanly(): + """The merged MCP server.py must be importable without side effects — the + @mcp.tool() registrations should not raise.""" + assert hasattr(S, "mcp"), "FastMCP instance 'mcp' must be exported" + assert hasattr(S, "preflight"), "preflight tool must be registered" + assert hasattr(S, "validate_prd"), "validate_prd tool must be registered" + assert hasattr(S, "detect_capabilities"), "detect_capabilities tool must be registered" + + +def test_mcp_direct_import_returns_dict(tmp_path, monkeypatch): + """Every MCP tool returns a plain dict, not a sys.exit'd JSON blob. This is + what makes direct-import tests possible. Verify across four tools.""" + monkeypatch.chdir(tmp_path) + p1 = P.preflight() + assert isinstance(p1, dict) and "ok" in p1 + + p2 = TPL.run_load_template("minimal") + assert isinstance(p2, dict) and "ok" in p2 + + p3 = detect_capabilities() + assert isinstance(p3, dict) and "ok" in p3 + + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n\n## Executive Summary\nT\n") + p4 = S.validate_prd(str(prd)) + assert isinstance(p4, dict) and "ok" in p4 diff --git a/tests/mcp/test_mcp_tools.py b/tests/mcp/test_mcp_tools.py new file mode 100644 index 0000000..77e59b3 --- /dev/null +++ b/tests/mcp/test_mcp_tools.py @@ -0,0 +1,124 @@ +"""MCP tool contract tests — the merged server.py registers 20 tools. + +Retargeted from the plugin: server.py now imports from prd_taskmaster.* and +lives at mcp-server/server.py. We add the repo root (so `prd_taskmaster` is +importable) and mcp-server/ (so `import server` works) to sys.path. +""" +import sys +import json +from pathlib import Path + +import pytest +from prd_taskmaster import license + +pytest.importorskip("mcp") + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "mcp-server")) +VECTORS = json.loads((REPO_ROOT / "tests" / "license" / "test_vectors.json").read_text()) + + +def test_load_template_comprehensive(): + import server as S + r = S.load_template("comprehensive") + assert r["ok"] is True + assert len(r["content"]) > 100 + assert "{{" in r["content"] or "[" in r["content"] # template has placeholders + + +def test_load_template_minimal(): + import server as S + r = S.load_template("minimal") + assert r["ok"] is True + assert len(r["content"]) > 10 + + +def test_load_template_unknown_type_raises(): + """LIVE contract: the package's run_load_template raises CommandError on an + unknown type (the plugin returned an ok=False dict). The server wrapper + does not catch it, so the call propagates the raise.""" + import server as S + from prd_taskmaster.lib import CommandError + with pytest.raises(CommandError) as exc: + S.load_template("bogus") + assert "not found" in str(exc.value).lower() + + +def test_compute_fleet_waves_tool(tmp_path, monkeypatch): + import server as S + tasks_dir = tmp_path / ".taskmaster" / "tasks" + tasks_dir.mkdir(parents=True) + (tasks_dir / "tasks.json").write_text(json.dumps({ + "alpha": { + "tasks": [ + {"id": 1, "status": "pending", "dependencies": []}, + {"id": 2, "status": "pending", "dependencies": []}, + {"id": 3, "status": "pending", "dependencies": [1, 2]}, + ] + } + })) + monkeypatch.chdir(tmp_path) + + r = S.compute_fleet_waves(concurrency=2, tag="alpha") + + assert r == { + "ok": True, + "tag": "alpha", + "waves": [[1, 2], [3]], + "blocked": [], + "deadlocked": False, + "ready": [1, 2], + "concurrency": 2, + } + + +def test_license_activate_tool_returns_status_and_tier(tmp_path, monkeypatch): + import server as S + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(license, "PUBLIC_KEY", bytes.fromhex(VECTORS["public_key_hex"])) + monkeypatch.setattr( + S.C, + "detect_atlas_launcher", + lambda: {"installed": False, "mcp_registered": True}, + ) + vector = VECTORS["vectors"][0] + now = vector["payload"]["exp"] - (7 * 24 * 60 * 60) + monkeypatch.setattr(license._time, "time", lambda: now) + + result = S.license_activate(vector["key"]) + + assert result["ok"] is True + assert result["status"] == "grace" + assert result["days_remaining"] == 7 + assert result["tier"] == "premium" + + +def test_detect_capabilities_tool_exposes_license_status(tmp_path, monkeypatch): + import server as S + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + result = S.detect_capabilities() + + assert "license_status" in result + assert result["license_status"]["status"] in {"active", "grace", "expired", "invalid"} + + +def test_server_registers_21_tools(): + """Verify server.py declares all 21 expected tool functions at module scope.""" + import server as S + expected = { + "preflight", "current_phase", "advance_phase", "check_gate", + "detect_taskmaster", "init_taskmaster", "validate_setup", + "detect_capabilities", "load_template", "validate_prd", + "calc_tasks", "gen_test_tasks", "backup_prd", "append_workflow", + "debrief", "log_progress", "read_state", "gen_scripts", + "compute_fleet_waves", + "engine_preflight", + "license_activate", + } + assert len(expected) == 21 + public_attrs = {name for name in dir(S) if not name.startswith("_")} + missing = expected - public_attrs + assert not missing, f"missing tools: {sorted(missing)}" diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugin/test_execute_fleet_skill.py b/tests/plugin/test_execute_fleet_skill.py new file mode 100644 index 0000000..1b2cd09 --- /dev/null +++ b/tests/plugin/test_execute_fleet_skill.py @@ -0,0 +1,89 @@ +"""Structural coverage for the execute-fleet phase skill. + +The skill is an orchestration contract, so these tests intentionally assert +grep-able markers and tool ids rather than trying to execute Atlas Fleet. +""" +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SKILL_PATH = REPO_ROOT / "skills" / "execute-fleet" / "SKILL.md" + + +def _content() -> str: + assert SKILL_PATH.exists(), "skills/execute-fleet/SKILL.md must exist" + return SKILL_PATH.read_text() + + +def _frontmatter() -> dict: + content = _content() + assert content.startswith("---\n"), "execute-fleet skill must start with YAML frontmatter" + end = content.find("\n---\n", 4) + assert end > 0, "execute-fleet frontmatter must be closed with ---" + return yaml.safe_load(content[4:end]) + + +def test_execute_fleet_skill_has_valid_frontmatter(): + fm = _frontmatter() + assert fm["name"] == "execute-fleet" + assert fm["user-invocable"] is False + assert "Read" in fm["allowed-tools"] + assert "Bash" in fm["allowed-tools"] + assert "Skill" in fm["allowed-tools"] + + +def test_execute_fleet_skill_references_required_mcp_tools(): + content = _content() + for tool_id in ( + "mcp__plugin_prd-taskmaster_go__compute_fleet_waves", + "mcp__atlas-launcher__session_spawn", + "mcp__atlas-launcher__session_send", + "mcp__atlas-launcher__inbox_read", + "mcp__atlas-launcher__inbox_send", + ): + assert tool_id in content, f"missing tool id: {tool_id}" + + +def test_execute_fleet_worker_prompt_has_contract_fields(): + content = _content() + for marker in ( + "WORKER_CONTRACT_ORCHESTRATOR_REPORT_TO", + "WORKER_CONTRACT_FULL_TASK_JSON_INLINE", + "WORKER_CONTRACT_WORKTREE_BRANCH", + "WORKER_CONTRACT_CDD_CARD", + "WORKER_CONTRACT_TERMINAL_STATUS", + "WORKER_CONTRACT_HARD_RULES", + "WORKER_CONTRACT_QUESTIONS_INBOX", + ): + assert content.count(marker) == 1, f"{marker} must appear exactly once" + + +def test_execute_fleet_skill_states_hard_gates(): + content = _content() + assert "SOLE-WRITER RULE" in content + assert ".taskmaster/tasks/tasks.json" in content + assert ".atlas-ai/state/pipeline.json" in content + assert "Never mark a task done without the evidence card" in content + assert "re-queue the task ONCE" in content + assert "second failure" in content and "BLOCKED" in content + assert "Merge conflict" in content and "do not force" in content + + +def test_execute_fleet_skill_states_worker_status_vocabulary(): + content = _content() + for status in ("DONE", "DONE_WITH_CONCERNS", "NEEDS_CONTEXT", "BLOCKED"): + assert status in content, f"missing worker status: {status}" + + +def test_execute_fleet_skill_renders_status_and_terminates_safely(): + content = _content() + assert "atlas fleet" in content + assert "wave 2 of 3" in content + assert "claude-1" in content + assert "codex-1" in content + assert "Gate: a wave merges only after the checker approves it" in content + assert "python3 skel/ship-check.py" in content + assert "SHIP_CHECK_OK" in content + assert "main is never auto-touched" in content diff --git a/tests/plugin/test_hooks.py b/tests/plugin/test_hooks.py new file mode 100644 index 0000000..62fc301 --- /dev/null +++ b/tests/plugin/test_hooks.py @@ -0,0 +1,180 @@ +# tests/test_hooks.py +import json +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _run_hook(hook_path, input_json): + r = subprocess.run( + ["python3", str(hook_path)], + input=json.dumps(input_json), + capture_output=True, + text=True, + timeout=5, + ) + return r.returncode, json.loads(r.stdout) if r.stdout.strip() else {}, r.stderr + + +def test_gate_enforcer_denies_advance_without_gate_evidence(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "gate_enforcer.py" + input_json = { + "hook_event_name": "PreToolUse", + "tool_name": "mcp__plugin_prd-taskmaster_go__advance_phase", + "tool_input": {"expected_current": None, "target": "SETUP", "evidence": {}}, + "session_id": "test-session", + } + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert out.get("hookSpecificOutput", {}).get("permissionDecision") == "deny" + assert "evidence" in out.get("hookSpecificOutput", {}).get("permissionDecisionReason", "").lower() + + +def test_gate_enforcer_allows_with_evidence(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "gate_enforcer.py" + input_json = { + "hook_event_name": "PreToolUse", + "tool_name": "mcp__plugin_prd-taskmaster_go__advance_phase", + "tool_input": { + "expected_current": None, + "target": "SETUP", + "evidence": {"validate_setup": {"ready": True, "critical_failures": 0}}, + }, + "session_id": "test-session", + } + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + decision = out.get("hookSpecificOutput", {}).get("permissionDecision") + assert decision in (None, "allow") # no denial + + +def test_mode_d_blocker_denies_atlas_cdd_tool(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "mode_d_blocker.py" + input_json = { + "hook_event_name": "PreToolUse", + "tool_name": "mcp__atlas-cdd__some_method", + "tool_input": {}, + "session_id": "test-session", + } + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert out.get("hookSpecificOutput", {}).get("permissionDecision") == "deny" + reason = out.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") + assert "Atlas Pro ($29/mo)" in reason + assert "https://atlas-ai.au/pro" in reason + + +def _write_pipeline(tmp_path, phase="EXECUTE"): + p = tmp_path / ".atlas-ai" / "state" + p.mkdir(parents=True, exist_ok=True) + (p / "pipeline.json").write_text(json.dumps({"current_phase": phase})) + + +def _write_tasks(tmp_path, tasks): + p = tmp_path / ".atlas-ai" / "taskmaster" / "tasks" + p.mkdir(parents=True, exist_ok=True) + (p / "tasks.json").write_text(json.dumps({"master": {"tasks": tasks}})) + + +def test_evidence_gate_blocks_incomplete_evidence(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "evidence_gate.py" + _write_pipeline(tmp_path, phase="EXECUTE") + _write_tasks(tmp_path, [ + { + "id": "1", + "status": "in-progress", + "subtasks": [{"id": "1.1"}, {"id": "1.2"}], + "evidence_files": [], + } + ]) + input_json = {"hook_event_name": "Stop", "stop_hook_active": False, "session_id": "test-session"} + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + reason = out.get("reason", "") + assert out.get("decision") == "block" or "incomplete" in reason.lower() + + +def test_evidence_gate_allows_complete_evidence(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "evidence_gate.py" + _write_pipeline(tmp_path, phase="EXECUTE") + _write_tasks(tmp_path, [ + { + "id": "1", + "status": "in-progress", + "subtasks": [{"id": "1.1"}, {"id": "1.2"}], + "evidence_files": ["evidence/a.md", "evidence/b.md"], + } + ]) + input_json = {"hook_event_name": "Stop", "stop_hook_active": False, "session_id": "test-session"} + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert "decision" not in out + assert out == {} + + +def test_evidence_gate_noop_no_pipeline(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "evidence_gate.py" + input_json = {"hook_event_name": "Stop", "stop_hook_active": False, "session_id": "test-session"} + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert out == {} + + +def test_evidence_gate_stop_hook_active_short_circuits(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "evidence_gate.py" + _write_pipeline(tmp_path, phase="EXECUTE") + _write_tasks(tmp_path, [ + { + "id": "1", + "status": "in-progress", + "subtasks": [{"id": "1.1"}, {"id": "1.2"}], + "evidence_files": [], + } + ]) + input_json = {"hook_event_name": "Stop", "stop_hook_active": True, "session_id": "test-session"} + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert out == {} + + +def test_evidence_gate_done_task_not_flagged(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "evidence_gate.py" + _write_pipeline(tmp_path, phase="EXECUTE") + _write_tasks(tmp_path, [ + { + "id": "1", + "status": "done", + "subtasks": [{"id": "1.1"}, {"id": "1.2"}, {"id": "1.3"}], + "evidence_files": [], + } + ]) + input_json = {"hook_event_name": "Stop", "stop_hook_active": False, "session_id": "test-session"} + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert out == {} + + +def test_evidence_gate_no_subtasks_key_no_crash(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook_path = REPO_ROOT / "hooks" / "evidence_gate.py" + _write_pipeline(tmp_path, phase="EXECUTE") + _write_tasks(tmp_path, [ + { + "id": "1", + "status": "in-progress", + "evidence_files": [], + } + ]) + input_json = {"hook_event_name": "Stop", "stop_hook_active": False, "session_id": "test-session"} + rc, out, err = _run_hook(hook_path, input_json) + assert rc == 0 + assert out == {} diff --git a/tests/plugin/test_plugin_manifest.py b/tests/plugin/test_plugin_manifest.py new file mode 100644 index 0000000..e28665d --- /dev/null +++ b/tests/plugin/test_plugin_manifest.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_plugin_manifest_exists(): + p = REPO_ROOT / ".claude-plugin" / "plugin.json" + assert p.exists(), "plugin.json must exist at .claude-plugin/plugin.json" + + +def test_plugin_manifest_has_required_fields(): + data = json.loads((REPO_ROOT / ".claude-plugin" / "plugin.json").read_text()) + assert data["name"] == "prd-taskmaster" + assert data["version"] == "4.0.0" + assert "description" in data + assert "author" in data + + +def test_mcp_json_exists_and_references_plugin_root(): + data = json.loads((REPO_ROOT / ".mcp.json").read_text()) + assert "go" in data + server = data["go"] + assert server["command"] == "python3" + assert any("${CLAUDE_PLUGIN_ROOT}" in arg for arg in server["args"]) + assert "mcp-server/server.py" in server["args"][-1] + + +def test_package_json_peer_dep_and_postinstall(): + data = json.loads((REPO_ROOT / "package.json").read_text()) + assert data["name"] == "prd-taskmaster" + assert data["version"] == "4.0.0" + assert data["peerDependencies"]["task-master-ai"].startswith(">=") + assert "postinstall" in data["scripts"] + assert "pip install" in data["scripts"]["postinstall"] + assert "test" in data["scripts"] + + +def test_templates_and_reference_present(): + assert (REPO_ROOT / "templates" / "taskmaster-prd-comprehensive.md").exists() + assert (REPO_ROOT / "templates" / "taskmaster-prd-minimal.md").exists() + assert (REPO_ROOT / "reference" / "validation-checklist.md").exists() + assert (REPO_ROOT / "reference" / "taskmaster-integration-guide.md").exists() diff --git a/tests/plugin/test_setup_portability.py b/tests/plugin/test_setup_portability.py new file mode 100644 index 0000000..2056fd6 --- /dev/null +++ b/tests/plugin/test_setup_portability.py @@ -0,0 +1,23 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_skills_do_not_reference_developer_machine_paths(): + offenders = [] + for path in (REPO_ROOT / "skills").glob("*/SKILL.md"): + text = path.read_text() + for forbidden in ("/Shade_Gen/", "$HOME/Shade_Gen"): + if forbidden in text: + offenders.append(f"{path.relative_to(REPO_ROOT)} contains {forbidden}") + + assert offenders == [] + + +def test_setup_bootstrap_uses_installed_skill_relative_skel(): + text = (REPO_ROOT / "skills" / "setup" / "SKILL.md").read_text() + + assert "$SKILL_DIR/../../skel" in text + assert ".atlas-ai/customizations/system-prompt-template.md" in text + assert ": > .atlas-ai/customizations/system-prompt-template.md" in text diff --git a/tests/plugin/test_skill_files.py b/tests/plugin/test_skill_files.py new file mode 100644 index 0000000..aa322f5 --- /dev/null +++ b/tests/plugin/test_skill_files.py @@ -0,0 +1,267 @@ +"""Skill file frontmatter validation (retargeted to merged skills/ layout). + +Tool-id prefix updated atlas-go -> prd-taskmaster; routes /atlas-go: -> /prd-taskmaster:. +Paths anchored to REPO_ROOT so the suite is cwd-independent. +""" +import yaml +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _parse_frontmatter(path: str) -> dict: + content = (REPO_ROOT / path).read_text() + assert content.startswith("---\n"), f"{path} does not start with YAML frontmatter" + end = content.find("\n---\n", 4) + assert end > 0, f"{path} frontmatter not closed with ---" + return yaml.safe_load(content[4:end]) + + +def test_orchestrator_skill_has_valid_frontmatter(): + fm = _parse_frontmatter("skills/go/SKILL.md") + assert fm["name"] == "go" + assert fm["user-invocable"] is True + assert "Skill" in fm["allowed-tools"] + assert "Bash" in fm["allowed-tools"] + assert "Read" in fm["allowed-tools"] + + +def test_orchestrator_skill_references_phase_skills(): + content = (REPO_ROOT / "skills/go/SKILL.md").read_text() + for phase in ("setup", "discover", "generate", "handoff", "execute-task"): + assert f"/prd-taskmaster:{phase}" in content, f"missing route to {phase}" + + +def test_orchestrator_skill_references_mcp_tools(): + content = (REPO_ROOT / "skills/go/SKILL.md").read_text() + assert "mcp__plugin_prd-taskmaster_go__preflight" in content + assert "mcp__plugin_prd-taskmaster_go__current_phase" in content + + +def test_setup_skill_has_valid_frontmatter(): + fm = _parse_frontmatter("skills/setup/SKILL.md") + assert fm["name"] == "setup" + assert fm["user-invocable"] is False + assert "Read" in fm["allowed-tools"] + assert "Bash" in fm["allowed-tools"] + assert "Skill" in fm["allowed-tools"] + assert "Write" not in fm["allowed-tools"] + assert "AskUserQuestion" not in fm["allowed-tools"] + + +def test_setup_skill_references_gate_mcp_tools(): + content = (REPO_ROOT / "skills/setup/SKILL.md").read_text() + assert "mcp__plugin_prd-taskmaster_go__check_gate" in content + assert "mcp__plugin_prd-taskmaster_go__advance_phase" in content + + +def test_setup_skill_ports_detect_first_rule(): + content = (REPO_ROOT / "skills/setup/SKILL.md").read_text() + assert "DETECT" in content or "detect" in content + assert "task-master" in content + + +def test_discover_skill_has_valid_frontmatter(): + fm = _parse_frontmatter("skills/discover/SKILL.md") + assert fm["name"] == "discover" + assert fm["user-invocable"] is False + assert "Read" in fm["allowed-tools"] + assert "Skill" in fm["allowed-tools"] + assert "AskUserQuestion" in fm["allowed-tools"] + assert "Write" in fm["allowed-tools"] + assert "Bash" not in fm["allowed-tools"] + + +def test_discover_skill_references_gate_mcp_tools(): + content = (REPO_ROOT / "skills/discover/SKILL.md").read_text() + assert "mcp__plugin_prd-taskmaster_go__check_gate" in content + assert "mcp__plugin_prd-taskmaster_go__advance_phase" in content + assert "GENERATE" in content + + +def test_discover_skill_ports_brainstorming_intercept(): + content = (REPO_ROOT / "skills/discover/SKILL.md").read_text() + assert "superpowers:brainstorming" in content + assert "writing-plans" in content + assert "Interactive Mode" in content or "Interactive mode" in content + assert "Autonomous Mode" in content or "Autonomous mode" in content + assert "CONSTRAINTS CAPTURED" in content or "Constraint" in content + for scale in ("Solo", "Team", "Enterprise"): + assert scale in content, f"missing scale classification: {scale}" + assert "session-context/discovery-" in content + assert "sys.exit" not in content + + +def test_generate_skill_has_valid_frontmatter(): + fm = _parse_frontmatter("skills/generate/SKILL.md") + assert fm["name"] == "generate" + assert fm["user-invocable"] is False + for t in ("Read", "Write", "Edit", "Bash", "Skill"): + assert t in fm["allowed-tools"], f"missing tool: {t}" + assert "AskUserQuestion" not in fm["allowed-tools"] + + +def test_generate_skill_references_gate_mcp_tools(): + content = (REPO_ROOT / "skills/generate/SKILL.md").read_text() + assert "mcp__plugin_prd-taskmaster_go__check_gate" in content + assert "mcp__plugin_prd-taskmaster_go__advance_phase" in content + assert "HANDOFF" in content + + +def test_generate_skill_ports_six_steps(): + content = (REPO_ROOT / "skills/generate/SKILL.md").read_text() + assert ".taskmaster/docs/prd.md" in content + assert "task-master parse-prd" in content + assert "task-master expand --all" in content + assert ".taskmaster/tasks/tasks.json" in content + assert ".taskmaster/reports/task-complexity-report.json" in content + assert "validate-prd" in content or "validate_prd" in content + assert "calc-tasks" in content or "calc_tasks" in content + assert "analyze-complexity" in content or "analyze_complexity" in content + assert "sys.exit" not in content + + +def test_generate_skill_enforces_anti_parallel_warning(): + content = (REPO_ROOT / "skills/generate/SKILL.md").read_text() + assert "in parallel" in content + assert "expand_task" in content + assert "CONSTRAINT" in content or "Constraint" in content + assert "SCOPE" in content or "Scope" in content + assert "reason:" in content + assert "deferred" in content.lower() + assert "placeholder" in content.lower() + + +def test_handoff_skill_has_valid_frontmatter(): + fm = _parse_frontmatter("skills/handoff/SKILL.md") + assert fm["name"] == "handoff" + assert fm["user-invocable"] is False + for t in ("Read", "Skill", "AskUserQuestion"): + assert t in fm["allowed-tools"], f"missing {t}" + for t in ("Bash", "Write", "Edit"): + assert t not in fm["allowed-tools"], f"forbidden tool present: {t}" + + +def test_handoff_skill_drops_plan_mode(): + content = (REPO_ROOT / "skills/handoff/SKILL.md").read_text() + assert "EnterPlanMode" not in content, "spec section 13.5 forbids Plan Mode in handoff" + assert "ExitPlanMode" not in content, "spec section 13.5 forbids Plan Mode in handoff" + assert "AskUserQuestion" in content + assert "EXECUTE" in content + for m in ("Mode A", "Mode B", "Mode C"): + assert m in content, f"missing mode: {m}" + + +def test_handoff_skill_references_gate_mcp_tools(): + content = (REPO_ROOT / "skills/handoff/SKILL.md").read_text() + assert "mcp__plugin_prd-taskmaster_go__check_gate" in content + assert "mcp__plugin_prd-taskmaster_go__advance_phase" in content + assert "EXECUTE" in content + + +def test_handoff_skill_ports_mode_selection_and_teaser(): + content = (REPO_ROOT / "skills/handoff/SKILL.md").read_text() + assert ".taskmaster/docs/prd.md" in content + assert "tasks.json" in content + assert "ralph-loop" in content.lower() or "ralph loop" in content.lower() + # Mode D is now branded "Atlas Fleet" / "Atlas Pro", shown as a locked + # teaser (was "Atlas-Auto" / "coming soon" in the plugin). + assert "Atlas Fleet" in content or "Atlas Pro" in content + assert "teaser" in content.lower() or "locked" in content.lower() + assert "sys.exit" not in content + + +def test_handoff_skill_teaser_reflects_license_status(): + content = (REPO_ROOT / "skills/handoff/SKILL.md").read_text() + assert "license_status" in content + assert "no license" in content.lower() + assert "expired license" in content.lower() + assert "$29/mo" in content + assert "atlas-ai.au/pro" in content + assert "★ Pro · license active" in content + assert "★ Pro · license grace" in content + assert "days remaining" in content + + +def test_execute_task_skill_uses_standard_taskmaster_root(): + content = (REPO_ROOT / "skills/execute-task/SKILL.md").read_text() + assert ".taskmaster/tasks/tasks.json" in content + assert "TASK_MASTER_PROJECT_ROOT=.atlas-ai/taskmaster" not in content + assert ".atlas-ai/taskmaster/tasks/tasks.json" not in content + + +def test_expand_tasks_skill_is_user_invocable(): + fm = _parse_frontmatter("skills/expand-tasks/SKILL.md") + assert fm["name"] == "expand-tasks" + assert fm["user-invocable"] is True, "expand-tasks is a companion skill, must be user-invocable" + for t in ("Read", "Write", "Bash", "Task"): + assert t in fm["allowed-tools"], f"missing tool: {t}" + + +def test_expand_tasks_skill_references_plugin_mcp_and_agent(): + content = (REPO_ROOT / "skills/expand-tasks/SKILL.md").read_text() + assert "mcp__plugin_prd-taskmaster_go__" in content + assert "task-master" in content + assert "tasks.json" in content + assert "parallel" in content.lower() + assert "Wave" in content + assert "research-expander" in content + assert "sys.exit" not in content + assert len(content.splitlines()) >= 80, "expand-tasks SKILL.md too short" + + +def test_research_expander_agent_has_valid_frontmatter(): + fm = _parse_frontmatter("agents/research-expander.md") + assert fm["name"] == "research-expander" + assert fm.get("description"), "agent needs description" + assert fm.get("tools") is not None, "agent needs tools field" + + +def test_research_expander_agent_body_shape(): + content = (REPO_ROOT / "agents/research-expander.md").read_text() + assert "sys.exit" not in content + assert len(content.splitlines()) >= 30, "research-expander agent too short" + + +def test_customise_workflow_skill_is_user_invocable(): + fm = _parse_frontmatter("skills/customise-workflow/SKILL.md") + assert fm["name"] == "customise-workflow" + assert fm["user-invocable"] is True + for t in ("Read", "Write", "AskUserQuestion"): + assert t in fm["allowed-tools"], f"missing tool: {t}" + + +def test_customise_workflow_uses_plugin_atlas_paths(): + content = (REPO_ROOT / "skills/customise-workflow/SKILL.md").read_text() + assert ".atlas-ai/config/atlas.json" in content, "must write to plugin atlas.json path" + assert ".atlas-ai/customizations/" in content, "must reference customizations dir" + assert ".taskmaster/config/user-workflow.json" not in content, "old v4 path must not regress" + for phase in ("LOAD", "ASK", "VALIDATE", "WRITE", "VERIFY"): + assert phase in content, f"missing 5-phase marker: {phase}" + assert "AskUserQuestion" in content + assert "curated" in content.lower() and "question" in content.lower() + assert "validate-answer" in content + assert "show-config" in content + assert "customization" in content.lower() or "customisation" in content.lower() + assert "sys.exit" not in content + assert len(content.splitlines()) >= 60, "customise-workflow SKILL.md too short" + + +def test_execute_task_skill_has_valid_frontmatter(): + fm = _parse_frontmatter("skills/execute-task/SKILL.md") + assert fm["name"] == "execute-task" + assert fm["user-invocable"] is True + for t in ("Read", "Write", "Edit", "Bash", "Skill", "Agent"): + assert t in fm["allowed-tools"], f"missing tool: {t}" + assert "AskUserQuestion" not in fm["allowed-tools"] + + +def test_execute_task_skill_implements_cdd_loop(): + content = (REPO_ROOT / "skills/execute-task/SKILL.md").read_text() + for phrase in ("Heartbeat check", "Route by status", "Triple verification", "SHIP_CHECK_OK"): + assert phrase in content, f"missing loop element: {phrase}" + for enum in ("DONE_WITH_CONCERNS", "NEEDS_CONTEXT", "BLOCKED"): + assert enum in content, f"missing route status: {enum}" + assert "task-master next --format json" in content + assert "sys.exit" not in content + assert "EnterPlanMode" not in content diff --git a/tests/scripts/quickstart-walkthrough.sh b/tests/scripts/quickstart-walkthrough.sh new file mode 100755 index 0000000..af56985 --- /dev/null +++ b/tests/scripts/quickstart-walkthrough.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/atlas-quickstart.XXXXXX")" +ORIGINAL_PATH="${PATH}" + +cleanup() { + rm -rf "${WORKDIR}" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +write_fake_tools() { + local bin_dir="$1" + mkdir -p "${bin_dir}" + + cat > "${bin_dir}/task-master-ai" <<'TMEOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + echo "task-master-ai 1.2.3" +else + echo "task-master-ai ok" +fi +TMEOF + + cat > "${bin_dir}/task-master" <<'TMEOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + init) + mkdir -p .taskmaster/docs .taskmaster/tasks + printf '{"currentTag":"master"}\n' > .taskmaster/state.json + ;; + parse-prd) + mkdir -p .taskmaster/tasks + cat > .taskmaster/tasks/tasks.json <<'JSON' +{"tasks":[{"id":1,"title":"Build CLI todo app","status":"pending","subtasks":[{"id":1,"title":"Create command parser","status":"pending"}]}]} +JSON + ;; + --version) + echo "task-master 1.2.3" + ;; + *) + echo "task-master fake: $*" + ;; +esac +TMEOF + + cat > "${bin_dir}/git" <<EOF +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "clone" ]]; then + dest="\${@: -1}" + mkdir -p "\${dest}" + cp -R "${ROOT}/." "\${dest}/" + exit 0 +fi +echo "unexpected git invocation: \$*" >&2 +exit 1 +EOF + + cat > "${bin_dir}/npm" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "install -g task-master-ai" ]]; then + bin_dir="$(dirname "$0")" + cat > "${bin_dir}/task-master-ai" <<'TMEOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + echo "task-master-ai 1.2.3" +else + echo "task-master-ai ok" +fi +TMEOF + cat > "${bin_dir}/task-master" <<'TMEOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + init) + mkdir -p .taskmaster/docs .taskmaster/tasks + printf '{"currentTag":"master"}\n' > .taskmaster/state.json + ;; + parse-prd) + mkdir -p .taskmaster/tasks + cat > .taskmaster/tasks/tasks.json <<'JSON' +{"tasks":[{"id":1,"title":"Build CLI todo app","status":"pending","subtasks":[{"id":1,"title":"Create command parser","status":"pending"}]}]} +JSON + ;; + --version) + echo "task-master 1.2.3" + ;; + *) + echo "task-master fake: $*" + ;; +esac +TMEOF + chmod +x "${bin_dir}/task-master-ai" "${bin_dir}/task-master" + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 1 +EOF + + chmod +x "${bin_dir}/git" "${bin_dir}/npm" "${bin_dir}/task-master-ai" "${bin_dir}/task-master" +} + +HOME_DIR="${WORKDIR}/home" +PROJECT_DIR="${WORKDIR}/project" +BIN_DIR="${WORKDIR}/bin" +SKILL_DIR="${HOME_DIR}/.claude/skills/prd-taskmaster" +mkdir -p "${HOME_DIR}" "${PROJECT_DIR}" +write_fake_tools "${BIN_DIR}" + +PATH="${BIN_DIR}:${ORIGINAL_PATH}" \ +HOME="${HOME_DIR}" \ +SKILL_DIR="${SKILL_DIR}" \ +ATLAS_TELEMETRY=0 \ +CI=1 \ +bash "${ROOT}/install.sh" > "${WORKDIR}/install.out" + +grep -q 'Try: /atlas or say "I want to build..."' "${WORKDIR}/install.out" \ + || fail "install output missing first-run hint" +test -f "${SKILL_DIR}/SKILL.md" || fail "installed SKILL.md missing" +test -f "${SKILL_DIR}/script.py" || fail "installed script.py missing" +test -d "${SKILL_DIR}/prd_taskmaster" || fail "installed package missing" + +cd "${PROJECT_DIR}" +PATH="${BIN_DIR}:${ORIGINAL_PATH}" HOME="${HOME_DIR}" python3 "${SKILL_DIR}/script.py" preflight > "${WORKDIR}/preflight.json" +python3 - "${WORKDIR}/preflight.json" <<'PY' +import json +import sys + +with open(sys.argv[1]) as fh: + data = json.load(fh) +if data.get("ok") is not True: + raise SystemExit(f"preflight did not return ok JSON: {data}") +PY + +if command -v task-master >/dev/null 2>&1; then + PATH="${BIN_DIR}:${ORIGINAL_PATH}" HOME="${HOME_DIR}" task-master init --yes +else + printf 'quickstart notice: task-master unavailable; skipping task-master init\n' +fi + +mkdir -p .taskmaster/docs +cat > .taskmaster/docs/prd.md <<'EOF' +# CLI Todo App + +Build a small command-line todo app with add, list, complete, and delete commands. +EOF + +if [[ "${QUICKSTART_LIVE:-0}" == "1" ]]; then + PATH="${BIN_DIR}:${ORIGINAL_PATH}" HOME="${HOME_DIR}" task-master parse-prd --input .taskmaster/docs/prd.md --num-tasks 3 + python3 - <<'PY' +import json +from pathlib import Path + +path = Path(".taskmaster/tasks/tasks.json") +data = json.loads(path.read_text()) +tasks = data.get("tasks", []) +if not tasks: + raise SystemExit("tasks.json contains no tasks") +PY +else + printf 'quickstart live parse skipped (set QUICKSTART_LIVE=1)\n' +fi + +printf 'quickstart deterministic path ok\n' diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..467e493 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,270 @@ +"""Tests for privacy-first client telemetry.""" + +import json +import platform +import socket +from types import SimpleNamespace +import uuid + +from prd_taskmaster import __version__ +from prd_taskmaster import pipeline +from prd_taskmaster import shipcheck +from prd_taskmaster import telemetry + + +def test_get_install_id_creates_uuid4_file(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + + install_id = telemetry.get_install_id() + + assert uuid.UUID(install_id).version == 4 + assert (tmp_path / ".atlas-ai" / "install_id").read_text().strip() == install_id + + +def test_get_install_id_is_stable_and_does_not_rewrite(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + install_dir = tmp_path / ".atlas-ai" + install_dir.mkdir() + existing = str(uuid.uuid4()) + install_file = install_dir / "install_id" + install_file.write_text(existing + "\n") + before = install_file.stat().st_mtime_ns + + assert telemetry.get_install_id() == existing + assert install_file.stat().st_mtime_ns == before + + +def test_get_install_id_regenerates_corrupt_file(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + install_dir = tmp_path / ".atlas-ai" + install_dir.mkdir() + install_file = install_dir / "install_id" + install_file.write_text("not-a-uuid\n") + + install_id = telemetry.get_install_id() + + assert uuid.UUID(install_id).version == 4 + assert install_id != "not-a-uuid" + assert install_file.read_text().strip() == install_id + + +class _Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"ok":true}' + + +def _run_threads_inline(monkeypatch): + threads = [] + + class ImmediateThread: + def __init__(self, *, target, daemon): + self.target = target + self.daemon = daemon + threads.append(self) + + def start(self): + self.target() + + threading_mod = getattr(telemetry, "threading", SimpleNamespace()) + monkeypatch.setattr(threading_mod, "Thread", ImmediateThread, raising=False) + monkeypatch.setattr(telemetry, "threading", threading_mod, raising=False) + return threads + + +def _patch_urlopen(monkeypatch, fake_urlopen): + request_mod = getattr(telemetry, "urllib_request", SimpleNamespace()) + monkeypatch.setattr(request_mod, "urlopen", fake_urlopen, raising=False) + monkeypatch.setattr(telemetry, "urllib_request", request_mod, raising=False) + + +def test_send_event_posts_exact_payload_in_daemon_thread(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + threads = _run_threads_inline(monkeypatch) + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["timeout"] = timeout + captured["payload"] = json.loads(request.data.decode("utf-8")) + return _Response() + + _patch_urlopen(monkeypatch, fake_urlopen) + + telemetry.send_event("atlas_invoked") + + assert len(threads) == 1 + assert threads[0].daemon is True + assert captured["url"] == "https://api.atlas-ai.au/telemetry" + assert captured["method"] == "POST" + assert captured["timeout"] == 1 + assert set(captured["payload"]) == {"install_id", "event", "version", "os"} + assert uuid.UUID(captured["payload"]["install_id"]).version == 4 + assert captured["payload"]["event"] == "atlas_invoked" + assert captured["payload"]["version"] == __version__ + assert captured["payload"]["os"] == platform.system() + + +def test_send_event_env_opt_out_skips_before_install_id_or_network(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("ATLAS_TELEMETRY", "0") + calls = [] + _run_threads_inline(monkeypatch) + _patch_urlopen(monkeypatch, lambda request, timeout: calls.append(request)) + + telemetry.send_event("atlas_invoked") + + assert calls == [] + assert not (tmp_path / ".atlas-ai" / "install_id").exists() + + +def test_send_event_config_opt_out_skips_before_install_id_or_network(monkeypatch, tmp_path): + cases = [ + (tmp_path / "home-config", tmp_path / "home-config", tmp_path / "home-config" / ".atlas-ai" / "config.json"), + ( + tmp_path / "project-config", + tmp_path / "project-config" / "project", + tmp_path / "project-config" / "project" / ".taskmaster" / "config.json", + ), + ] + for home, cwd, target in cases: + cwd.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.chdir(cwd) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('{"telemetry": false}\n') + calls = [] + _run_threads_inline(monkeypatch) + _patch_urlopen(monkeypatch, lambda request, timeout: calls.append(request)) + + telemetry.send_event("reach_execute") + + assert calls == [] + assert not (home / ".atlas-ai" / "install_id").exists() + + +def test_send_event_swallow_errors_and_does_not_join(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + started = [] + + class RecordingThread: + def __init__(self, *, target, daemon): + self.target = target + self.daemon = daemon + + def start(self): + started.append(self) + + def join(self, *args, **kwargs): + raise AssertionError("send_event must not join telemetry threads") + + threading_mod = getattr(telemetry, "threading", SimpleNamespace()) + monkeypatch.setattr(threading_mod, "Thread", RecordingThread, raising=False) + monkeypatch.setattr(telemetry, "threading", threading_mod, raising=False) + _patch_urlopen(monkeypatch, lambda request, timeout: (_ for _ in ()).throw(socket.timeout("down"))) + + telemetry.send_event("ship_check_ok") + + assert len(started) == 1 + assert started[0].daemon is True + started[0].target() + + +def test_send_event_invalid_event_is_noop(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + calls = [] + _run_threads_inline(monkeypatch) + _patch_urlopen(monkeypatch, lambda request, timeout: calls.append(request)) + + telemetry.send_event("goal_text_or_path") + + assert calls == [] + assert not (tmp_path / ".atlas-ai" / "install_id").exists() + + +def test_preflight_fires_atlas_invoked_once(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + events = [] + monkeypatch.setattr(telemetry, "send_event", lambda event: events.append(event)) + + result = pipeline.preflight() + + assert result["ok"] is True + assert events == ["atlas_invoked"] + + +def test_advance_phase_fires_reach_execute_only_for_execute(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + events = [] + monkeypatch.setattr(telemetry, "send_event", lambda event: events.append(event)) + + assert pipeline.advance_phase(None, "SETUP", {})["ok"] is True + assert pipeline.advance_phase("SETUP", "DISCOVER", {})["ok"] is True + assert pipeline.advance_phase("DISCOVER", "GENERATE", {})["ok"] is True + assert pipeline.advance_phase("GENERATE", "HANDOFF", {})["ok"] is True + assert events == [] + + result = pipeline.advance_phase("HANDOFF", "EXECUTE", {}) + + assert result["ok"] is True + assert events == ["reach_execute"] + + +def _green_ship_project(root): + atlas = root / ".atlas-ai" + (atlas / "state").mkdir(parents=True) + (atlas / "state" / "pipeline.json").write_text(json.dumps({"current_phase": "EXECUTE"})) + tasks = root / ".taskmaster" / "tasks" + tasks.mkdir(parents=True) + (tasks / "tasks.json").write_text( + json.dumps({"master": {"tasks": [{"id": 1, "status": "done"}]}}) + ) + cdd = atlas / "cdd" + cdd.mkdir(parents=True) + (cdd / "task-1.json").write_text("{}") + docs = root / ".taskmaster" / "docs" + docs.mkdir(parents=True) + (docs / "plan.md").write_text("# Plan\n") + + +def test_ship_check_fires_ship_check_ok_only_on_real_pass(monkeypatch, tmp_path): + _green_ship_project(tmp_path) + events = [] + monkeypatch.setattr(telemetry, "send_event", lambda event: events.append(event)) + + result = shipcheck.run_ship_check(cwd=str(tmp_path)) + dry_run = shipcheck.run_ship_check(cwd=str(tmp_path), dry_run=True) + + assert result["passed"] is True + assert dry_run["passed"] is True + assert events == ["ship_check_ok"] + + +def test_telemetry_hook_failures_do_not_change_return_values(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + def boom(event): + raise RuntimeError(f"{event} failed") + + monkeypatch.setattr(telemetry, "send_event", boom) + + assert pipeline.preflight()["ok"] is True + assert pipeline.advance_phase(None, "SETUP", {})["ok"] is True + assert pipeline.advance_phase("SETUP", "DISCOVER", {})["ok"] is True + assert pipeline.advance_phase("DISCOVER", "GENERATE", {})["ok"] is True + assert pipeline.advance_phase("GENERATE", "HANDOFF", {})["ok"] is True + assert pipeline.advance_phase("HANDOFF", "EXECUTE", {})["ok"] is True + + ship_root = tmp_path / "ship" + ship_root.mkdir() + _green_ship_project(ship_root) + result = shipcheck.run_ship_check(cwd=str(ship_root)) + + assert result["passed"] is True + assert result["exit_code"] == 0 diff --git a/tests/test_ux_conformance.py b/tests/test_ux_conformance.py new file mode 100644 index 0000000..63f51af --- /dev/null +++ b/tests/test_ux_conformance.py @@ -0,0 +1,191 @@ +import ast +import re +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_extract_md_user_strings_reads_frontmatter_description_and_body(tmp_path): + path = tmp_path / "SKILL.md" + path.write_text( + "---\n" + "name: sample\n" + "description: >-\n" + " Frontmatter description text.\n" + "---\n" + "# Body Heading\n\n" + "Body text here.\n" + ) + + extracted = extract_md_user_strings(path) + + assert "Frontmatter description text." in extracted + assert "# Body Heading" in extracted + assert "Body text here." in extracted + + +def test_extract_py_string_literals_collects_constants(tmp_path): + path = tmp_path / "sample.py" + path.write_text( + "MESSAGE = 'Atlas Fleet is part of Atlas Pro ($29/mo)'\n" + "def reason():\n" + " return f'Unlock: {\"https://atlas-ai.au/pro\"}'\n" + ) + + extracted = extract_py_string_literals(path) + + assert "Atlas Fleet is part of Atlas Pro ($29/mo)" in extracted + assert "Unlock: " in extracted + assert "https://atlas-ai.au/pro" in extracted + + +def test_banned_vocab_rule_catches_primary_labels(): + text = "phoenix CDD ralph-loop Mode D" + + violations = check_banned_vocab(text, path=Path("fixture.md")) + + assert [violation.term for violation in violations] == [ + "phoenix", + "CDD", + "ralph-loop", + "Mode D", + ] + + +def test_banned_vocab_rule_allows_canonical_text_and_identifiers(): + text = ( + "worker verified execution evidence-gated loop fleet mode Atlas Fleet\n" + ".atlas-ai/cdd/task-1.json\n" + "'ralph-loop' capability key\n" + '"requiresCDD": true\n' + ) + + assert check_banned_vocab(text, path=Path("fixture.md")) == [] + + +def test_repo_user_facing_vocab_lint_has_no_violations(): + violations: list[Violation] = [] + for path in iter_user_facing_files(): + text = path.read_text() + violations.extend(check_banned_vocab(text, path=path.relative_to(REPO_ROOT))) + + assert violations == [] + + +def test_paywall_surfaces_inline_price_pro_url_and_free_default(): + handoff = (REPO_ROOT / "skills" / "handoff" / "SKILL.md").read_text() + + locked_index = handoff.index("Atlas Fleet is a locked teaser") + locked_context = handoff[locked_index : locked_index + 1200] + assert "$29/mo" in locked_context + assert "https://atlas-ai.au/pro" in locked_context + assert "Fleet is never default while locked" in handoff + assert "recommended free mode as the default" in handoff + assert "estimate from your dependency graph" in handoff + assert "actual time varies" in handoff + + all_text = "\n".join(path.read_text() for path in iter_user_facing_files()) + assert "waitlist" not in all_text.lower() + assert "atlas-ai.au/waitlist" not in all_text + + upgrade_urls = sorted(set(re.findall(r"https://atlas-ai\.au/[A-Za-z0-9_./-]+", all_text))) + assert all(url == "https://atlas-ai.au/pro" for url in upgrade_urls if "/pro" in url or "waitlist" in url) + + +def iter_user_facing_files() -> list[Path]: + files: list[Path] = [] + files.extend(sorted((REPO_ROOT / "skills").glob("*/SKILL.md"))) + files.extend(sorted((REPO_ROOT / "phases").glob("*.md"))) + files.extend(sorted((REPO_ROOT / "templates").glob("*.md"))) + files.extend( + REPO_ROOT / path + for path in ( + "README.md", + "install.sh", + "hooks/mode_d_blocker.py", + "prd_taskmaster/mode_recommend.py", + "prd_taskmaster/capabilities.py", + "mcp-server/server.py", + ) + ) + return files + + +class Violation: + def __init__(self, path: Path, line: int, term: str): + self.path = path + self.line = line + self.term = term + + def __eq__(self, other): + return ( + isinstance(other, Violation) + and self.path == other.path + and self.line == other.line + and self.term == other.term + ) + + def __repr__(self): + return f"{self.path}:{self.line}:{self.term}" + + +def check_banned_vocab(text: str, *, path: Path) -> list[Violation]: + patterns = [ + ("phoenix", re.compile(r"\bphoenix\b", re.IGNORECASE)), + ("CDD", re.compile(r"\bCDD\b", re.IGNORECASE)), + ("ralph-loop", re.compile(r"\bralph-loop\b", re.IGNORECASE)), + ("Mode D", re.compile(r"\bMode\s+D\b", re.IGNORECASE)), + ] + violations: list[Violation] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + if _is_identifier_exception(line): + continue + for term, pattern in patterns: + if pattern.search(line): + violations.append(Violation(path, line_number, term)) + return violations + + +def _is_identifier_exception(line: str) -> bool: + identifier_fragments = ( + ".atlas-ai/cdd/", + '"requiresCDD"', + '"cddCardId"', + "'ralph-loop' capability key", + "`ralph-loop` capability key", + '"ralph-loop"', + "'ralph-loop'", + "mcp__atlas-cdd__", + "atlas-cdd", + "WORKER_CONTRACT_CDD_CARD", + ".claude/ralph-loop.local.md", + "/ralph-loop", + ) + return any(fragment in line for fragment in identifier_fragments) + + +def extract_md_user_strings(path: Path) -> str: + text = path.read_text() + parts: list[str] = [] + if text.startswith("---\n"): + _start, frontmatter, body = text.split("---", 2) + metadata = yaml.safe_load(frontmatter) or {} + description = metadata.get("description") + if description: + parts.append(str(description)) + parts.append(body.lstrip()) + else: + parts.append(text) + return "\n".join(parts) + + +def extract_py_string_literals(path: Path) -> str: + tree = ast.parse(path.read_text()) + values: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + values.append(node.value) + return "\n".join(values) diff --git a/workers/.dev.vars.example b/workers/.dev.vars.example new file mode 100644 index 0000000..98a4f9b --- /dev/null +++ b/workers/.dev.vars.example @@ -0,0 +1,4 @@ +STRIPE_WEBHOOK_SECRET=<stripe-cli-or-dashboard-webhook-secret> +STRIPE_API_KEY=<stripe-test-or-live-api-key> +ED25519_PRIVATE_KEY=<32-byte-ed25519-seed-hex> +RESEND_API_KEY=<resend-api-key> diff --git a/workers/.gitignore b/workers/.gitignore new file mode 100644 index 0000000..690da05 --- /dev/null +++ b/workers/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.wrangler/ +.dev.vars +.npm-cache/ diff --git a/workers/.npmrc b/workers/.npmrc new file mode 100644 index 0000000..797f117 --- /dev/null +++ b/workers/.npmrc @@ -0,0 +1,2 @@ +cache=.npm-cache +fund=false diff --git a/workers/migrations/0001_licenses.sql b/workers/migrations/0001_licenses.sql new file mode 100644 index 0000000..ca3d5a4 --- /dev/null +++ b/workers/migrations/0001_licenses.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS licenses ( + lid TEXT PRIMARY KEY, + sub_hash TEXT NOT NULL, + plan TEXT NOT NULL, + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + cancelled_at INTEGER, + created_at INTEGER DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_licenses_sub_hash + ON licenses (sub_hash); + +CREATE INDEX IF NOT EXISTS idx_licenses_stripe_subscription_id + ON licenses (stripe_subscription_id); + +CREATE TABLE IF NOT EXISTS processed_events ( + event_id TEXT PRIMARY KEY, + processed_at INTEGER NOT NULL +); diff --git a/workers/migrations/0002_rate_limits.sql b/workers/migrations/0002_rate_limits.sql new file mode 100644 index 0000000..e781da7 --- /dev/null +++ b/workers/migrations/0002_rate_limits.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS rate_limits ( + lid TEXT NOT NULL, + day TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (lid, day) +); diff --git a/workers/migrations/0003_telemetry.sql b/workers/migrations/0003_telemetry.sql new file mode 100644 index 0000000..423ef0a --- /dev/null +++ b/workers/migrations/0003_telemetry.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS telemetry_events ( + id INTEGER PRIMARY KEY, + install_id TEXT NOT NULL, + event TEXT NOT NULL, + version TEXT NOT NULL, + os TEXT NOT NULL, + timestamp TEXT NOT NULL +); diff --git a/workers/package-lock.json b/workers/package-lock.json new file mode 100644 index 0000000..0b56aa4 --- /dev/null +++ b/workers/package-lock.json @@ -0,0 +1,4831 @@ +{ + "name": "@atlas-ai/worker-backend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@atlas-ai/worker-backend", + "version": "0.0.0", + "dependencies": { + "stripe": "^19.0.0" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.8.69", + "@cloudflare/workers-types": "^4.20260610.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4", + "wrangler": "^4.20.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.8.71", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.71.tgz", + "integrity": "sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "birpc": "0.2.14", + "cjs-module-lexer": "^1.2.3", + "devalue": "^5.3.2", + "miniflare": "4.20250906.0", + "semver": "^7.7.1", + "wrangler": "4.35.0", + "zod": "^3.22.3" + }, + "peerDependencies": { + "@vitest/runner": "2.0.x - 3.2.x", + "@vitest/snapshot": "2.0.x - 3.2.x", + "vitest": "2.0.x - 3.2.x" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.3.tgz", + "integrity": "sha512-tsQQagBKjvpd9baa6nWVIv399ejiqcrUBBW6SZx6Z22+ymm+Odv5+cFimyuCsD/fC1fQTwfRmwXBNpzvHSeGCw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.21", + "workerd": "^1.20250828.1" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/unenv": { + "version": "2.0.0-rc.21", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.21.tgz", + "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.7", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "ufo": "^1.6.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.35.0.tgz", + "integrity": "sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.0", + "@cloudflare/unenv-preset": "2.7.3", + "blake3-wasm": "2.1.5", + "esbuild": "0.25.4", + "miniflare": "4.20250906.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.21", + "workerd": "1.20250906.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250906.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250906.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250906.0.tgz", + "integrity": "sha512-E+X/YYH9BmX0ew2j/mAWFif2z05NMNuhCTlNYEGLkqMe99K15UewBqajL9pMcMUKxylnlrEoK3VNxl33DkbnPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250906.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250906.0.tgz", + "integrity": "sha512-X5apsZ1SFW4FYTM19ISHf8005FJMPfrcf4U5rO0tdj+TeJgQgXuZ57IG0WeW7SpLVeBo8hM6WC8CovZh41AfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250906.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250906.0.tgz", + "integrity": "sha512-rlKzWgsLnlQ5Nt9W69YBJKcmTmZbOGu0edUsenXPmc6wzULUxoQpi7ZE9k3TfTonJx4WoQsQlzCUamRYFsX+0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250906.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250906.0.tgz", + "integrity": "sha512-DdedhiQ+SeLzpg7BpcLrIPEZ33QKioJQ1wvL4X7nuLzEB9rWzS37NNNahQzc1+44rhG4fyiHbXBPOeox4B9XVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250906.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250906.0.tgz", + "integrity": "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260611.1.tgz", + "integrity": "sha512-DLiz8Ol1OIWLigJ+dGvuQ5Nm66D/CHNPasl8YnPiz6fGo10ggYSIVuEDMlFk6oho+piAHstNmZMl088w8xqW6g==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.16.tgz", + "integrity": "sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/birpc": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz", + "integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "4.20250906.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250906.0.tgz", + "integrity": "sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "sharp": "^0.33.5", + "stoppable": "1.1.0", + "undici": "^7.10.0", + "workerd": "1.20250906.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/miniflare/node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/stripe": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.3.0.tgz", + "integrity": "sha512-3MbqRkw5LXb4LWP1LgIEYxUAYhYDDU5pcHZj4Xha6VWPnN1wrUmQ7Htsgm8wR584s0hn1aQg1lYD0Hi+F37E5g==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20250906.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250906.0.tgz", + "integrity": "sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250906.0", + "@cloudflare/workerd-darwin-arm64": "1.20250906.0", + "@cloudflare/workerd-linux-64": "1.20250906.0", + "@cloudflare/workerd-linux-arm64": "1.20250906.0", + "@cloudflare/workerd-windows-64": "1.20250906.0" + } + }, + "node_modules/wrangler": { + "version": "4.100.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.100.0.tgz", + "integrity": "sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260611.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260611.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260611.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260611.1.tgz", + "integrity": "sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260611.1.tgz", + "integrity": "sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260611.1.tgz", + "integrity": "sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260611.1.tgz", + "integrity": "sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260611.1.tgz", + "integrity": "sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "4.20260611.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260611.0.tgz", + "integrity": "sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.24.8", + "workerd": "1.20260611.1", + "ws": "8.20.1", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/wrangler/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/wrangler/node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260611.1.tgz", + "integrity": "sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260611.1", + "@cloudflare/workerd-darwin-arm64": "1.20260611.1", + "@cloudflare/workerd-linux-64": "1.20260611.1", + "@cloudflare/workerd-linux-arm64": "1.20260611.1", + "@cloudflare/workerd-windows-64": "1.20260611.1" + } + }, + "node_modules/wrangler/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/workers/package.json b/workers/package.json new file mode 100644 index 0000000..bf2d9f1 --- /dev/null +++ b/workers/package.json @@ -0,0 +1,24 @@ +{ + "name": "@atlas-ai/worker-backend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "XDG_CONFIG_HOME=.wrangler/xdg-config WRANGLER_LOG_PATH=.wrangler/logs vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "dev": "XDG_CONFIG_HOME=.wrangler/xdg-config WRANGLER_LOG_PATH=.wrangler/logs wrangler dev", + "deploy:dry-run": "XDG_CONFIG_HOME=.wrangler/xdg-config WRANGLER_LOG_PATH=.wrangler/logs wrangler deploy --dry-run --outdir dist", + "d1:migrate:local": "XDG_CONFIG_HOME=.wrangler/xdg-config WRANGLER_LOG_PATH=.wrangler/logs wrangler d1 migrations apply LICENSE_DB --local" + }, + "dependencies": { + "stripe": "^19.0.0" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.8.69", + "@cloudflare/workers-types": "^4.20260610.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4", + "wrangler": "^4.20.0" + } +} diff --git a/workers/src/email.ts b/workers/src/email.ts new file mode 100644 index 0000000..8fbed3d --- /dev/null +++ b/workers/src/email.ts @@ -0,0 +1,133 @@ +const RESEND_EMAIL_URL = "https://api.resend.com/emails"; +const FROM_ADDRESS = "Atlas AI <licenses@atlas-ai.au>"; +const SUPPORT_EMAIL = "support@atlas-ai.au"; +const ACTIVATION_DOCS_URL = "https://atlas-ai.au/docs/activation"; +const RETRY_DELAYS_MS = [1000, 2000, 4000]; +const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; + +export type LicenseEmailPlan = "pro-monthly" | "pro-annual"; + +type EmailFetcher = (input: string, init: RequestInit) => Promise<Response>; +type Sleep = (delayMs: number) => Promise<void>; + +export interface SendLicenseEmailOptions { + fetcher?: EmailFetcher; + sleep?: Sleep; + idempotencyKey?: string; +} + +export class EmailDeliveryError extends Error { + override name = "EmailDeliveryError"; + + constructor( + message: string, + readonly attempts: number, + readonly transient: boolean, + readonly status?: number + ) { + super(message); + } +} + +function defaultSleep(delayMs: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +function planLabel(plan: LicenseEmailPlan): string { + return plan === "pro-annual" ? "Atlas Pro Annual" : "Atlas Pro Monthly"; +} + +function buildEmailBody(key: string, plan: LicenseEmailPlan): { text: string; html: string } { + const command = `script.py license-activate ${key}`; + const pluginCommand = `/license-activate ${key}`; + const text = [ + "Your Atlas Pro license key is ready.", + "", + `Plan: ${planLabel(plan)}`, + `License key: ${key}`, + "", + `Activate from the CLI: ${command}`, + `Activate from Claude Code: ${pluginCommand}`, + `Activation docs: ${ACTIVATION_DOCS_URL}`, + "", + `Support: ${SUPPORT_EMAIL}` + ].join("\n"); + const escapedKey = escapeHtml(key); + const html = [ + "<p>Your Atlas Pro license key is ready.</p>", + `<p><strong>Plan:</strong> ${escapeHtml(planLabel(plan))}</p>`, + `<p><strong>License key:</strong> <code>${escapedKey}</code></p>`, + `<p><strong>CLI:</strong> <code>script.py license-activate ${escapedKey}</code></p>`, + `<p><strong>Claude Code:</strong> <code>/license-activate ${escapedKey}</code></p>`, + `<p><a href="${ACTIVATION_DOCS_URL}">Activation docs</a></p>`, + `<p>Support: <a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a></p>` + ].join(""); + + return { text, html }; +} + +export async function sendLicenseEmail( + toEmail: string, + key: string, + plan: LicenseEmailPlan, + resendApiKey: string, + options: SendLicenseEmailOptions = {} +): Promise<void> { + const fetcher: EmailFetcher = options.fetcher ?? ((input, init) => fetch(input, init)); + const sleep = options.sleep ?? defaultSleep; + const { text, html } = buildEmailBody(key, plan); + const headers: Record<string, string> = { + authorization: `Bearer ${resendApiKey}`, + "content-type": "application/json" + }; + if (options.idempotencyKey) { + headers["idempotency-key"] = options.idempotencyKey; + } + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + const response = await fetcher(RESEND_EMAIL_URL, { + method: "POST", + headers, + body: JSON.stringify({ + from: FROM_ADDRESS, + to: [toEmail], + subject: "Your Atlas Pro License Key", + text, + html + }) + }); + + if (response.ok) { + return; + } + + const transient = response.status >= 500; + if (!transient || attempt === MAX_ATTEMPTS) { + throw new EmailDeliveryError( + `Resend email delivery failed with HTTP ${response.status}`, + attempt, + transient, + response.status + ); + } + } catch (error) { + if (error instanceof EmailDeliveryError) { + throw error; + } + if (attempt === MAX_ATTEMPTS) { + throw new EmailDeliveryError("Resend email delivery failed after a network error", attempt, true); + } + } + + await sleep(RETRY_DELAYS_MS[attempt - 1]); + } +} diff --git a/workers/src/index.ts b/workers/src/index.ts new file mode 100644 index 0000000..a733572 --- /dev/null +++ b/workers/src/index.ts @@ -0,0 +1,45 @@ +import type { Env } from "./types"; +import { handleLicenseRefresh } from "./refresh"; +import { handleStripeWebhook } from "./stripe"; +import { handleTelemetry } from "./telemetry"; + +const POST_ONLY_ROUTES = new Set([ + "/stripe/webhook", + "/telemetry" +]); + +function methodNotAllowed(): Response { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "POST" } + }); +} + +function notImplemented(): Response { + return new Response("Not Implemented", { status: 501 }); +} + +export default { + async fetch(request, env, ctx): Promise<Response> { + const { pathname } = new URL(request.url); + + if (pathname === "/license/refresh") { + return handleLicenseRefresh(request, env); + } + + if (POST_ONLY_ROUTES.has(pathname)) { + if (request.method !== "POST") { + return methodNotAllowed(); + } + if (pathname === "/stripe/webhook") { + return handleStripeWebhook(request, env, ctx); + } + if (pathname === "/telemetry") { + return handleTelemetry(request, env); + } + return notImplemented(); + } + + return new Response("Not Found", { status: 404 }); + } +} satisfies ExportedHandler<Env>; diff --git a/workers/src/license-signer.ts b/workers/src/license-signer.ts new file mode 100644 index 0000000..28e63f2 --- /dev/null +++ b/workers/src/license-signer.ts @@ -0,0 +1,97 @@ +const KEY_PREFIX = "ATLAS-"; +const PKCS8_ED25519_PREFIX = "302e020100300506032b657004220420"; +const PLANS = new Set(["pro-monthly", "pro-annual"]); + +export interface LicensePayload { + lid: string; + sub: string; + plan: "pro-monthly" | "pro-annual"; + iat: number; + exp: number; + v: 1; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + } + const record = value as Record<string, unknown>; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function hexToBytes(hex: string): Uint8Array { + if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) { + throw new Error("hex input must contain an even number of hex characters"); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function validatePayload(payload: LicensePayload): void { + const keys = Object.keys(payload).sort().join(","); + if (keys !== "exp,iat,lid,plan,sub,v") { + throw new Error("payload fields must be exactly lid, sub, plan, iat, exp, v"); + } + if (!payload.lid) { + throw new Error("lid must be a non-empty string"); + } + if (!/^[0-9a-f]{64}$/.test(payload.sub)) { + throw new Error("sub must be a lowercase SHA-256 hex digest"); + } + if (!PLANS.has(payload.plan)) { + throw new Error("plan must be pro-monthly or pro-annual"); + } + if (!Number.isInteger(payload.iat) || !Number.isInteger(payload.exp)) { + throw new Error("iat and exp must be integer Unix timestamps"); + } + if (payload.exp < payload.iat) { + throw new Error("exp must be greater than or equal to iat"); + } + if (payload.v !== 1) { + throw new Error("v must be 1"); + } +} + +export function canonicalPayloadBytes(payload: LicensePayload): Uint8Array { + validatePayload(payload); + return new TextEncoder().encode(canonicalJson(payload)); +} + +export async function signLicensePayload( + payload: LicensePayload, + privateSeedHex: string +): Promise<string> { + const seed = hexToBytes(privateSeedHex); + if (seed.length !== 32) { + throw new Error("Ed25519 private seed must be 32 bytes"); + } + + const privateKey = await crypto.subtle.importKey( + "pkcs8", + hexToBytes(`${PKCS8_ED25519_PREFIX}${privateSeedHex}`), + { name: "Ed25519" }, + false, + ["sign"] + ); + const payloadBytes = canonicalPayloadBytes(payload); + const signature = new Uint8Array(await crypto.subtle.sign("Ed25519", privateKey, payloadBytes)); + + return `${KEY_PREFIX}${bytesToBase64Url(payloadBytes)}.${bytesToBase64Url(signature)}`; +} diff --git a/workers/src/refresh.ts b/workers/src/refresh.ts new file mode 100644 index 0000000..5618c06 --- /dev/null +++ b/workers/src/refresh.ts @@ -0,0 +1,181 @@ +import { signLicensePayload, type LicensePayload } from "./license-signer"; +import type { Env } from "./types"; + +const LICENSE_GRACE_SECONDS = 14 * 24 * 60 * 60; +const ACTIVE_STATUSES = new Set(["active", "trialing"]); +const CORS_HEADERS = { + "access-control-allow-origin": "https://atlas-ai.au", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "content-type" +}; + +interface LicenseRow { + lid: string; + sub_hash: string; + plan: "pro-monthly" | "pro-annual"; + stripe_subscription_id: string | null; + cancelled_at: number | null; +} + +interface StripeSubscriptionResponse { + id?: unknown; + status?: unknown; + current_period_end?: unknown; +} + +function json(data: unknown, status = 200): Response { + return Response.json(data, { + status, + headers: CORS_HEADERS + }); +} + +function methodNotAllowed(): Response { + return new Response("Method Not Allowed", { + status: 405, + headers: { + ...CORS_HEADERS, + Allow: "POST, OPTIONS" + } + }); +} + +function dayKey(): string { + return new Date(Date.now()).toISOString().slice(0, 10); +} + +async function readLid(request: Request): Promise<string | null> { + let body: unknown; + try { + body = await request.json(); + } catch { + return null; + } + + if ( + !body || + typeof body !== "object" || + !("lid" in body) || + typeof body.lid !== "string" || + body.lid.length === 0 + ) { + return null; + } + return body.lid; +} + +async function consumeRateLimit(env: Env, lid: string): Promise<number> { + const row = await env.LICENSE_DB.prepare( + `INSERT INTO rate_limits (lid, day, count) + VALUES (?, ?, 1) + ON CONFLICT(lid, day) DO UPDATE SET count = count + 1 + RETURNING count` + ) + .bind(lid, dayKey()) + .first<{ count: number }>(); + + return row?.count ?? 1; +} + +async function retrieveStripeSubscription( + env: Env, + subscriptionId: string +): Promise<{ status: string; currentPeriodEnd: number } | null> { + const response = await fetch( + `https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, + { + method: "GET", + headers: { + authorization: `Bearer ${env.STRIPE_API_KEY}` + } + } + ); + + if (!response.ok) { + console.log("license_refresh.stripe_subscription_lookup_failed", { + subscriptionId, + status: response.status + }); + return null; + } + + const subscription = await response.json() as StripeSubscriptionResponse; + if ( + typeof subscription.status !== "string" || + !Number.isInteger(subscription.current_period_end) + ) { + return null; + } + + return { + status: subscription.status, + currentPeriodEnd: subscription.current_period_end as number + }; +} + +async function findLicense(env: Env, lid: string): Promise<LicenseRow | null> { + return env.LICENSE_DB.prepare( + `SELECT lid, sub_hash, plan, stripe_subscription_id, cancelled_at + FROM licenses + WHERE lid = ?` + ) + .bind(lid) + .first<LicenseRow>(); +} + +async function markCancelled(env: Env, lid: string): Promise<void> { + await env.LICENSE_DB.prepare("UPDATE licenses SET cancelled_at = ? WHERE lid = ?") + .bind(Math.floor(Date.now() / 1000), lid) + .run(); +} + +export async function handleLicenseRefresh(request: Request, env: Env): Promise<Response> { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + if (request.method !== "POST") { + return methodNotAllowed(); + } + + const lid = await readLid(request); + if (!lid) { + return json({ ok: false }, 400); + } + + const rateLimitCount = await consumeRateLimit(env, lid); + if (rateLimitCount > 10) { + return json({ ok: false, reason: "rate_limited" }, 429); + } + + const license = await findLicense(env, lid); + if (!license) { + return json({ ok: false, reason: "license_not_found" }, 403); + } + if (license.cancelled_at !== null || !license.stripe_subscription_id) { + return json({ ok: false, reason: "subscription_cancelled" }, 403); + } + + const subscription = await retrieveStripeSubscription(env, license.stripe_subscription_id); + if (!subscription || !ACTIVE_STATUSES.has(subscription.status)) { + await markCancelled(env, lid); + return json({ ok: false, reason: "subscription_cancelled" }, 403); + } + + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAt = subscription.currentPeriodEnd + LICENSE_GRACE_SECONDS; + await env.LICENSE_DB.prepare("UPDATE licenses SET expires_at = ? WHERE lid = ?") + .bind(expiresAt, lid) + .run(); + + const payload: LicensePayload = { + lid: license.lid, + sub: license.sub_hash, + plan: license.plan, + iat: issuedAt, + exp: expiresAt, + v: 1 + }; + const key = await signLicensePayload(payload, env.ED25519_PRIVATE_KEY); + + return json({ ok: true, key }); +} diff --git a/workers/src/stripe.ts b/workers/src/stripe.ts new file mode 100644 index 0000000..208ef0a --- /dev/null +++ b/workers/src/stripe.ts @@ -0,0 +1,314 @@ +import Stripe from "stripe"; +import { EmailDeliveryError, sendLicenseEmail, type LicenseEmailPlan } from "./email"; +import { signLicensePayload, type LicensePayload } from "./license-signer"; +import type { Env } from "./types"; + +const LICENSE_GRACE_SECONDS = 14 * 24 * 60 * 60; + +interface StripeSubscriptionLike { + id?: unknown; + current_period_end?: unknown; +} + +interface StripeInvoiceLike { + subscription?: unknown; + customer_email?: unknown; + customer_details?: { + email?: unknown; + }; + period_end?: unknown; + lines?: { + data?: Array<{ + period?: { + end?: unknown; + }; + }>; + }; +} + +function json(data: unknown, status = 200): Response { + return Response.json(data, { status }); +} + +function stripeClient(env: Env): Stripe { + return new Stripe(env.STRIPE_API_KEY, { + httpClient: Stripe.createFetchHttpClient(), + maxNetworkRetries: 1 + }); +} + +function queueLicenseEmail( + ctx: ExecutionContext, + env: Env, + message: { toEmail: string; key: string; plan: LicenseEmailPlan; lid: string } +): void { + ctx.waitUntil( + sendLicenseEmail(message.toEmail, message.key, message.plan, env.RESEND_API_KEY, { + idempotencyKey: `license-${message.lid}` + }).catch((error: unknown) => { + if (error instanceof EmailDeliveryError) { + console.error("resend.license_email.failed", { + lid: message.lid, + attempts: error.attempts, + transient: error.transient, + status: error.status, + message: error.message + }); + return; + } + console.error("resend.license_email.failed", { + lid: message.lid, + message: "Unexpected email delivery failure" + }); + }) + ); +} + +async function verifyStripeEvent(request: Request, env: Env): Promise<Stripe.Event | null> { + const signature = request.headers.get("stripe-signature"); + if (!signature) { + return null; + } + + const rawBody = await request.text(); + try { + return await stripeClient(env).webhooks.constructEventAsync( + rawBody, + signature, + env.STRIPE_WEBHOOK_SECRET, + undefined, + Stripe.createSubtleCryptoProvider() + ); + } catch { + return null; + } +} + +function objectId(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object" && "id" in value && typeof value.id === "string") { + return value.id; + } + return null; +} + +async function sha256Hex(value: string): Promise<string> { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function subscriptionPeriodEnd( + env: Env, + subscriptionValue: unknown +): Promise<{ id: string; currentPeriodEnd: number } | null> { + const id = objectId(subscriptionValue); + if (!id) { + return null; + } + const inlineSubscription = subscriptionValue as StripeSubscriptionLike; + if ( + typeof inlineSubscription === "object" && + inlineSubscription !== null && + Number.isInteger(inlineSubscription.current_period_end) + ) { + return { id, currentPeriodEnd: inlineSubscription.current_period_end as number }; + } + + const subscription = await stripeClient(env).subscriptions.retrieve(id) as unknown as StripeSubscriptionLike; + if (typeof subscription.id === "string" && Number.isInteger(subscription.current_period_end)) { + return { + id: subscription.id, + currentPeriodEnd: subscription.current_period_end as number + }; + } + return null; +} + +async function handleCheckoutSessionCompleted( + event: Stripe.Event, + env: Env, + ctx: ExecutionContext +): Promise<Response> { + const session = event.data.object as Stripe.Checkout.Session; + const email = session.customer_details?.email; + const plan = session.metadata?.plan; + const customerId = objectId(session.customer); + const subscription = await subscriptionPeriodEnd(env, session.subscription); + + if (!email || !customerId || !subscription || (plan !== "pro-monthly" && plan !== "pro-annual")) { + return json({ ok: false }, 400); + } + + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAt = subscription.currentPeriodEnd + LICENSE_GRACE_SECONDS; + const lid = `lic_${crypto.randomUUID()}`; + const subHash = await sha256Hex(email.trim().toLowerCase()); + const payload: LicensePayload = { + lid, + sub: subHash, + plan, + iat: issuedAt, + exp: expiresAt, + v: 1 + }; + + await env.LICENSE_DB.prepare( + `INSERT INTO licenses ( + lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, + issued_at, expires_at, cancelled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind(lid, subHash, plan, customerId, subscription.id, issuedAt, expiresAt, null) + .run(); + + const key = await signLicensePayload(payload, env.ED25519_PRIVATE_KEY); + queueLicenseEmail(ctx, env, { toEmail: email, key, plan, lid }); + return json({ ok: true, key, lid }); +} + +function invoiceEmail(invoice: Stripe.Invoice): string | null { + const invoiceLike = invoice as unknown as StripeInvoiceLike; + if (typeof invoiceLike.customer_email === "string") { + return invoiceLike.customer_email; + } + if (typeof invoiceLike.customer_details?.email === "string") { + return invoiceLike.customer_details.email; + } + return null; +} + +function invoicePeriodEnd(invoice: Stripe.Invoice): number | null { + const invoiceLike = invoice as unknown as StripeInvoiceLike; + if (Number.isInteger(invoiceLike.period_end)) { + return invoiceLike.period_end as number; + } + const linePeriodEnd = invoiceLike.lines?.data?.[0]?.period?.end; + return Number.isInteger(linePeriodEnd) ? linePeriodEnd as number : null; +} + +async function handleInvoicePaid( + event: Stripe.Event, + env: Env, + ctx: ExecutionContext +): Promise<Response> { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = objectId((invoice as unknown as StripeInvoiceLike).subscription); + const periodEnd = invoicePeriodEnd(invoice); + const email = invoiceEmail(invoice); + + if (!subscriptionId || periodEnd === null) { + return json({ ok: false }, 400); + } + + const license = await env.LICENSE_DB.prepare( + "SELECT lid, sub_hash, plan FROM licenses WHERE stripe_subscription_id = ?" + ) + .bind(subscriptionId) + .first<{ lid: string; sub_hash: string; plan: "pro-monthly" | "pro-annual" }>(); + + if (!license) { + console.log("stripe.invoice_paid.unknown_subscription", { subscriptionId }); + return json({ ok: true, ignored: true, reason: "unknown_subscription" }); + } + + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAt = periodEnd + LICENSE_GRACE_SECONDS; + await env.LICENSE_DB.prepare( + "UPDATE licenses SET expires_at = ? WHERE stripe_subscription_id = ?" + ) + .bind(expiresAt, subscriptionId) + .run(); + + const key = await signLicensePayload( + { + lid: license.lid, + sub: license.sub_hash, + plan: license.plan, + iat: issuedAt, + exp: expiresAt, + v: 1 + }, + env.ED25519_PRIVATE_KEY + ); + if (email) { + queueLicenseEmail(ctx, env, { toEmail: email, key, plan: license.plan, lid: license.lid }); + } else { + console.log("stripe.invoice_paid.email_missing", { lid: license.lid, subscriptionId }); + } + + return json({ ok: true, key, lid: license.lid }); +} + +async function handleSubscriptionDeleted(event: Stripe.Event, env: Env): Promise<Response> { + const subscription = event.data.object as Stripe.Subscription; + const subscriptionId = objectId(subscription); + + if (!subscriptionId) { + return json({ ok: false }, 400); + } + + const license = await env.LICENSE_DB.prepare( + "SELECT lid FROM licenses WHERE stripe_subscription_id = ?" + ) + .bind(subscriptionId) + .first<{ lid: string }>(); + + if (!license) { + console.log("stripe.subscription_deleted.unknown_subscription", { subscriptionId }); + return json({ ok: true, ignored: true, reason: "unknown_subscription" }); + } + + await env.LICENSE_DB.prepare( + "UPDATE licenses SET cancelled_at = ? WHERE stripe_subscription_id = ?" + ) + .bind(Math.floor(Date.now() / 1000), subscriptionId) + .run(); + + return json({ ok: true, cancelled: true, lid: license.lid }); +} + +async function dispatchStripeEvent( + event: Stripe.Event, + env: Env, + ctx: ExecutionContext +): Promise<Response> { + switch (event.type) { + case "checkout.session.completed": + return handleCheckoutSessionCompleted(event, env, ctx); + case "invoice.paid": + return handleInvoicePaid(event, env, ctx); + case "customer.subscription.deleted": + return handleSubscriptionDeleted(event, env); + default: + return json({ ok: true, ignored: true }); + } +} + +async function markEventProcessed(env: Env, eventId: string): Promise<boolean> { + const result = await env.LICENSE_DB.prepare( + "INSERT OR IGNORE INTO processed_events (event_id, processed_at) VALUES (?, ?)" + ) + .bind(eventId, Math.floor(Date.now() / 1000)) + .run(); + + return result.meta.changes === 1; +} + +export async function handleStripeWebhook( + request: Request, + env: Env, + ctx: ExecutionContext +): Promise<Response> { + const event = await verifyStripeEvent(request, env); + if (!event) { + return json({ ok: false }, 400); + } + + if (!(await markEventProcessed(env, event.id))) { + return json({ ok: true, duplicate: true }); + } + + return dispatchStripeEvent(event, env, ctx); +} diff --git a/workers/src/telemetry.ts b/workers/src/telemetry.ts new file mode 100644 index 0000000..0df760d --- /dev/null +++ b/workers/src/telemetry.ts @@ -0,0 +1,114 @@ +import type { Env } from "./types"; + +const EVENTS = new Set(["install", "atlas_invoked", "reach_execute", "ship_check_ok"]); +const EXACT_KEYS = "event,install_id,os,version"; +const MAX_INSTALL_ID_LENGTH = 96; +const MAX_LABEL_LENGTH = 80; + +interface TelemetryPayload { + install_id: string; + event: "install" | "atlas_invoked" | "reach_execute" | "ship_check_ok"; + version: string; + os: string; +} + +function invalid(): Response { + return new Response(null, { status: 400 }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function isBoundedString(value: unknown, maxLength: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maxLength; +} + +async function readTelemetryPayload(request: Request): Promise<TelemetryPayload | null> { + let body: unknown; + try { + body = await request.json(); + } catch { + return null; + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return null; + } + + const record = body as Record<string, unknown>; + if (Object.keys(record).sort().join(",") !== EXACT_KEYS) { + return null; + } + if ( + !isBoundedString(record.install_id, MAX_INSTALL_ID_LENGTH) || + !isBoundedString(record.version, MAX_LABEL_LENGTH) || + !isBoundedString(record.os, MAX_LABEL_LENGTH) || + typeof record.event !== "string" || + !EVENTS.has(record.event) + ) { + return null; + } + + return { + install_id: record.install_id, + event: record.event as TelemetryPayload["event"], + version: record.version, + os: record.os + }; +} + +function writeAnalytics(env: Env, payload: TelemetryPayload): void { + try { + env.TELEMETRY.writeDataPoint({ + blobs: [payload.event, payload.version, payload.os], + doubles: [1], + indexes: [payload.install_id] + }); + } catch (error) { + console.error("telemetry.analytics_write_failed", { + installId: payload.install_id, + message: error instanceof Error ? error.message : "Unknown Analytics Engine failure" + }); + } +} + +async function insertTelemetryEvent( + env: Env, + payload: TelemetryPayload, + timestamp: string +): Promise<void> { + try { + await env.LICENSE_DB.prepare( + `INSERT INTO telemetry_events (install_id, event, version, os, timestamp) + VALUES (?, ?, ?, ?, ?)` + ) + .bind(payload.install_id, payload.event, payload.version, payload.os, timestamp) + .run(); + } catch (error) { + console.error("telemetry.d1_insert_failed", { + installId: payload.install_id, + event: payload.event, + message: error instanceof Error ? error.message : "Unknown D1 failure" + }); + } +} + +export async function handleTelemetry(request: Request, env: Env): Promise<Response> { + if (request.method !== "POST") { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "POST" } + }); + } + + const payload = await readTelemetryPayload(request); + if (!payload) { + return invalid(); + } + + writeAnalytics(env, payload); + await insertTelemetryEvent(env, payload, new Date(Date.now()).toISOString()); + + return noContent(); +} diff --git a/workers/src/types.ts b/workers/src/types.ts new file mode 100644 index 0000000..20d7f7b --- /dev/null +++ b/workers/src/types.ts @@ -0,0 +1,8 @@ +export interface Env { + LICENSE_DB: D1Database; + TELEMETRY: AnalyticsEngineDataset; + STRIPE_WEBHOOK_SECRET: string; + STRIPE_API_KEY: string; + ED25519_PRIVATE_KEY: string; + RESEND_API_KEY: string; +} diff --git a/workers/test/d1.test.ts b/workers/test/d1.test.ts new file mode 100644 index 0000000..9085d42 --- /dev/null +++ b/workers/test/d1.test.ts @@ -0,0 +1,44 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("LICENSE_DB schema", () => { + it("stores and queries license records", async () => { + await env.LICENSE_DB.prepare( + `INSERT INTO licenses ( + lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, + issued_at, expires_at, cancelled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + "lic_test_001", + "a".repeat(64), + "pro-monthly", + "cus_test", + "sub_test", + 1_717_200_000, + 1_719_720_000, + null + ) + .run(); + + const row = await env.LICENSE_DB.prepare( + "SELECT lid, sub_hash, plan, stripe_subscription_id, expires_at FROM licenses WHERE lid = ?" + ) + .bind("lic_test_001") + .first<{ + lid: string; + sub_hash: string; + plan: string; + stripe_subscription_id: string; + expires_at: number; + }>(); + + expect(row).toEqual({ + lid: "lic_test_001", + sub_hash: "a".repeat(64), + plan: "pro-monthly", + stripe_subscription_id: "sub_test", + expires_at: 1_719_720_000 + }); + }); +}); diff --git a/workers/test/email.test.ts b/workers/test/email.test.ts new file mode 100644 index 0000000..9f58e92 --- /dev/null +++ b/workers/test/email.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmailDeliveryError, sendLicenseEmail } from "../src/email"; + +const LICENSE_KEY = "ATLAS-test.payload.signature"; +const API_KEY = "resend_test_key"; + +function okResponse(): Response { + return Response.json({ id: "email_test" }); +} + +describe("sendLicenseEmail", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("posts a Resend email containing the key and activation instructions", async () => { + const fetchMock = vi.fn(async (_input: string, _init: RequestInit) => okResponse()); + const sleep = vi.fn(async () => undefined); + + await sendLicenseEmail("buyer@example.com", LICENSE_KEY, "pro-monthly", API_KEY, { + fetcher: fetchMock, + sleep + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(String(init?.body)) as { + from: string; + to: string[]; + subject: string; + text: string; + html: string; + }; + + expect(url).toBe("https://api.resend.com/emails"); + expect(init).toMatchObject({ + method: "POST", + headers: { + authorization: `Bearer ${API_KEY}`, + "content-type": "application/json" + } + }); + expect(body).toMatchObject({ + from: "Atlas AI <licenses@atlas-ai.au>", + to: ["buyer@example.com"], + subject: "Your Atlas Pro License Key" + }); + expect(body.text).toContain(LICENSE_KEY); + expect(body.text).toContain(`script.py license-activate ${LICENSE_KEY}`); + expect(body.text).toContain(`/license-activate ${LICENSE_KEY}`); + expect(body.text).toContain("https://atlas-ai.au/docs/activation"); + expect(body.text).toContain("support@atlas-ai.au"); + expect(body.html).toContain(LICENSE_KEY); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("retries transient Resend failures with exponential backoff", async () => { + const fetchMock = vi + .fn(async (_input: string, _init: RequestInit) => okResponse()) + .mockResolvedValueOnce(new Response("temporary", { status: 500 })) + .mockResolvedValueOnce(new Response("temporary", { status: 502 })) + .mockResolvedValueOnce(okResponse()); + const sleep = vi.fn(async () => undefined); + + await sendLicenseEmail("buyer@example.com", LICENSE_KEY, "pro-annual", API_KEY, { + fetcher: fetchMock, + sleep + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenNthCalledWith(1, 1000); + expect(sleep).toHaveBeenNthCalledWith(2, 2000); + }); + + it("throws a typed error after transient retries are exhausted", async () => { + const fetchMock = vi.fn(async (_input: string, _init: RequestInit) => new Response("temporary", { status: 503 })); + const sleep = vi.fn(async () => undefined); + + await expect( + sendLicenseEmail("buyer@example.com", LICENSE_KEY, "pro-monthly", API_KEY, { + fetcher: fetchMock, + sleep + }) + ).rejects.toMatchObject({ + name: "EmailDeliveryError", + attempts: 4, + transient: true, + status: 503 + } satisfies Partial<EmailDeliveryError>); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenNthCalledWith(1, 1000); + expect(sleep).toHaveBeenNthCalledWith(2, 2000); + expect(sleep).toHaveBeenNthCalledWith(3, 4000); + }); + + it("does not retry permanent 4xx Resend errors", async () => { + const fetchMock = vi.fn(async (_input: string, _init: RequestInit) => new Response("unauthorized", { status: 401 })); + const sleep = vi.fn(async () => undefined); + + await expect( + sendLicenseEmail("buyer@example.com", LICENSE_KEY, "pro-monthly", API_KEY, { + fetcher: fetchMock, + sleep + }) + ).rejects.toMatchObject({ + name: "EmailDeliveryError", + attempts: 1, + transient: false, + status: 401 + } satisfies Partial<EmailDeliveryError>); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + }); +}); diff --git a/workers/test/env.d.ts b/workers/test/env.d.ts new file mode 100644 index 0000000..1879796 --- /dev/null +++ b/workers/test/env.d.ts @@ -0,0 +1,12 @@ +import type { Env } from "../src/types"; +import type { D1Migration } from "@cloudflare/vitest-pool-workers/config"; + +declare module "cloudflare:test" { + interface ProvidedEnv extends Env {} +} + +declare module "vitest" { + export interface ProvidedContext { + d1Migrations: D1Migration[]; + } +} diff --git a/workers/test/helpers.ts b/workers/test/helpers.ts new file mode 100644 index 0000000..522799f --- /dev/null +++ b/workers/test/helpers.ts @@ -0,0 +1,81 @@ +import { env } from "cloudflare:test"; +import Stripe from "stripe"; +import type { Env } from "../src/types"; + +export const TEST_STRIPE_WEBHOOK_SECRET = "stripe_webhook_test_secret"; +export const TEST_STRIPE_API_KEY = "stripe_api_test_key"; +export const TEST_PRIVATE_SEED_HEX = + "36c0ed3c3e7c3514443c21504a2d2a3cacc661eba233a2913cb8e7bd9c089053"; +export const TEST_PUBLIC_KEY_HEX = + "1fc868c32afba550e6db6db038302a6bd83fbbd848a87191f3a03bdcccf7e88d"; + +export function makeTestEnv(overrides: Partial<Env> = {}): Env { + return { + ...env, + TELEMETRY: { + writeDataPoint: () => undefined + } as unknown as AnalyticsEngineDataset, + STRIPE_WEBHOOK_SECRET: TEST_STRIPE_WEBHOOK_SECRET, + STRIPE_API_KEY: TEST_STRIPE_API_KEY, + ED25519_PRIVATE_KEY: TEST_PRIVATE_SEED_HEX, + RESEND_API_KEY: "resend_test_key", + ...overrides + }; +} + +export async function signedStripeRequest( + event: Record<string, unknown>, + path = "/stripe/webhook" +): Promise<Request<unknown, IncomingRequestCfProperties>> { + const payload = JSON.stringify(event); + const header = await Stripe.webhooks.generateTestHeaderStringAsync({ + payload, + secret: TEST_STRIPE_WEBHOOK_SECRET + }); + + return new Request(`https://api.atlas-ai.au${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": header + }, + body: payload + }) as unknown as Request<unknown, IncomingRequestCfProperties>; +} + +export function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function b64urlDecode(value: string): Uint8Array { + const padded = value + "=".repeat((4 - (value.length % 4)) % 4); + const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +export async function decodeAndVerifyAtlasKey(key: string): Promise<{ + ok: boolean; + payload: Record<string, unknown>; +}> { + const body = key.replace(/^ATLAS-/, ""); + const [payloadSegment, signatureSegment] = body.split("."); + const payloadBytes = b64urlDecode(payloadSegment); + const signature = b64urlDecode(signatureSegment); + const publicKey = await crypto.subtle.importKey( + "raw", + hexToBytes(TEST_PUBLIC_KEY_HEX), + { name: "Ed25519" }, + false, + ["verify"] + ); + const ok = await crypto.subtle.verify("Ed25519", publicKey, signature, payloadBytes); + + return { + ok, + payload: JSON.parse(new TextDecoder().decode(payloadBytes)) as Record<string, unknown> + }; +} diff --git a/workers/test/index.test.ts b/workers/test/index.test.ts new file mode 100644 index 0000000..e9e9b06 --- /dev/null +++ b/workers/test/index.test.ts @@ -0,0 +1,27 @@ +import { env, SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("Worker route scaffold", () => { + it("returns 404 for unknown paths", async () => { + const response = await SELF.fetch("https://api.atlas-ai.au/"); + + expect(response.status).toBe(404); + }); + + it("exposes the LICENSE_DB D1 binding to tests", () => { + expect(env.LICENSE_DB).toBeDefined(); + expect(typeof env.LICENSE_DB.prepare).toBe("function"); + }); + + it.each([ + "/stripe/webhook", + "/license/refresh", + "/telemetry" + ])("returns 405 for unsupported methods on %s", async (path) => { + const response = await SELF.fetch(`https://api.atlas-ai.au${path}`, { + method: "GET" + }); + + expect(response.status).toBe(405); + }); +}); diff --git a/workers/test/license-signer.test.ts b/workers/test/license-signer.test.ts new file mode 100644 index 0000000..2a7c425 --- /dev/null +++ b/workers/test/license-signer.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import vectors from "../../tests/license/test_vectors.json"; +import { signLicensePayload } from "../src/license-signer"; +import type { LicensePayload } from "../src/license-signer"; +import { TEST_PRIVATE_SEED_HEX } from "./helpers"; + +describe("Worker license signer", () => { + it("reproduces committed reproducible license vectors byte-for-byte", async () => { + for (const vector of vectors.vectors) { + if (!vector.reproducible) { + continue; + } + + await expect(signLicensePayload(vector.payload as LicensePayload, TEST_PRIVATE_SEED_HEX)).resolves.toBe(vector.key); + } + }); +}); diff --git a/workers/test/refresh.test.ts b/workers/test/refresh.test.ts new file mode 100644 index 0000000..6cf4f11 --- /dev/null +++ b/workers/test/refresh.test.ts @@ -0,0 +1,232 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; +import { decodeAndVerifyAtlasKey, makeTestEnv } from "./helpers"; + +const GRACE_SECONDS = 1_209_600; + +async function seedLicense(options: { + lid: string; + subHash?: string; + plan?: "pro-monthly" | "pro-annual"; + subscriptionId?: string; + expiresAt?: number; + cancelledAt?: number | null; +}): Promise<void> { + await env.LICENSE_DB.prepare( + `INSERT INTO licenses ( + lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, + issued_at, expires_at, cancelled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + options.lid, + options.subHash ?? "d".repeat(64), + options.plan ?? "pro-monthly", + `cus_${options.lid}`, + options.subscriptionId ?? `sub_${options.lid}`, + 1_700_000_000, + options.expiresAt ?? 1_710_000_000, + options.cancelledAt ?? null + ) + .run(); +} + +function refreshRequest(body: unknown, method = "POST"): Request<unknown, IncomingRequestCfProperties> { + return new Request("https://api.atlas-ai.au/license/refresh", { + method, + headers: { "content-type": "application/json" }, + body: method === "POST" ? JSON.stringify(body) : undefined + }) as unknown as Request<unknown, IncomingRequestCfProperties>; +} + +function mockStripeSubscription(status: string, currentPeriodEnd = 1_900_000_000) { + return vi.fn(async (input: string | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.url; + const headers = init?.headers as Record<string, string>; + + expect(url).toContain("https://api.stripe.com/v1/subscriptions/"); + expect(headers.authorization).toBe("Bearer stripe_api_test_key"); + return Response.json({ + id: url.split("/").at(-1), + object: "subscription", + status, + current_period_end: currentPeriodEnd + }); + }); +} + +describe("/license/refresh", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it.each([ + ["invalid JSON", "{not json"], + ["missing lid", {}], + ["non-string lid", { lid: 42 }] + ])("returns 400 for %s", async (_name, body) => { + const request = new Request("https://api.atlas-ai.au/license/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body) + }) as unknown as Request<unknown, IncomingRequestCfProperties>; + + const response = await worker.fetch(request, makeTestEnv(), createExecutionContext()); + + expect(response.status).toBe(400); + expect(response.headers.get("access-control-allow-origin")).toBe("https://atlas-ai.au"); + }); + + it("returns 403 license_not_found for an unknown lid", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await worker.fetch( + refreshRequest({ lid: "lic_missing" }), + makeTestEnv(), + createExecutionContext() + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ ok: false, reason: "license_not_found" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns a fresh signed key for an active subscription", async () => { + const periodEnd = 1_901_000_000; + await seedLicense({ + lid: "lic_refresh", + subHash: "e".repeat(64), + plan: "pro-annual", + subscriptionId: "sub_refresh" + }); + vi.stubGlobal("fetch", mockStripeSubscription("active", periodEnd)); + + const response = await worker.fetch( + refreshRequest({ lid: "lic_refresh" }), + makeTestEnv(), + createExecutionContext() + ); + const body = (await response.json()) as { ok: boolean; key: string }; + const decoded = await decodeAndVerifyAtlasKey(body.key); + const row = await env.LICENSE_DB.prepare("SELECT expires_at FROM licenses WHERE lid = ?") + .bind("lic_refresh") + .first<{ expires_at: number }>(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(decoded.ok).toBe(true); + expect(decoded.payload).toMatchObject({ + lid: "lic_refresh", + sub: "e".repeat(64), + plan: "pro-annual", + exp: periodEnd + GRACE_SECONDS, + v: 1 + }); + expect(row?.expires_at).toBe(periodEnd + GRACE_SECONDS); + }); + + it("short-circuits cancelled D1 rows without calling Stripe", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await seedLicense({ + lid: "lic_cancelled", + subscriptionId: "sub_cancelled", + cancelledAt: 1_800_000_000 + }); + + const response = await worker.fetch( + refreshRequest({ lid: "lic_cancelled" }), + makeTestEnv(), + createExecutionContext() + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ ok: false, reason: "subscription_cancelled" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("persists cancellation when Stripe reports an inactive subscription", async () => { + await seedLicense({ + lid: "lic_unpaid", + subscriptionId: "sub_unpaid", + cancelledAt: null + }); + vi.stubGlobal("fetch", mockStripeSubscription("unpaid")); + + const response = await worker.fetch( + refreshRequest({ lid: "lic_unpaid" }), + makeTestEnv(), + createExecutionContext() + ); + const row = await env.LICENSE_DB.prepare("SELECT cancelled_at FROM licenses WHERE lid = ?") + .bind("lic_unpaid") + .first<{ cancelled_at: number | null }>(); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ ok: false, reason: "subscription_cancelled" }); + expect(row?.cancelled_at).toEqual(expect.any(Number)); + }); + + it("enforces a 10-per-day rate limit per lid and resets on a new UTC day", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-11T12:00:00Z")); + await seedLicense({ lid: "lic_limited", subscriptionId: "sub_limited" }); + await seedLicense({ lid: "lic_other", subscriptionId: "sub_other" }); + const fetchMock = mockStripeSubscription("trialing", 1_902_000_000); + vi.stubGlobal("fetch", fetchMock); + + for (let i = 0; i < 10; i += 1) { + const response = await worker.fetch( + refreshRequest({ lid: "lic_limited" }), + makeTestEnv(), + createExecutionContext() + ); + expect(response.status).toBe(200); + } + + const limited = await worker.fetch( + refreshRequest({ lid: "lic_limited" }), + makeTestEnv(), + createExecutionContext() + ); + const other = await worker.fetch( + refreshRequest({ lid: "lic_other" }), + makeTestEnv(), + createExecutionContext() + ); + vi.setSystemTime(new Date("2026-06-12T00:01:00Z")); + const nextDay = await worker.fetch( + refreshRequest({ lid: "lic_limited" }), + makeTestEnv(), + createExecutionContext() + ); + + expect(limited.status).toBe(429); + await expect(limited.json()).resolves.toEqual({ ok: false, reason: "rate_limited" }); + expect(other.status).toBe(200); + expect(nextDay.status).toBe(200); + }); + + it("handles CORS preflight and method guards", async () => { + const options = await worker.fetch( + refreshRequest({}, "OPTIONS"), + makeTestEnv(), + createExecutionContext() + ); + const get = await worker.fetch( + refreshRequest({}, "GET"), + makeTestEnv(), + createExecutionContext() + ); + + expect(options.status).toBe(204); + expect(options.headers.get("access-control-allow-origin")).toBe("https://atlas-ai.au"); + expect(options.headers.get("access-control-allow-methods")).toContain("POST"); + expect(get.status).toBe(405); + expect(get.headers.get("allow")).toBe("POST, OPTIONS"); + }); +}); diff --git a/workers/test/setup.ts b/workers/test/setup.ts new file mode 100644 index 0000000..7ff09e1 --- /dev/null +++ b/workers/test/setup.ts @@ -0,0 +1,9 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, inject } from "vitest"; +import type { D1Migration } from "@cloudflare/vitest-pool-workers/config"; + +const migrations = inject("d1Migrations") as D1Migration[]; + +beforeAll(async () => { + await applyD1Migrations(env.LICENSE_DB, migrations); +}); diff --git a/workers/test/stripe.test.ts b/workers/test/stripe.test.ts new file mode 100644 index 0000000..a3fd5ee --- /dev/null +++ b/workers/test/stripe.test.ts @@ -0,0 +1,375 @@ +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { env } from "cloudflare:test"; +import Stripe from "stripe"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; +import { + decodeAndVerifyAtlasKey, + makeTestEnv, + signedStripeRequest, + TEST_STRIPE_WEBHOOK_SECRET +} from "./helpers"; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ id: "email_test" }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("Stripe webhook signature verification", () => { + it.each([ + ["missing signature", new Headers({ "content-type": "application/json" })], + [ + "garbled signature", + new Headers({ + "content-type": "application/json", + "stripe-signature": "not-a-valid-stripe-signature" + }) + ] + ])("rejects %s with 400", async (_name, headers) => { + const ctx = createExecutionContext(); + const response = await worker.fetch( + new Request("https://api.atlas-ai.au/stripe/webhook", { + method: "POST", + headers, + body: JSON.stringify({ id: "evt_bad", object: "event", type: "customer.created" }) + }), + makeTestEnv(), + ctx + ); + + expect(response.status).toBe(400); + }); + + it("rejects a signature generated with a different secret", async () => { + const payload = JSON.stringify({ + id: "evt_wrong_secret", + object: "event", + type: "customer.created", + data: { object: { id: "cus_test", object: "customer" } } + }); + const header = await Stripe.webhooks.generateTestHeaderStringAsync({ + payload, + secret: `${TEST_STRIPE_WEBHOOK_SECRET}_wrong` + }); + const ctx = createExecutionContext(); + + const response = await worker.fetch( + new Request("https://api.atlas-ai.au/stripe/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": header + }, + body: payload + }), + makeTestEnv(), + ctx + ); + + expect(response.status).toBe(400); + }); + + it("accepts a valid signed event and reaches dispatch", async () => { + const ctx = createExecutionContext(); + const request = await signedStripeRequest({ + id: "evt_signed_customer_created", + object: "event", + type: "customer.created", + data: { object: { id: "cus_test", object: "customer" } } + }); + + const response = await worker.fetch(request, makeTestEnv(), ctx); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, ignored: true }); + }); +}); + +describe("Stripe webhook idempotency", () => { + it("records a processed event once and treats replay as a no-op", async () => { + const event = { + id: "evt_replayed_customer_created", + object: "event", + type: "customer.created", + data: { object: { id: "cus_test", object: "customer" } } + }; + + const first = await worker.fetch(await signedStripeRequest(event), makeTestEnv(), createExecutionContext()); + const second = await worker.fetch(await signedStripeRequest(event), makeTestEnv(), createExecutionContext()); + const row = await env.LICENSE_DB.prepare( + "SELECT COUNT(*) AS count FROM processed_events WHERE event_id = ?" + ) + .bind(event.id) + .first<{ count: number }>(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + await expect(second.json()).resolves.toEqual({ ok: true, duplicate: true }); + expect(row?.count).toBe(1); + }); +}); + +describe("checkout.session.completed", () => { + it("creates a license row and returns a signed key", async () => { + const periodEnd = 1_800_000_000; + const request = await signedStripeRequest({ + id: "evt_checkout_completed", + object: "event", + type: "checkout.session.completed", + data: { + object: { + id: "cs_test", + object: "checkout.session", + customer: "cus_test", + customer_details: { email: "Buyer@Example.COM" }, + metadata: { plan: "pro-monthly" }, + subscription: { + id: "sub_checkout", + object: "subscription", + current_period_end: periodEnd + } + } + } + }); + + const response = await worker.fetch(request, makeTestEnv(), createExecutionContext()); + const body = (await response.json()) as { ok: boolean; key: string }; + const decoded = await decodeAndVerifyAtlasKey(body.key); + const row = await env.LICENSE_DB.prepare( + "SELECT lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, issued_at, expires_at FROM licenses WHERE stripe_subscription_id = ?" + ) + .bind("sub_checkout") + .first<{ + lid: string; + sub_hash: string; + plan: string; + stripe_customer_id: string; + stripe_subscription_id: string; + issued_at: number; + expires_at: number; + }>(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(decoded.ok).toBe(true); + expect(row).toMatchObject({ + plan: "pro-monthly", + stripe_customer_id: "cus_test", + stripe_subscription_id: "sub_checkout", + expires_at: periodEnd + 1_209_600 + }); + expect(decoded.payload).toMatchObject({ + lid: row?.lid, + sub: row?.sub_hash, + plan: "pro-monthly", + exp: periodEnd + 1_209_600, + v: 1 + }); + expect(typeof decoded.payload.iat).toBe("number"); + }); + + it("persists the license and returns 200 when queued email delivery fails", async () => { + const fetchMock = vi.fn(async () => new Response("unauthorized", { status: 401 })); + vi.stubGlobal("fetch", fetchMock); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const ctx = createExecutionContext(); + const request = await signedStripeRequest({ + id: "evt_checkout_email_failure", + object: "event", + type: "checkout.session.completed", + data: { + object: { + id: "cs_email_failure", + object: "checkout.session", + customer: "cus_email_failure", + customer_details: { email: "Failure@Example.COM" }, + metadata: { plan: "pro-monthly" }, + subscription: { + id: "sub_email_failure", + object: "subscription", + current_period_end: 1_800_000_000 + } + } + } + }); + + const response = await worker.fetch(request, makeTestEnv(), ctx); + const body = (await response.json()) as { ok: boolean; key: string; lid: string }; + const row = await env.LICENSE_DB.prepare( + "SELECT lid FROM licenses WHERE stripe_subscription_id = ?" + ) + .bind("sub_email_failure") + .first<{ lid: string }>(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(row?.lid).toBe(body.lid); + + await waitOnExecutionContext(ctx); + + const logged = JSON.stringify(errorSpy.mock.calls); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(logged).toContain(body.lid); + expect(logged).not.toContain("Failure@Example.COM"); + expect(logged).not.toContain(body.key); + }); +}); + +describe("invoice.paid", () => { + it("extends expiry and returns a fresh signed key for a known subscription", async () => { + const periodEnd = 1_801_000_000; + await env.LICENSE_DB.prepare( + `INSERT INTO licenses ( + lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, + issued_at, expires_at, cancelled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + "lic_invoice", + "b".repeat(64), + "pro-annual", + "cus_invoice", + "sub_invoice", + 1_700_000_000, + 1_710_000_000, + null + ) + .run(); + + const response = await worker.fetch( + await signedStripeRequest({ + id: "evt_invoice_paid", + object: "event", + type: "invoice.paid", + data: { + object: { + id: "in_test", + object: "invoice", + customer: "cus_invoice", + subscription: "sub_invoice", + lines: { data: [{ period: { end: periodEnd } }] } + } + } + }), + makeTestEnv(), + createExecutionContext() + ); + const body = (await response.json()) as { ok: boolean; key: string }; + const decoded = await decodeAndVerifyAtlasKey(body.key); + const row = await env.LICENSE_DB.prepare( + "SELECT expires_at FROM licenses WHERE lid = ?" + ) + .bind("lic_invoice") + .first<{ expires_at: number }>(); + + expect(response.status).toBe(200); + expect(decoded.ok).toBe(true); + expect(row?.expires_at).toBe(periodEnd + 1_209_600); + expect(decoded.payload).toMatchObject({ + lid: "lic_invoice", + sub: "b".repeat(64), + plan: "pro-annual", + exp: periodEnd + 1_209_600, + v: 1 + }); + }); + + it("returns 200 and leaves D1 unchanged for an unknown subscription", async () => { + const response = await worker.fetch( + await signedStripeRequest({ + id: "evt_invoice_unknown_subscription", + object: "event", + type: "invoice.paid", + data: { + object: { + id: "in_unknown", + object: "invoice", + subscription: "sub_missing", + lines: { data: [{ period: { end: 1_801_000_000 } }] } + } + } + }), + makeTestEnv(), + createExecutionContext() + ); + const row = await env.LICENSE_DB.prepare( + "SELECT COUNT(*) AS count FROM licenses WHERE stripe_subscription_id = ?" + ) + .bind("sub_missing") + .first<{ count: number }>(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: true, + reason: "unknown_subscription" + }); + expect(row?.count).toBe(0); + }); +}); + +describe("customer.subscription.deleted", () => { + it("marks a license cancelled and duplicate delivery is a no-op", async () => { + await env.LICENSE_DB.prepare( + `INSERT INTO licenses ( + lid, sub_hash, plan, stripe_customer_id, stripe_subscription_id, + issued_at, expires_at, cancelled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + "lic_cancel", + "c".repeat(64), + "pro-monthly", + "cus_cancel", + "sub_cancel", + 1_700_000_000, + 1_900_000_000, + null + ) + .run(); + + const event = { + id: "evt_subscription_deleted", + object: "event", + type: "customer.subscription.deleted", + data: { + object: { + id: "sub_cancel", + object: "subscription", + status: "canceled" + } + } + }; + + const first = await worker.fetch( + await signedStripeRequest(event), + makeTestEnv(), + createExecutionContext() + ); + const second = await worker.fetch( + await signedStripeRequest(event), + makeTestEnv(), + createExecutionContext() + ); + const row = await env.LICENSE_DB.prepare( + "SELECT lid, plan, expires_at, cancelled_at FROM licenses WHERE lid = ?" + ) + .bind("lic_cancel") + .first<{ lid: string; plan: string; expires_at: number; cancelled_at: number | null }>(); + + expect(first.status).toBe(200); + await expect(first.json()).resolves.toEqual({ ok: true, cancelled: true, lid: "lic_cancel" }); + expect(second.status).toBe(200); + await expect(second.json()).resolves.toEqual({ ok: true, duplicate: true }); + expect(row).toMatchObject({ + lid: "lic_cancel", + plan: "pro-monthly", + expires_at: 1_900_000_000 + }); + expect(row?.cancelled_at).toEqual(expect.any(Number)); + }); +}); diff --git a/workers/test/telemetry.test.ts b/workers/test/telemetry.test.ts new file mode 100644 index 0000000..42c9ae2 --- /dev/null +++ b/workers/test/telemetry.test.ts @@ -0,0 +1,180 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; +import type { Env } from "../src/types"; +import { makeTestEnv } from "./helpers"; + +const VALID_EVENTS = ["install", "atlas_invoked", "reach_execute", "ship_check_ok"] as const; + +function telemetryBinding(writeDataPoint = vi.fn()): AnalyticsEngineDataset { + return { writeDataPoint } as unknown as AnalyticsEngineDataset; +} + +function telemetryEnv(writeDataPoint = vi.fn(), overrides: Partial<Env> = {}): Env { + return makeTestEnv({ + TELEMETRY: telemetryBinding(writeDataPoint), + ...overrides + }); +} + +function telemetryRequest(body: unknown, method = "POST"): Request<unknown, IncomingRequestCfProperties> { + return new Request("https://api.atlas-ai.au/telemetry", { + method, + headers: { "content-type": "application/json" }, + body: method === "POST" ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined + }) as unknown as Request<unknown, IncomingRequestCfProperties>; +} + +async function countTelemetryRows(installId: string): Promise<number> { + const row = await env.LICENSE_DB.prepare( + "SELECT COUNT(*) AS count FROM telemetry_events WHERE install_id = ?" + ) + .bind(installId) + .first<{ count: number }>(); + return row?.count ?? 0; +} + +describe("/telemetry", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("can insert and select telemetry_events rows through D1", async () => { + await env.LICENSE_DB.prepare( + "INSERT INTO telemetry_events (install_id, event, version, os, timestamp) VALUES (?, ?, ?, ?, ?)" + ) + .bind("install_binding", "install", "1.0.0", "linux", "2026-06-11T00:00:00.000Z") + .run(); + + await expect(countTelemetryRows("install_binding")).resolves.toBe(1); + }); + + it.each(VALID_EVENTS)("accepts valid %s telemetry and stores it", async (event) => { + const writeDataPoint = vi.fn(); + const installId = `install_${event}`; + + const response = await worker.fetch( + telemetryRequest({ + install_id: installId, + event, + version: "1.2.3", + os: "linux" + }), + telemetryEnv(writeDataPoint), + createExecutionContext() + ); + + expect(response.status).toBe(204); + await expect(response.text()).resolves.toBe(""); + await expect(countTelemetryRows(installId)).resolves.toBe(1); + expect(writeDataPoint).toHaveBeenCalledOnce(); + expect(writeDataPoint).toHaveBeenCalledWith({ + blobs: [event, "1.2.3", "linux"], + doubles: [1], + indexes: [installId] + }); + }); + + it.each([ + ["invalid JSON", "{not json"], + ["missing field", { install_id: "install_bad", event: "install", version: "1.0.0" }], + ["wrong type", { install_id: "install_bad", event: "install", version: "1.0.0", os: 42 }], + ["unknown event", { install_id: "install_bad", event: "bogus", version: "1.0.0", os: "linux" }], + [ + "oversized install_id", + { install_id: "x".repeat(97), event: "install", version: "1.0.0", os: "linux" } + ], + [ + "unknown key", + { install_id: "install_bad", event: "install", version: "1.0.0", os: "linux", extra: true } + ] + ])("rejects %s payloads", async (_name, payload) => { + const writeDataPoint = vi.fn(); + + const response = await worker.fetch( + telemetryRequest(payload), + telemetryEnv(writeDataPoint), + createExecutionContext() + ); + + expect(response.status).toBe(400); + expect(writeDataPoint).not.toHaveBeenCalled(); + }); + + it("returns 405 for non-POST methods", async () => { + const response = await worker.fetch( + telemetryRequest({}, "GET"), + telemetryEnv(), + createExecutionContext() + ); + + expect(response.status).toBe(405); + }); + + it("logs storage sink failures without failing a valid telemetry response", async () => { + const writeDataPoint = vi.fn(() => { + throw new Error("analytics down"); + }); + const failingDb = { + prepare: () => { + throw new Error("d1 down"); + } + } as unknown as D1Database; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const response = await worker.fetch( + telemetryRequest({ + install_id: "install_storage_failure", + event: "atlas_invoked", + version: "1.2.3", + os: "linux" + }), + telemetryEnv(writeDataPoint, { LICENSE_DB: failingDb }), + createExecutionContext() + ); + + expect(response.status).toBe(204); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("computes documented D1 KPI queries from seeded telemetry rows", async () => { + await env.LICENSE_DB.prepare("DELETE FROM telemetry_events").run(); + const rows = [ + ["i1", "install", "1.0.0", "linux", "2026-06-01T00:00:00.000Z"], + ["i1", "install", "1.0.0", "linux", "2026-06-01T01:00:00.000Z"], + ["i2", "install", "1.0.0", "darwin", "2026-06-01T00:00:00.000Z"], + ["i1", "atlas_invoked", "1.0.0", "linux", "2026-06-10T00:00:00.000Z"], + ["i2", "reach_execute", "1.0.0", "darwin", "2026-06-10T00:00:00.000Z"], + ["i2", "ship_check_ok", "1.0.0", "darwin", "2026-06-10T01:00:00.000Z"], + ["i3", "atlas_invoked", "1.0.0", "linux", "2026-05-01T00:00:00.000Z"], + ["i4", "reach_execute", "1.0.0", "linux", "2026-05-01T00:00:00.000Z"] + ]; + for (const row of rows) { + await env.LICENSE_DB.prepare( + "INSERT INTO telemetry_events (install_id, event, version, os, timestamp) VALUES (?, ?, ?, ?, ?)" + ) + .bind(...row) + .run(); + } + + const a1 = await env.LICENSE_DB.prepare( + "SELECT COUNT(DISTINCT install_id) AS value FROM telemetry_events WHERE event = 'install'" + ).first<{ value: number }>(); + const a3 = await env.LICENSE_DB.prepare( + "SELECT COUNT(DISTINCT install_id) AS value FROM telemetry_events WHERE event != 'install' AND timestamp > ?" + ) + .bind("2026-06-04T00:00:00.000Z") + .first<{ value: number }>(); + const ac1 = await env.LICENSE_DB.prepare( + "SELECT COUNT(*) AS value FROM telemetry_events WHERE event = 'reach_execute'" + ).first<{ value: number }>(); + const ac2 = await env.LICENSE_DB.prepare( + "SELECT COUNT(*) AS value FROM telemetry_events WHERE event = 'ship_check_ok'" + ).first<{ value: number }>(); + + expect(a1?.value).toBe(2); + expect(a3?.value).toBe(2); + expect(ac1?.value).toBe(2); + expect(ac2?.value).toBe(1); + }); +}); diff --git a/workers/tsconfig.json b/workers/tsconfig.json new file mode 100644 index 0000000..460444c --- /dev/null +++ b/workers/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"], + "strict": true, + "noEmit": true, + "allowJs": false, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/workers/vitest.config.ts b/workers/vitest.config.ts new file mode 100644 index 0000000..4c10315 --- /dev/null +++ b/workers/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineWorkersConfig, readD1Migrations } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig(async () => ({ + test: { + setupFiles: ["./test/setup.ts"], + provide: { + d1Migrations: await readD1Migrations("./migrations") + }, + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.toml" } + } + } + } +})); diff --git a/workers/wrangler.toml b/workers/wrangler.toml new file mode 100644 index 0000000..771b277 --- /dev/null +++ b/workers/wrangler.toml @@ -0,0 +1,19 @@ +name = "atlas-pro-backend" +main = "src/index.ts" +compatibility_date = "2026-06-10" + +routes = [ + { pattern = "api.atlas-ai.au/stripe/webhook", zone_name = "atlas-ai.au" }, + { pattern = "api.atlas-ai.au/license/refresh", zone_name = "atlas-ai.au" }, + { pattern = "api.atlas-ai.au/telemetry", zone_name = "atlas-ai.au" } +] + +[[d1_databases]] +binding = "LICENSE_DB" +database_name = "atlas-license-db" +database_id = "00000000-0000-0000-0000-000000000000" +migrations_dir = "migrations" + +[[analytics_engine_datasets]] +binding = "TELEMETRY" +dataset = "telemetry_events"