diff --git a/.env.enc b/.env.enc new file mode 100644 index 00000000..9bf4c900 --- /dev/null +++ b/.env.enc @@ -0,0 +1,16 @@ +LINEAR_API_KEY=ENC[AES256_GCM,data:tJaJPTLAR0hb0WAdGHkgTrg2k7K3nd3LmXAaTY7mfOSWZrEP7RURtyLo2cbpNqh/,iv:chLunRjSqS/QJ3zxbQgRv/Qbjd+mxPQQ9Fw0BH2xUQk=,tag:YQIn2LCxiJi57IEv3bq1iA==,type:str] +#ENC[AES256_GCM,data:OkSUShBxbXPG5c7eYFY5ptCdNZhOZg==,iv:S803Su37L06iWgB2KTioRzlzAfevLB3oBJfQfZn/1T0=,tag:RvO5cmVkT36VKleA3tuUOQ==,type:comment] +SLACK_BOT_TOKEN=ENC[AES256_GCM,data:SWizqznycCHI5Qn370iI3LryyLb4exIOltJXtN+m5BbS8yyHQ3spt0ojJ+GuVy0D9b3U/XZ86PgAWAM=,iv:VlBrwZ5fVfHA/DAfeyv7u6M9H/RMyGJoOWxlsB3mKOI=,tag:lZNOHeOoGZcqe+qi0cPrPQ==,type:str] +SLACK_APP_TOKEN=ENC[AES256_GCM,data:LtVK3j4h0DsZ9C6ijDwlFYJDvlJdFOZPWH5VhvP6SZApCQxQAcfXxWsF9wyQZaNlwZFJDerCjremvuIGtZRjHJPi+0smIrI4fzSB9/Zt3yqSNBQ56dX3k3L1FQ0kQtxcRS0=,iv:cokUzSPsmiOtq4U0Yu3cMZmKbv6AM7c5kPZ4oQ7qY7Q=,tag:GPeK1wjJPTkdgPqIxqeipA==,type:str] +#ENC[AES256_GCM,data:MJGhyQqe53qQ+OeTuLMrEPw7J4c05j3zKyEOsdU+o3L4dVk3WS47EFrcqZo4w9ZB9D667bAGrA==,iv:cOG1KrS2k0+ysscMgL+dPuwKYxhMFZl9y6o8AvTJic0=,tag:Mczpu8xwjYiN4KIgrzBzuA==,type:comment] +#ENC[AES256_GCM,data:LYsUCQTmfrF7mrXUhP9E4wA4GK3JuBLxW4Ou4+ZvEEgU75TYnabWEKfRyVKPXi8rzk0qikHX7gyE0TVX049RT9XdtxC/AxcMR+n8pfIDadPyVfPGWTzN3cSh3Y4=,iv:+ovvE+ZURxfUshyMn/E4HUy5JWsxZQ9ld1FM0/kh168=,tag:GriUrvUCBEyKwxMlHAOm/Q==,type:comment] +CHANNEL_PROJECT_MAP=ENC[AES256_GCM,data:4XUJtkbgiUXrhY7C7AeqvRhuom+4v5atdclXkmz7vGJaPB0iqYmShIRUqtHeqO0p+UID4aCF69XEvfzv0I1dswF2NGYlDHn8NiIMhZJBzhJViID8nus5Bb5+zhkGqGZgW2rDMwPCLcNGfB4UxGDbd/Vq5GVP4E3ZlbzUIP5J9k6Tiz3PvicMpmgio5KTi07ynqPqyRMqOVe9T6Q4p/xZzmyPTHWGMMEiCWDj5BxViHwL9OdHPyt8YLE93GKa16goROIXKd6ZNd5lI4FHl2F5bfBJaoOhVOr4nabUsq1lIki5yHWElG7fS6KFVFsDXM8ePDEgi7RLOPft+BvTAKIs6Cqhi+7tqftukuP4xXeU01PXovX4AHZe2mLYykg=,iv:GC8wYmwP2V0xwzgls60tm1H9BIALrkvyTjHDVuh9r5c=,tag:llaCCalJqiNpiSvDNdbvKw==,type:str] +#ENC[AES256_GCM,data:HGWm3HNLgZsVhwiy54BFG0jH2cFTetFZW0R+GDjqOu+1d5u2UGmFYyuKI4okhh80qEhj,iv:jD0wSJLnADOzSKFdgV9R+xABc99T3B9UqI7PbFZ28Jw=,tag:XiGD/fh2dkrLvqzurGHKrA==,type:comment] +CLAUDE_MODEL=ENC[AES256_GCM,data:4PFMFw==,iv:t4mJ9pP/GvNpgP/okQOV9eWq471zQG8HTwotmmxPmGI=,tag:YgmEzRXS/gEGaK3/AKz4pA==,type:str] +REPO_URL=ENC[AES256_GCM,data:83dA6fg5EQY9NIp6uwie4rjLxDg4ApNFCT6cSg072k0+VnWcqTVypdyq6ifH/0c=,iv:bNYWRwVYVU5Qb4WIbYs6/bAr/yfzv+0b3oRFCpfwDZ8=,tag:84OKL3NbuGuG1Q9tms4IWg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPa3pTeldVa0JIY0ErdlQw\na0JSUE5Hd242YW8zcHQrMlZVVXdXVXNWalhrCldwd2tmMGpqVkxWaHFKeTluUFQ3\nTVJnd21MTS9ad0QzVXNwcUhEcGtjTHMKLS0tIDV5NStLS3BLU0VhZGxWZG9BdjBE\nNFpnZDlIZEpCMEZLUGMwTGM1Rm4raTAKmvUgZGqmk94GGuTDOxEHg9vFUPNhhHXj\nDzfzlK2Rh6wLU4+cFZ3aMD+MZp/GNd9kWa4gCU5PZgR1aJF3zSFAZw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age17ud00xq42ckzfpmhxtwz4zy0vc50lsa3jfvulrt5zhe2pd94p9kqy775uc +sops_lastmodified=2026-03-25T04:07:11Z +sops_mac=ENC[AES256_GCM,data:t9RrWey+hBnjWlFZglbGpqSTgKOXAa69fcPqOtwwOrljXjgqsn9CNrdEja9KR8OSYDyhDy3Zy9UnciqnWEj8m0zx2lVAOHXJPeiHdBwseHdi2dPTYGThnuU7y1MI7lYgFHmSTmg3OeI1RKhI1YsDbg7ms4d/4umNguQGaZwmnKU=,iv:uO5mdIpLKfBmuhh0HkYskG9rh5JSt4X77aL+GQhPinE=,tag:sCPoMnDL1e4FdJePQiBwBg==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.12.2 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d4519dae --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Slack bot credentials +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-level-token + +# Channel ID → project directory mapping (JSON object) +# Example: {"C0123456789":"/home/user/projects/my-app","C9876543210":"/home/user/projects/other-app"} +CHANNEL_PROJECT_MAP={} + +# Claude Code model identifier (optional, defaults to "sonnet") +# Valid values: sonnet, opus, haiku +CLAUDE_MODEL=sonnet diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ae229c..2a9e0d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + merge_group: jobs: test: diff --git a/.github/workflows/post-merge-gate.yml b/.github/workflows/post-merge-gate.yml new file mode 100644 index 00000000..537cdfb7 --- /dev/null +++ b/.github/workflows/post-merge-gate.yml @@ -0,0 +1,202 @@ +name: Post-Merge Gate + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + gate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.30.2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + id: lint + run: pnpm lint + + - name: Typecheck + id: typecheck + if: always() && steps.lint.outcome != 'cancelled' + run: pnpm typecheck + + - name: Test + id: test + if: always() && steps.typecheck.outcome != 'cancelled' + run: pnpm test + + - name: Build + id: build + if: always() && steps.test.outcome != 'cancelled' + run: pnpm build + + - name: Bump calver version + if: success() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Retry loop handles race when concurrent merges push at the same time + for ATTEMPT in 1 2 3; do + git pull --rebase origin main + + # Read current version after pulling latest + CURRENT_VERSION=$(node -p "require('./package.json').version") + TODAY=$(date -u +"%Y.%m.%d") + + # Extract date prefix and sequence from current version + CURRENT_PREFIX=$(echo "$CURRENT_VERSION" | cut -d. -f1-3) + CURRENT_SEQ=$(echo "$CURRENT_VERSION" | cut -d. -f4) + + # Determine next sequence + if [ "$CURRENT_PREFIX" = "$TODAY" ]; then + NEXT_SEQ=$((CURRENT_SEQ + 1)) + else + NEXT_SEQ=1 + fi + + NEXT_VERSION="${TODAY}.${NEXT_SEQ}" + + # Update package.json version field directly (avoids npm lockfile side-effects) + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '${NEXT_VERSION}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + git add package.json + git commit -m "chore: bump calver to ${NEXT_VERSION} [skip ci]" + + if git push; then + echo "::notice::Bumped version to ${NEXT_VERSION}" + exit 0 + fi + + echo "::warning::Push failed (attempt ${ATTEMPT}/3), retrying..." + git reset --soft HEAD~1 + done + + echo "::error::Failed to push calver bump after 3 attempts" + exit 1 + + - name: Create Linear issue on failure + if: failure() + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + run: | + COMMIT_SHA="${{ github.sha }}" + SHORT_SHA="${COMMIT_SHA:0:7}" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Extract PR number from merge commit message (GitHub format: "Merge pull request #N ..." or squash "... (#N)") + PR_NUMBER=$(git log -1 --pretty=%s | grep -oP '#\K[0-9]+' | head -1) + PR_INFO="" + if [ -n "$PR_NUMBER" ]; then + PR_INFO="**PR:** #${PR_NUMBER}" + fi + + # Determine which steps failed + FAILED_STEPS="" + if [ "${{ steps.lint.outcome }}" = "failure" ]; then + FAILED_STEPS="${FAILED_STEPS}\n- Lint" + fi + if [ "${{ steps.typecheck.outcome }}" = "failure" ]; then + FAILED_STEPS="${FAILED_STEPS}\n- Typecheck" + fi + if [ "${{ steps.test.outcome }}" = "failure" ]; then + FAILED_STEPS="${FAILED_STEPS}\n- Test" + fi + if [ "${{ steps.build.outcome }}" = "failure" ]; then + FAILED_STEPS="${FAILED_STEPS}\n- Build" + fi + + TITLE="pipeline-halt: post-merge gate failure on ${SHORT_SHA}" + + BODY="## Post-Merge Gate Failure\n\n**Commit:** ${COMMIT_SHA}\n${PR_INFO}\n**Run:** ${RUN_URL}\n\n**Failed steps:**${FAILED_STEPS}" + + # Look up SYMPH team ID and pipeline-halt label via Linear API + TEAM_QUERY='{ "query": "{ teams(filter: { key: { eq: \"SYMPH\" } }) { nodes { id } } }" }' + TEAM_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: ${LINEAR_API_KEY}" \ + -d "$TEAM_QUERY") + TEAM_ID=$(echo "$TEAM_RESPONSE" | jq -r '.data.teams.nodes[0].id') + + if [ -z "$TEAM_ID" ] || [ "$TEAM_ID" = "null" ]; then + echo "::error::Failed to look up SYMPH team ID from Linear API" + exit 1 + fi + + # Find or create the pipeline-halt label + LABEL_QUERY='{ "query": "{ issueLabels(filter: { name: { eq: \"pipeline-halt\" } }) { nodes { id } } }" }' + LABEL_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: ${LINEAR_API_KEY}" \ + -d "$LABEL_QUERY") + LABEL_ID=$(echo "$LABEL_RESPONSE" | jq -r '.data.issueLabels.nodes[0].id') + + LABEL_IDS_PARAM="" + if [ -n "$LABEL_ID" ] && [ "$LABEL_ID" != "null" ]; then + LABEL_IDS_PARAM=", labelIds: [\"${LABEL_ID}\"]" + else + # Create the label on the SYMPH team + CREATE_LABEL_QUERY=$(cat < config resolver -> orchestrator polls Linear -> creates workspace (after-create hook clones repo) -> renders prompt template with issue context -> dispatches agent run -> agent works in isolated workspace -> orchestrator manages state transitions back to Linear. + +**Key architectural decisions**: +- In-memory state only (no BullMQ/Redis) -- designed for 2-3 concurrent workers +- `strictVariables: true` on LiquidJS -- all template variables must be in render context +- Orchestrator is deliberately "dumb" -- review intelligence, failure classification, and feedback injection live in the agent layer (prompts + skills), not here +- `permissionMode: "bypassPermissions"` required for headless agent runs + +## Build & Run + +```bash +# Install dependencies +pnpm install + +# Build (compiles TypeScript to dist/) +pnpm build # or: npm run build + +# Run the pipeline for a specific product +./run-pipeline.sh +# Products: symphony, jony-agent, hs-data, hs-ui, hs-mobile, stickerlabs, household + +# Run directly (after building) +node dist/src/cli/main.js --acknowledge-high-trust-preview + +# Type check only +pnpm typecheck # or: npx tsc --noEmit + +# Lint +pnpm lint # Biome check + +# Auto-format +pnpm format # Biome format +``` + +No dev server -- this is a CLI tool. The D40 port table does not apply. + +## Conventions + +- **Runtime**: Node.js >= 22, pnpm >= 10, TypeScript strict mode, ES2023 target +- **Module system**: ESM (`"type": "module"`), NodeNext module resolution +- **Imports**: `import type { ... }` for type-only imports (`verbatimModuleSyntax: true`), `.js` extensions required for NodeNext +- **Formatting**: Biome -- spaces (not tabs), double quotes, semicolons always, trailing commas +- **Naming**: kebab-case for file names, PascalCase for types/interfaces, camelCase for functions/variables +- **Validation**: Zod for config/input validation at I/O boundaries +- **Templates**: LiquidJS for prompt rendering -- always pass all required variables (strictVariables is on) +- **Strict TS options**: `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `useUnknownInCatchVariables`, `noImplicitOverride` + +## Testing + +- **Framework**: Vitest +- **Run tests**: `pnpm test` (runs all 347 tests once via `node scripts/test.mjs`) +- **Watch mode**: `pnpm test:watch` +- **Location**: `tests/` directory, mirrors `src/` structure (e.g., `tests/orchestrator/core.test.ts` covers `src/orchestrator/core.ts`) +- **Fixtures**: `tests/fixtures/` for shared test data +- **Coverage**: All new code must have tests. Critical paths (orchestrator, config resolution, tracker) have thorough coverage. +- **Naming**: Test files named after the module they cover; individual test cases named after observable behavior. + +## Pipeline Notes + +### Critical: dist/ staleness + +**The pipeline runs from compiled `dist/`, NOT source.** If you modify source files but forget to rebuild, your changes will not take effect. `run-pipeline.sh` includes a staleness check that compares `src/` timestamps against `dist/src/cli/main.js`. Use `--auto-build` to rebuild automatically, or `--skip-build-check` to bypass. + +### Auto-generated files (never edit directly) +- `dist/` -- compiled output, regenerated by `pnpm build` +- `pipeline-config/workspaces/` -- runtime workspace directories (UUID-named) +- `pnpm-lock.yaml` -- dependency lock file (regenerated by `pnpm install`) + +### Required environment variables +- `LINEAR_API_KEY` -- Linear API token for tracker integration (loaded from `.env` by `run-pipeline.sh`) +- `REPO_URL` -- target repo URL for workspace cloning (set per-product in `run-pipeline.sh`, or override via env) + +### Fragile areas +- **`active_states` in WORKFLOW configs** must include ALL states set during execution (In Progress, In Review, Blocked, Resume). This bug has been hit 3 times -- missing a state causes silent failures. +- **LiquidJS `strictVariables: true`** -- any variable referenced in a prompt template that is not passed in the render context will throw. Always verify template variables match the context passed by `prompt-builder.ts`. +- **`scheduleRetry`** is used for both failures AND continuations -- the max retry limit must only count actual failures, not continuation retries. +- **Hook scripts** run with `cwd: workspacePath`, NOT the WORKFLOW.md location. Relative paths in hooks resolve against the workspace. +- **`issue.state`** is a string in LiquidJS context (via `toTemplateIssue`), not an object. Template conditionals must compare against string values. +- **`stall_timeout_ms`** default (5 min) is too short for Claude Code agents. Set to 900000 (15 min) in WORKFLOW configs. +- **Linear project slug** is the `slugId` UUID, not the team key. + +### Verify commands (must pass before any PR) +```bash +pnpm test # All 347 tests pass +pnpm build # Compiles without errors +pnpm typecheck # No type errors +pnpm lint # Biome passes +``` + +### Scope boundaries +- Do NOT add BullMQ, Redis, or external queue infrastructure -- in-memory state is a deliberate design choice at current scale +- Do NOT move review intelligence or failure classification into the orchestrator -- these belong in the agent layer (prompts + skills) +- Do NOT modify hook scripts without testing against actual workspace creation flow +- Do NOT commit secrets to `.env` in public contexts (current repo is private; audit before making public) +- Every non-Claude-Code component should be designed for removal when Anthropic ships equivalent features diff --git a/INVESTIGATION-BRIEF.md b/INVESTIGATION-BRIEF.md new file mode 100644 index 00000000..546eefa0 --- /dev/null +++ b/INVESTIGATION-BRIEF.md @@ -0,0 +1,118 @@ +# Investigation Brief +## Issue: SYMPH-57 — Consolidate spec-gen to produce 1-2 sub-issues for STANDARD specs + +## Objective +Update three spec-gen skill reference files to change the STANDARD tier task-count target from "2-6" to "1-2". Pipeline telemetry shows ~20 min fixed overhead per ticket regardless of complexity, so fewer larger tickets dramatically reduce total wall-clock time. No logic changes — only documentation/guidance text updates. + +## Relevant Files (ranked by importance) + +1. `~/.claude/skills/spec-gen/references/complexity-router.md` — Primary file. Contains the STANDARD tier definition, the decision tree, Rule 6 (task-count estimate guidance), and the Quick Reference Table. Four distinct locations need updating. +2. `~/.claude/skills/spec-gen/references/model-tendencies.md` — Contains "Task granularity mismatch" bullet and Spec Quality Checklist. Two locations need updating. +3. `~/.claude/skills/spec-gen/SKILL.md` — Step 4 Self-Review checklist references `2-6 for STANDARD`. One location needs updating. + +## Key Code Patterns + +- All files are plain Markdown — no code, no tests, no build step. +- Changes are simple string substitutions: `2-6` → `1-2` in STANDARD-context sentences. +- Be precise: the string `2-6` also appears in non-STANDARD contexts (e.g., "Touches 2-6 files" in the STANDARD Signals list) — do NOT change those. + +## Architecture Context + +These files are read by the `spec-gen` skill (a Claude slash command at `~/.claude/skills/spec-gen/SKILL.md`) during spec generation. They are guidance documents, not executable code. No tests, no imports, no CI pipeline applies to them directly. + +## Exact Changes Required + +### File 1: `~/.claude/skills/spec-gen/references/complexity-router.md` + +**Change 1 — Decision tree (line 13):** +``` +Before: │ ├── 1 capability, ≤6 tasks, clear scope → STANDARD +After: │ ├── 1 capability, ≤2 tasks, clear scope → STANDARD +``` + +**Change 2 — STANDARD definition (line 60):** +``` +Before: **Definition**: A single capability with clear scope that decomposes into 2-6 tasks. +After: **Definition**: A single capability with clear scope that decomposes into 1-2 tasks. +``` + +**Change 3 — Quick Reference Table (line 167):** +``` +Before: | Tasks | 0-1 | 2-6 | 7+ | +After: | Tasks | 0-1 | 1-2 | 7+ | +``` + +**Change 4 — Rule 6, Signal Detection (line 142):** +``` +Before: If you estimate 2-6 tasks → STANDARD. If you estimate 7+ tasks → COMPLEX. If you estimate 1 task → TRIVIAL (unless it's a behavioral change with verification needs). +After: If you estimate 1-2 tasks → STANDARD. If you estimate 3+ tasks → COMPLEX. If you estimate 1 task → TRIVIAL (unless it's a behavioral change with verification needs). +``` +Note: Rule 6 also updates the COMPLEX boundary from "7+" to "3+" to eliminate the 3-6 gap — this is consistent with the new 1-2 STANDARD definition and the "3+ capabilities → COMPLEX" spirit of the spec. If the issue intent is strictly "only change STANDARD, don't touch COMPLEX threshold," keep `7+` and add a TODO noting the gap. + +### File 2: `~/.claude/skills/spec-gen/references/model-tendencies.md` + +**Change 1 — Task granularity mismatch bullet (line 25):** +``` +Before: Target 2-6 tasks for STANDARD features. +After: Target 1-2 tasks for STANDARD features. +``` + +**Change 2 — Spec Quality Checklist (line 76):** +``` +Before: - [ ] Task count is appropriate for complexity tier (2-6 for STANDARD) +After: - [ ] Task count is appropriate for complexity tier (1-2 for STANDARD) +``` + +### File 3: `~/.claude/skills/spec-gen/SKILL.md` + +**Change 1 — Step 4 Self-Review checklist (line 288):** +``` +Before: - [ ] Task count matches complexity tier (2-6 for STANDARD, 7+ for COMPLEX) +After: - [ ] Task count matches complexity tier (1-2 for STANDARD, 7+ for COMPLEX) +``` + +## Test Strategy + +No automated tests. Validate with grep: +```bash +# Confirm no STANDARD-context "2-6" references remain: +grep -n "2-6" ~/.claude/skills/spec-gen/references/complexity-router.md +grep -n "2-6" ~/.claude/skills/spec-gen/references/model-tendencies.md +grep -n "2-6" ~/.claude/skills/spec-gen/SKILL.md + +# Confirm new "1-2" values are present in each file: +grep -n "1-2" ~/.claude/skills/spec-gen/references/complexity-router.md +grep -n "1-2" ~/.claude/skills/spec-gen/references/model-tendencies.md +grep -n "1-2" ~/.claude/skills/spec-gen/SKILL.md +``` + +Note: `complexity-router.md` has `2-6` in the STANDARD Signals list ("Touches 2-6 files") — this is a *file count* signal, NOT a task count. Do NOT change it. + +## Gotchas & Constraints + +- **Only change task-count references to "2-6"**, not file-count references. "Touches 2-6 files" in the STANDARD Signals section stays unchanged. +- **STANDARD examples table** (complexity-router.md lines 78-86) shows 2-4 estimated tasks per example. These are now inconsistent with the new 1-2 target but the issue spec does not mention updating them. Leave them as-is; optionally add a `` HTML comment. +- **Do not change COMPLEX threshold** in the Quick Reference Table unless the spec explicitly says to. The issue is ambiguous on Rule 6 — see Change 4 notes above. +- These files live in `~/.claude/skills/`, NOT in the symphony-ts repo. No PR is needed. Changes are applied directly. +- No build step, no tests, no migration. + +## Key Code Excerpts + +**complexity-router.md lines 12-14 (decision tree):** +``` +│ ├── How many capabilities does it touch? +│ │ ├── 1 capability, ≤6 tasks, clear scope → STANDARD ← change ≤6 to ≤2 +│ │ └── 2+ capabilities, OR architectural change, OR 7+ tasks → COMPLEX +``` + +**complexity-router.md lines 59-60 (STANDARD definition):** +``` +### STANDARD — Generate spec → parent issue in Draft → freeze to sub-issues + +**Definition**: A single capability with clear scope that decomposes into 2-6 tasks. ← change to 1-2 +``` + +**model-tendencies.md lines 24-26 (granularity mismatch):** +``` +- **Task granularity mismatch**: Either decomposes into too many tiny tasks (1 task per endpoint) or too few large tasks (1 task for entire feature). Target 2-6 tasks for STANDARD features. ← change to 1-2 +``` diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 00000000..cd4ebd59 --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,27 @@ +--- +tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: 1fa66498be91 +workspace: + root: /tmp/symphony_workspaces +polling: + interval_ms: 15000 +agent: + max_concurrent_agents: 1 + max_turns: 5 +codex: + command: codex app-server + approval_policy: never +server: + port: 4321 +--- + +You are implementing work for Linear issue {{ issue.identifier }}. + +Rules: +1. Implement only what the ticket asks for. +2. Keep changes scoped and safe. +3. Do not add secrets or credentials to the repository. + +When finished, update the Linear issue state to "Done" using the `linear_graphql` tool. diff --git a/biome.json b/biome.json index 19a6477e..aff96c55 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,7 @@ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "files": { "ignoreUnknown": true, - "ignore": ["dist/**", "node_modules/**"] + "ignore": ["dist/**", "node_modules/**", "pipeline-config/**"] }, "formatter": { "enabled": true, @@ -23,5 +23,17 @@ "semicolons": "always", "trailingCommas": "all" } - } + }, + "overrides": [ + { + "include": ["**/*.test.ts", "**/tests/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] } diff --git a/docs/conformance-test-matrix.md b/docs/conformance-test-matrix.md index 08fd7f31..08443cde 100644 --- a/docs/conformance-test-matrix.md +++ b/docs/conformance-test-matrix.md @@ -65,10 +65,13 @@ tool handling, and the optional `linear_graphql` dynamic tool extension. - `tests/logging/session-metrics.test.ts` - `tests/logging/runtime-snapshot.test.ts` - `tests/observability/dashboard-server.test.ts` +- `tests/orchestrator/runtime-host.test.ts` (poll_tick_completed event) Covered behaviors include operator-visible validation failures via runtime surfaces, structured log context fields, sink failure isolation, token and -rate-limit aggregation, and the operator dashboard APIs. +rate-limit aggregation, the operator dashboard APIs, and the `poll_tick_completed` +structured log event emitted after each successful poll tick (including +`dispatched_count`, `running_count`, `reconciled_stop_requests`, and `duration_ms`). ## 17.7 CLI and Host Lifecycle diff --git a/linear_workpad.py b/linear_workpad.py new file mode 100644 index 00000000..3c8e1896 --- /dev/null +++ b/linear_workpad.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +import urllib.request +import urllib.error +import json +import os +import sys + +LINEAR_API_KEY = os.environ["LINEAR_API_KEY"] +ISSUE_ID = "7b4cc9a1-e014-4463-8cab-78bce7cfa7d0" + +WORKPAD_CONTENT = r"""## Workpad +**Environment**: pro14:/Users/ericlitman/intent/workspaces/architecture-build/repo/symphony-ts@8d4e5b7 + +### Plan +- [ ] **Step 1: Add `poll_tick_completed` to `ORCHESTRATOR_EVENTS` in `src/domain/model.ts`** + - Insert `"poll_tick_completed"` into the array after `"poll_tick"` + +- [ ] **Step 2: Add new log fields to `LOG_FIELDS` in `src/logging/fields.ts`** + - Add `"dispatched_count"`, `"running_count"`, `"reconciled_stop_requests"` to the `LOG_FIELDS` array + +- [ ] **Step 3: Extend `PollTickResult` in `src/orchestrator/core.ts` to include `runningCount`** + - `PollTickResult` already has `dispatchedIssueIds: string[]` and `stopRequests: StopRequest[]` + - Add `runningCount: number` field + - In all three return sites of `pollTick()`, set `runningCount: Object.keys(this.state.running).length` + - Note: `stopRequests` already provides reconciliation stop count, `dispatchedIssueIds.length` provides dispatch count + +- [ ] **Step 4: Add timing in `runPollCycle()` in `src/orchestrator/runtime-host.ts`** + - Before `runtimeHost.pollOnce()`, record `const tickStart = Date.now()` + - After `pollOnce()` returns, compute `durationMs = Date.now() - tickStart` + - Pass `durationMs` to `logPollCycleResult(logger, result, durationMs)` + +- [ ] **Step 5: Update `logPollCycleResult()` signature and body in `src/orchestrator/runtime-host.ts`** + - Add `durationMs: number` parameter + - After the existing warn/error checks, emit an info-level `poll_tick_completed` event: + ```typescript + await logger.info("poll_tick_completed", "Poll tick completed.", { + dispatched_count: result.dispatchedIssueIds.length, + running_count: result.runningCount, + reconciled_stop_requests: result.stopRequests.length, + duration_ms: durationMs, + }); + ``` + +- [ ] **Step 6: Add tests in `tests/orchestrator/runtime-host.test.ts`** + - New describe block for poll tick logging + - Test 1: `poll_tick_completed` event is logged after a successful poll (using `startRuntimeService`) + - Test 2: `dispatched_count` reflects the number of newly dispatched issues + - Verify `running_count` and `reconciled_stop_requests` fields are present and numeric + +### Acceptance Criteria +- [ ] `poll_tick_completed` in `ORCHESTRATOR_EVENTS` +- [ ] `dispatched_count`, `running_count`, `reconciled_stop_requests` in `LOG_FIELDS` +- [ ] `PollTickResult` has `runningCount: number` and all return sites populate it +- [ ] `logPollCycleResult` emits `poll_tick_completed` info event with all four fields +- [ ] `runPollCycle` times the `pollOnce()` call and passes duration +- [ ] Test: `poll_tick_completed` event is logged after successful poll +- [ ] Test: `dispatched_count` reflects newly dispatched issues +- [ ] All existing tests pass +- [ ] `npx tsc --noEmit` passes + +### Validation +- `pnpm test` +- `npx tsc --noEmit` +- `pnpm lint` + +### Notes +- 2026-03-20 Investigation complete. Plan posted. +- `PollTickResult.dispatchedIssueIds` is already `string[]` — use `.length` for `dispatched_count` +- `PollTickResult.stopRequests` is already `StopRequest[]` — use `.length` for `reconciled_stop_requests` +- `runningCount` must be added to `PollTickResult`; it is computed as `Object.keys(this.state.running).length` at the end of `pollTick()` in `core.ts` +- The `logPollCycleResult` function currently takes `(logger, result)` and uses `Awaited>` as the result type — need to add `durationMs: number` parameter +- `duration_ms` already exists in `LOG_FIELDS`, so no new field needed for it +- The three early-return paths in `pollTick()` must all include `runningCount` +""" + +def graphql(query, variables=None): + payload = json.dumps({"query": query, "variables": variables or {}}).encode("utf-8") + req = urllib.request.Request( + "https://api.linear.app/graphql", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": LINEAR_API_KEY, + }, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + +# Step 1: Query existing comments +result = graphql(""" +query GetComments($issueId: String!) { + issue(id: $issueId) { + comments { + nodes { + id + body + } + } + } +} +""", {"issueId": ISSUE_ID}) + +print("Query result:", json.dumps(result, indent=2)) + +comments = result.get("data", {}).get("issue", {}).get("comments", {}).get("nodes", []) +existing = next((c for c in comments if "## Workpad" in c["body"]), None) + +if existing: + print(f"\nFound existing workpad comment: {existing['id']}") + update_result = graphql(""" +mutation UpdateComment($id: String!, $body: String!) { + commentUpdate(id: $id, input: { body: $body }) { + success + comment { + id + } + } +} +""", {"id": existing["id"], "body": WORKPAD_CONTENT}) + print("Update result:", json.dumps(update_result, indent=2)) + print(f"\nACTION: updated") + print(f"COMMENT_ID: {existing['id']}") +else: + print("\nNo existing workpad comment found, creating new one...") + create_result = graphql(""" +mutation CreateComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + comment { + id + } + } +} +""", {"issueId": ISSUE_ID, "body": WORKPAD_CONTENT}) + print("Create result:", json.dumps(create_result, indent=2)) + new_id = create_result.get("data", {}).get("commentCreate", {}).get("comment", {}).get("id") + print(f"\nACTION: created") + print(f"COMMENT_ID: {new_id}") diff --git a/ops/com.slack-bridge.plist b/ops/com.slack-bridge.plist new file mode 100644 index 00000000..614b4bf2 --- /dev/null +++ b/ops/com.slack-bridge.plist @@ -0,0 +1,64 @@ + + + + + + Label + com.slack-bridge + + ProgramArguments + + /opt/homebrew/bin/node + /path/to/symphony-ts/dist/src/cli/main.js + + + WorkingDirectory + /path/to/symphony-ts + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + /Users/youruser + NODE_ENV + production + SLACK_BOT_TOKEN + xoxb-xxxxx + SLACK_SIGNING_SECRET + xxxxx + CHANNEL_PROJECT_MAP + {"C123":"/path/to/project"} + + + StandardOutPath + ~/Library/Logs/slack-bridge/stdout.log + + StandardErrorPath + ~/Library/Logs/slack-bridge/stderr.log + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 60 + + SoftResourceLimits + + NumberOfFiles + 4096 + + + ProcessType + Background + + diff --git a/ops/com.symphony.example.plist b/ops/com.symphony.example.plist new file mode 100644 index 00000000..251f5552 --- /dev/null +++ b/ops/com.symphony.example.plist @@ -0,0 +1,68 @@ + + + + + + Label + com.symphony.example + + ProgramArguments + + /opt/homebrew/bin/node + /path/to/symphony-ts/dist/src/cli/main.js + /path/to/symphony-ts/WORKFLOW.md + --acknowledge-high-trust-preview + --logs-root + ~/Library/Logs/symphony/example/ + + + WorkingDirectory + /path/to/symphony-ts + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + /Users/youruser + NODE_ENV + production + LINEAR_API_KEY + lin_api_xxxxx + LINEAR_PROJECT_SLUG + your-project-slug + REPO_URL + https://github.com/org/repo.git + + + StandardOutPath + ~/Library/Logs/symphony/example/stdout.log + + StandardErrorPath + ~/Library/Logs/symphony/example/stderr.log + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 60 + + SoftResourceLimits + + NumberOfFiles + 4096 + + + ProcessType + Background + + diff --git a/ops/com.symphony.newsyslog.conf b/ops/com.symphony.newsyslog.conf new file mode 100644 index 00000000..c670f1af --- /dev/null +++ b/ops/com.symphony.newsyslog.conf @@ -0,0 +1,19 @@ +# DEPRECATED — superseded by `token-report.sh rotate` (SYMPH-131) +# +# This newsyslog config is no longer used. Log rotation for symphony logs +# is now handled by `token-report.sh rotate` / `token-report.mjs rotate`, +# which runs as part of the daily pipeline. The two cannot coexist because +# newsyslog renames/truncates without HWM coordination. +# +# To remove this config if previously installed: +# sudo rm /usr/local/etc/newsyslog.d/com.symphony.newsyslog.conf +# +# Original config (preserved for reference): +# Install: sudo cp ops/com.symphony.newsyslog.conf /etc/newsyslog.d/ +# +# Fields: logfile owner:group mode count size(KB) when flags +# - Rotates at 10MB, keeps 5 archives, compresses old logs (J = bzip2) +# - Wildcard (*) matches any project name under ~/Library/Logs/symphony/ + +/Users/*/Library/Logs/symphony/*/stdout.log : 644 5 10240 * J +/Users/*/Library/Logs/symphony/*/stderr.log : 644 5 10240 * J diff --git a/ops/com.symphony.report-server.plist b/ops/com.symphony.report-server.plist new file mode 100644 index 00000000..217a7253 --- /dev/null +++ b/ops/com.symphony.report-server.plist @@ -0,0 +1,54 @@ + + + + + + Label + com.symphony.report-server + + ProgramArguments + + /usr/bin/python3 + -m + http.server + 8090 + --directory + /Users/clawdilize/.symphony/reports + + + WorkingDirectory + /Users/clawdilize/.symphony/reports + + EnvironmentVariables + + PATH + /usr/bin:/usr/sbin:/bin:/sbin + HOME + /Users/clawdilize + + + StandardOutPath + /Users/clawdilize/.symphony/logs/report-server-stdout.log + + StandardErrorPath + /Users/clawdilize/.symphony/logs/report-server-stderr.log + + KeepAlive + + + ThrottleInterval + 10 + + ProcessType + Background + + diff --git a/ops/com.symphony.token-report.plist b/ops/com.symphony.token-report.plist new file mode 100644 index 00000000..72b834d6 --- /dev/null +++ b/ops/com.symphony.token-report.plist @@ -0,0 +1,61 @@ + + + + + + Label + com.symphony.token-report + + ProgramArguments + + /bin/bash + /Users/clawdilize/projects/symphony-ts/ops/token-report.sh + daily + + + WorkingDirectory + /Users/clawdilize/projects/symphony-ts + + StartCalendarInterval + + Hour + 6 + Minute + 0 + + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + /Users/clawdilize + NODE_ENV + production + + + StandardOutPath + /Users/clawdilize/.symphony/logs/token-report-stdout.log + + StandardErrorPath + /Users/clawdilize/.symphony/logs/token-report-stderr.log + + KeepAlive + + + ProcessType + Background + + diff --git a/ops/slack-bridge-ctl b/ops/slack-bridge-ctl new file mode 100755 index 00000000..90140865 --- /dev/null +++ b/ops/slack-bridge-ctl @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +set -euo pipefail + +# slack-bridge-ctl — manage the Slack bridge as a macOS launchd service +# Usage: slack-bridge-ctl {install|uninstall|start|stop|restart|status|logs|tail|cleanup} + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +SYMPHONY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Defaults — override via environment or .env +SERVICE_LABEL="com.slack-bridge" +PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_LABEL}.plist" +LOG_DIR="$HOME/Library/Logs/slack-bridge" +ENV_FILE="${SLACK_BRIDGE_ENV_FILE:-$SYMPHONY_ROOT/.env}" +NODE_BIN="${SLACK_BRIDGE_NODE:-$(which node 2>/dev/null || echo /opt/homebrew/bin/node)}" +CLI_JS="$SYMPHONY_ROOT/dist/src/slack-bot/server.js" + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' +fi + +info() { echo -e "${CYAN}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; } +die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } + +# --- Precondition checks --- + +check_node() { + [[ -x "$NODE_BIN" ]] || die "Node not found at $NODE_BIN. Set SLACK_BRIDGE_NODE or install Node >= 22." +} + +check_built() { + [[ -f "$CLI_JS" ]] || die "Built CLI not found at $CLI_JS. Run 'pnpm build' in $SYMPHONY_ROOT first." +} + +check_env_file() { + [[ -f "$ENV_FILE" ]] || die ".env file not found at $ENV_FILE. Set SLACK_BRIDGE_ENV_FILE to override." +} + +check_not_installed() { + [[ ! -f "$PLIST_PATH" ]] || die "Service already installed at $PLIST_PATH. Run 'uninstall' first." +} + +check_installed() { + [[ -f "$PLIST_PATH" ]] || die "Service not installed. Run 'install' first." +} + +# --- .env → plist EnvironmentVariables --- + +generate_env_dict() { + local env_dict="" + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and blank lines + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + + local key="${line%%=*}" + local value="${line#*=}" + # Remove surrounding quotes from value + value="${value#\"}" ; value="${value%\"}" + value="${value#\'}" ; value="${value%\'}" + # Strip inline comments + value="${value%% \#*}" + + env_dict+=" ${key}"$'\n' + env_dict+=" ${value}"$'\n' + done < "$ENV_FILE" + echo "$env_dict" +} + +# --- Commands --- + +cmd_install() { + check_node + check_built + check_env_file + check_not_installed + + info "Installing $SERVICE_LABEL ..." + + mkdir -p "$LOG_DIR" + mkdir -p "$(dirname "$PLIST_PATH")" + + local env_dict + env_dict="$(generate_env_dict)" + + cat > "$PLIST_PATH" < + + + + Label + ${SERVICE_LABEL} + + ProgramArguments + + ${NODE_BIN} + ${CLI_JS} + + + WorkingDirectory + ${SYMPHONY_ROOT} + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + ${HOME} + NODE_ENV + production +${env_dict} + + StandardOutPath + ${LOG_DIR}/stdout.log + + StandardErrorPath + ${LOG_DIR}/stderr.log + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 60 + + SoftResourceLimits + + NumberOfFiles + 4096 + + + ProcessType + Background + + +PLIST + + ok "Plist written to $PLIST_PATH" + info "Run 'slack-bridge-ctl start' to start the service." +} + +cmd_uninstall() { + check_installed + cmd_stop 2>/dev/null || true + rm -f "$PLIST_PATH" + ok "Service uninstalled." +} + +cmd_start() { + check_installed + launchctl load "$PLIST_PATH" + ok "Service started." +} + +cmd_stop() { + check_installed + launchctl unload "$PLIST_PATH" 2>/dev/null || true + ok "Service stopped." +} + +cmd_restart() { + cmd_stop + cmd_start +} + +cmd_status() { + if launchctl list "$SERVICE_LABEL" &>/dev/null; then + ok "Service is running." + launchctl list "$SERVICE_LABEL" + else + warn "Service is not running." + fi +} + +cmd_logs() { + if [[ -f "$LOG_DIR/stdout.log" ]]; then + cat "$LOG_DIR/stdout.log" + else + warn "No stdout log found at $LOG_DIR/stdout.log" + fi + if [[ -f "$LOG_DIR/stderr.log" ]]; then + echo "--- stderr ---" + cat "$LOG_DIR/stderr.log" + fi +} + +cmd_tail() { + tail -f "$LOG_DIR/stdout.log" "$LOG_DIR/stderr.log" 2>/dev/null || die "No log files found in $LOG_DIR" +} + +cmd_cleanup() { + info "Cleaning up logs in $LOG_DIR ..." + rm -f "$LOG_DIR"/*.log + ok "Logs cleaned." +} + +# --- Main --- + +case "${1:-}" in + install) cmd_install ;; + uninstall) cmd_uninstall ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) cmd_status ;; + logs) cmd_logs ;; + tail) cmd_tail ;; + cleanup) cmd_cleanup ;; + *) + echo "Usage: $(basename "$0") {install|uninstall|start|stop|restart|status|logs|tail|cleanup}" + exit 1 + ;; +esac diff --git a/ops/symphony-ctl b/ops/symphony-ctl new file mode 100755 index 00000000..9b1f9c00 --- /dev/null +++ b/ops/symphony-ctl @@ -0,0 +1,1104 @@ +#!/usr/bin/env bash +set -euo pipefail + +# symphony-ctl — manage symphony-ts as a macOS launchd service +# Usage: symphony-ctl {install|uninstall|start|stop|restart|status|logs|tail|cleanup} + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +SYMPHONY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Defaults — override via environment or .env +SYMPHONY_PROJECT="${SYMPHONY_PROJECT:-symphony}" +SERVICE_LABEL="com.symphony.${SYMPHONY_PROJECT}" +PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_LABEL}.plist" +LOG_DIR="$HOME/Library/Logs/symphony/${SYMPHONY_PROJECT}" +ENV_FILE="${SYMPHONY_ENV_FILE:-$SYMPHONY_ROOT/.env}" +WORKFLOW_PATH="${SYMPHONY_WORKFLOW:-$SYMPHONY_ROOT/pipeline-config/workflows/WORKFLOW-${SYMPHONY_PROJECT}.md}" +NODE_BIN="${SYMPHONY_NODE:-$(which node 2>/dev/null || echo /opt/homebrew/bin/node)}" +CLI_JS="$SYMPHONY_ROOT/dist/src/cli/main.js" + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' +fi + +info() { echo -e "${CYAN}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; } +die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } + +# --- Precondition checks --- + +check_node() { + [[ -x "$NODE_BIN" ]] || die "Node not found at $NODE_BIN. Set SYMPHONY_NODE or install Node >= 22." +} + +check_built() { + [[ -f "$CLI_JS" ]] || die "Built CLI not found at $CLI_JS. Run 'pnpm build' in $SYMPHONY_ROOT first." +} + +check_env_file() { + [[ -f "$ENV_FILE" ]] || die ".env file not found at $ENV_FILE. Set SYMPHONY_ENV_FILE to override." +} + +check_workflow() { + [[ -f "$WORKFLOW_PATH" ]] || die "WORKFLOW.md not found at $WORKFLOW_PATH. Set SYMPHONY_WORKFLOW to override." +} + +check_not_installed() { + [[ ! -f "$PLIST_PATH" ]] || die "Service already installed at $PLIST_PATH. Run 'uninstall' first." +} + +check_installed() { + [[ -f "$PLIST_PATH" ]] || die "Service not installed. Run 'install' first." +} + +# --- .env → plist EnvironmentVariables --- + +generate_env_dict() { + local env_dict="" + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and blank lines + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + # Trim leading/trailing whitespace (bash-native, preserves quotes) + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + + local key="${line%%=*}" + local value="${line#*=}" + # Remove surrounding quotes from value + value="${value#\"}" ; value="${value%\"}" + value="${value#\'}" ; value="${value%\'}" + # Strip inline comments (only unquoted: space then #) + # Only strip if value was not quoted (quotes already removed above) + value="${value%% \#*}" + + env_dict+=" ${key}"$'\n' + env_dict+=" ${value}"$'\n' + done < "$ENV_FILE" + echo "$env_dict" +} + +# --- plist generation --- + +generate_plist() { + local env_dict + env_dict="$(generate_env_dict)" + + cat < + + + + Label + ${SERVICE_LABEL} + + ProgramArguments + + ${NODE_BIN} + ${CLI_JS} + ${WORKFLOW_PATH} + --acknowledge-high-trust-preview + --logs-root + ${LOG_DIR} + + + WorkingDirectory + ${SYMPHONY_ROOT} + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + ${HOME} + NODE_ENV + production +${env_dict} + + StandardOutPath + ${LOG_DIR}/stdout.log + + StandardErrorPath + ${LOG_DIR}/stderr.log + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 60 + + SoftResourceLimits + + NumberOfFiles + 4096 + + + ProcessType + Background + + +PLIST +} + +# --- Commands --- + +cmd_install() { + check_node + check_built + check_env_file + check_workflow + check_not_installed + + mkdir -p "$LOG_DIR" + mkdir -p "$(dirname "$PLIST_PATH")" + + generate_plist > "$PLIST_PATH" + ok "Plist written to $PLIST_PATH" + + launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH" + ok "Service registered: $SERVICE_LABEL" + info "Run 'symphony-ctl start' to begin polling." +} + +cmd_uninstall() { + check_installed + + # Stop first if running + if launchctl print "gui/$(id -u)/${SERVICE_LABEL}" &>/dev/null; then + launchctl kill SIGTERM "gui/$(id -u)/${SERVICE_LABEL}" 2>/dev/null || true + sleep 1 + fi + + launchctl bootout "gui/$(id -u)/${SERVICE_LABEL}" 2>/dev/null || true + rm -f "$PLIST_PATH" + ok "Service uninstalled: $SERVICE_LABEL" +} + +cmd_start() { + check_installed + + if is_running; then + warn "Service is already running." + return 0 + fi + + launchctl kickstart "gui/$(id -u)/${SERVICE_LABEL}" + ok "Service started: $SERVICE_LABEL" + info "Dashboard: http://localhost:$(get_port)" + info "Logs: $LOG_DIR/" +} + +cmd_stop() { + check_installed + + if ! is_running; then + warn "Service is not running." + return 0 + fi + + launchctl kill SIGTERM "gui/$(id -u)/${SERVICE_LABEL}" + ok "Service stopped: $SERVICE_LABEL" +} + +cmd_restart() { + check_installed + + if is_running; then + cmd_stop + sleep 1 + fi + cmd_start +} + +cmd_status() { + if [[ ! -f "$PLIST_PATH" ]]; then + info "Service not installed." + return 0 + fi + + echo "" + info "Service: $SERVICE_LABEL" + info "Plist: $PLIST_PATH" + info "Workflow: $WORKFLOW_PATH" + info "Logs: $LOG_DIR/" + info "Dashboard: http://localhost:$(get_port)" + echo "" + + if is_running; then + local pid + pid="$(get_pid)" + ok "Running (PID ${pid:-unknown})" + else + warn "Not running" + fi + + # Show last few lines of stderr if available + if [[ -f "$LOG_DIR/stderr.log" ]]; then + local size + size="$(wc -c < "$LOG_DIR/stderr.log" | tr -d ' ')" + if [[ "$size" -gt 0 ]]; then + echo "" + info "Last 5 lines of stderr.log:" + tail -5 "$LOG_DIR/stderr.log" | sed 's/^/ /' + fi + fi +} + +cmd_logs() { + if [[ ! -d "$LOG_DIR" ]]; then + die "Log directory not found: $LOG_DIR" + fi + + local log_file="${1:-stderr}" + local log_path="$LOG_DIR/${log_file}.log" + + [[ -f "$log_path" ]] || die "Log file not found: $log_path" + less +G "$log_path" +} + +cmd_tail() { + if [[ ! -d "$LOG_DIR" ]]; then + die "Log directory not found: $LOG_DIR" + fi + + info "Tailing stderr.log (Ctrl-C to stop)..." + tail -f "$LOG_DIR/stderr.log" "$LOG_DIR/stdout.log" 2>/dev/null +} + +# --- Helpers --- + +is_running() { + launchctl print "gui/$(id -u)/${SERVICE_LABEL}" &>/dev/null && \ + launchctl print "gui/$(id -u)/${SERVICE_LABEL}" 2>/dev/null | grep -q 'pid = [0-9]' +} + +get_pid() { + launchctl print "gui/$(id -u)/${SERVICE_LABEL}" 2>/dev/null | grep -oE 'pid = [0-9]+' | grep -oE '[0-9]+' +} + +get_port() { + # Extract port from WORKFLOW frontmatter + if [[ -f "$WORKFLOW_PATH" ]]; then + local port + port="$(sed -n '/^---$/,/^---$/p' "$WORKFLOW_PATH" | grep -E '^\s*port:' | head -1 | awk '{print $2}')" + echo "${port:-4321}" + else + echo "4321" + fi +} + +# --- Log rotation --- + +cmd_install_logrotate() { + local conf_src="$SCRIPT_DIR/com.symphony.newsyslog.conf" + local conf_dest="/etc/newsyslog.d/com.symphony.newsyslog.conf" + + [[ -f "$conf_src" ]] || die "newsyslog config not found at $conf_src" + + info "Installing log rotation config to $conf_dest" + sudo cp "$conf_src" "$conf_dest" + ok "Log rotation installed. Logs rotate at 10MB, keep 5 archives." + info "newsyslog checks this automatically — no restart needed." +} + +# --- Cleanup --- + +cmd_cleanup() { + local execute=false + local skip_github=false + local skip_linear=false + local prune_branches=false + + # Parse flags + while [[ $# -gt 0 ]]; do + case "$1" in + --execute) execute=true ;; + --skip-github) skip_github=true ;; + --skip-linear) skip_linear=true ;; + --prune-branches) prune_branches=true ;; + *) die "Unknown flag: $1" ;; + esac + shift + done + + # Load env if not already set + if [[ -f "$ENV_FILE" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + local key="${line%%=*}" + local value="${line#*=}" + value="${value#\"}" ; value="${value%\"}" + value="${value#\'}" ; value="${value%\'}" + value="${value%% \#*}" + # Only export if not already set + if [[ -z "${!key:-}" ]]; then + export "$key=$value" + fi + done < "$ENV_FILE" + fi + + check_workflow + + # Parse WORKFLOW frontmatter for project_slug and workspace root + local frontmatter + frontmatter="$(sed -n '/^---$/,/^---$/p' "$WORKFLOW_PATH")" + + local project_slug + project_slug="$(echo "$frontmatter" | grep -E '^\s*project_slug:' | head -1 | awk '{print $2}')" + [[ -n "$project_slug" ]] || die "Could not parse project_slug from $WORKFLOW_PATH frontmatter" + + local workspace_root_raw + workspace_root_raw="$(echo "$frontmatter" | grep -E '^\s*root:' | head -1 | awk '{print $2}')" + [[ -n "$workspace_root_raw" ]] || die "Could not parse workspace.root from $WORKFLOW_PATH frontmatter" + + # Resolve workspace root relative to WORKFLOW_PATH directory + local workflow_dir + workflow_dir="$(cd "$(dirname "$WORKFLOW_PATH")" && pwd)" + local workspace_root + if [[ "$workspace_root_raw" == /* ]]; then + workspace_root="$workspace_root_raw" + else + workspace_root="$workflow_dir/$workspace_root_raw" + fi + + # Extract GitHub owner/repo from REPO_URL + local github_repo="" + if [[ -n "${REPO_URL:-}" ]]; then + github_repo="$(echo "$REPO_URL" | sed -E 's|^https?://github\.com/||; s|\.git$||')" + fi + + local dashboard_port + dashboard_port="$(get_port)" + + if $execute; then + info "symphony-ctl cleanup [EXECUTING]" + else + info "symphony-ctl cleanup [DRY RUN]" + fi + echo "" + + local count_workspaces=0 + local count_prs=0 + local count_logs=0 + local count_stale_issues=0 + + # --- 1. Local workspaces --- + info "Local workspaces:" + if [[ -d "$workspace_root" ]]; then + local has_workspaces=false + for dir in "$workspace_root"/*/; do + [[ -d "$dir" ]] || continue + local uuid + uuid="$(basename "$dir")" + # Only process UUID-shaped directories + [[ "$uuid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || continue + has_workspaces=true + + if $skip_linear || [[ -z "${LINEAR_API_KEY:-}" ]]; then + warn " $uuid — skipped (Linear not available)" + continue + fi + + # Query Linear for the issue state + local response + response="$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "{\"query\":\"{ issue(id: \\\"$uuid\\\") { identifier title state { name type } } }\"}" 2>/dev/null)" || true + + local issue_id issue_state state_type + issue_id="$(echo "$response" | grep -o '"identifier":"[^"]*"' | head -1 | cut -d'"' -f4)" + issue_state="$(echo "$response" | grep -o '"name":"[^"]*"' | head -1 | cut -d'"' -f4)" + state_type="$(echo "$response" | grep -o '"type":"[^"]*"' | head -1 | cut -d'"' -f4)" + + if [[ -z "$issue_id" ]]; then + # Issue not found in Linear + if $execute; then + rm -rf "$dir" + ok " $uuid (issue not found) — removed" + else + ok " $uuid (issue not found) — would remove" + fi + count_workspaces=$((count_workspaces + 1)) + elif [[ "$state_type" == "completed" || "$state_type" == "canceled" || "$state_type" == "cancelled" ]]; then + if $execute; then + rm -rf "$dir" + ok " $uuid ($issue_id, $issue_state) — removed" + else + ok " $uuid ($issue_id, $issue_state) — would remove" + fi + count_workspaces=$((count_workspaces + 1)) + else + info " $uuid ($issue_id, $issue_state) — active, keeping" + fi + done + if ! $has_workspaces; then + info " (none found)" + fi + else + info " (workspace root not found: $workspace_root)" + fi + echo "" + + # --- 2. Orphaned PRs --- + info "Orphaned PRs:" + if $skip_github; then + warn " skipped (--skip-github)" + elif [[ -z "$github_repo" ]]; then + warn " skipped (REPO_URL not set)" + elif ! command -v gh &>/dev/null; then + warn " skipped (gh CLI not found)" + else + local pr_json + pr_json="$(gh pr list --repo "$github_repo" --state open --json number,title,headRefName 2>/dev/null)" || { + warn " skipped (gh pr list failed)" + pr_json="[]" + } + + # Use gh's --jq to extract tab-delimited fields (avoids fragile JSON parsing) + local pr_lines + pr_lines="$(gh pr list --repo "$github_repo" --state open \ + --json number,title,headRefName \ + --jq '.[] | [.number, .headRefName, .title] | @tsv' 2>/dev/null)" || { + warn " skipped (gh pr list failed)" + pr_lines="" + } + + if [[ -z "$pr_lines" ]]; then + info " (no open PRs)" + else + while IFS=$'\t' read -r pr_number pr_branch pr_title; do + [[ -n "$pr_number" ]] || continue + + # Check if branch matches pipeline pattern (eric/mob-*) + if [[ "$pr_branch" =~ ^eric/mob- ]]; then + # Extract MOB identifier from title or branch + local mob_id + mob_id="$(echo "$pr_title" | grep -oE 'MOB-[0-9]+' | head -1)" + [[ -n "$mob_id" ]] || mob_id="$(echo "$pr_branch" | grep -oE 'mob-[0-9]+' | head -1 | tr '[:lower:]' '[:upper:]')" + + local should_close=false + local reason="" + + if ! $skip_linear && [[ -n "${LINEAR_API_KEY:-}" ]] && [[ -n "$mob_id" ]]; then + # Parse team key and number from identifier (e.g., MOB-16 → team=MOB, number=16) + local team_key issue_number + team_key="$(echo "$mob_id" | cut -d'-' -f1)" + issue_number="$(echo "$mob_id" | cut -d'-' -f2)" + + local pr_response + pr_response="$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "{\"query\":\"{ issues(filter: { number: { eq: $issue_number }, team: { key: { eq: \\\"$team_key\\\" } } }) { nodes { identifier state { name type } } } }\"}" 2>/dev/null)" || true + + local pr_state_type + pr_state_type="$(echo "$pr_response" | grep -o '"type":"[^"]*"' | head -1 | cut -d'"' -f4)" + + if [[ "$pr_state_type" == "completed" || "$pr_state_type" == "canceled" || "$pr_state_type" == "cancelled" ]]; then + should_close=true + local pr_issue_state + pr_issue_state="$(echo "$pr_response" | grep -o '"name":"[^"]*"' | head -1 | cut -d'"' -f4)" + reason="$mob_id is $pr_issue_state" + fi + fi + + if $should_close; then + if $execute; then + gh pr close "$pr_number" --repo "$github_repo" --delete-branch 2>/dev/null && \ + ok " PR #$pr_number ($reason) — closed + branch deleted" || \ + warn " PR #$pr_number ($reason) — failed to close" + else + ok " PR #$pr_number ($reason) — would close + delete branch" + fi + count_prs=$((count_prs + 1)) + else + info " PR #$pr_number ($pr_branch) — issue still active, keeping" + fi + fi + done <<< "$pr_lines" + fi + fi + echo "" + + # --- 3. Stale "In Progress" issues --- + info "Stale issues:" + if $skip_linear || [[ -z "${LINEAR_API_KEY:-}" ]]; then + warn " skipped (Linear not available)" + else + local ip_response + ip_response="$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "{\"query\":\"{ issues(filter: { project: { slugId: { eq: \\\"$project_slug\\\" } }, state: { name: { eq: \\\"In Progress\\\" } } }) { nodes { id identifier title state { name } } } }\"}" 2>/dev/null)" || true + + # Try to get dashboard state for cross-reference + local dashboard_state="" + local dashboard_available=false + dashboard_state="$(curl -s --connect-timeout 2 "http://localhost:$dashboard_port/api/v1/state" 2>/dev/null)" || true + if [[ -n "$dashboard_state" ]] && echo "$dashboard_state" | grep -q '"agents"' 2>/dev/null; then + dashboard_available=true + fi + + # Parse In Progress issues + local ip_issues + ip_issues="$(echo "$ip_response" | grep -o '"identifier":"[^"]*"' | cut -d'"' -f4 || true)" + + if [[ -z "$ip_issues" ]]; then + info " (no In Progress issues)" + else + local idx=0 + while IFS= read -r ident; do + local ip_title + # Extract corresponding title (nth occurrence) + ip_title="$(echo "$ip_response" | grep -o '"title":"[^"]*"' | sed -n "$((idx + 1))p" | cut -d'"' -f4)" + idx=$((idx + 1)) + + local has_worker=false + if $dashboard_available; then + # Check if issue identifier appears in dashboard state + if echo "$dashboard_state" | grep -q "$ident" 2>/dev/null; then + has_worker=true + fi + fi + + if $has_worker; then + info " $ident \"$ip_title\" — In Progress, worker active" + elif $dashboard_available; then + warn " $ident \"$ip_title\" — In Progress, no active worker" + count_stale_issues=$((count_stale_issues + 1)) + else + warn " $ident \"$ip_title\" — In Progress (dashboard unreachable, cannot verify worker)" + count_stale_issues=$((count_stale_issues + 1)) + fi + done <<< "$ip_issues" + fi + fi + echo "" + + # --- 4. Log files --- + info "Log files:" + local has_logs=false + while IFS= read -r logfile; do + [[ -f "$logfile" ]] || continue + has_logs=true + + # Check age — find files older than 7 days + if [[ "$(uname)" == "Darwin" ]]; then + local file_age_days + local file_mod + file_mod="$(stat -f %m "$logfile")" + local now + now="$(date +%s)" + file_age_days=$(( (now - file_mod) / 86400 )) + else + local file_age_days=0 + if find "$logfile" -mtime +7 -print | grep -q .; then + file_age_days=8 + fi + fi + + if [[ "$file_age_days" -ge 7 ]]; then + if $execute; then + rm -f "$logfile" + ok " $logfile (${file_age_days}d old) — removed" + else + ok " $logfile (${file_age_days}d old) — would remove" + fi + count_logs=$((count_logs + 1)) + else + info " $logfile (${file_age_days}d old) — recent, keeping" + fi + done < <(find "$LOG_DIR" -name "*.log" -o -name "*.jsonl" 2>/dev/null) + if ! $has_logs; then + info " (none found)" + fi + echo "" + + # --- Prune branches (optional phase) --- + local count_branches=0 + if $prune_branches; then + info "=== Prune Branches ===" + _do_prune_branches "$execute" + count_branches="$_PRUNE_BRANCHES_COUNT" + fi + + # --- Summary --- + info "Summary: $count_workspaces workspaces, $count_prs PRs, $count_stale_issues stale issues, $count_logs logs, $count_branches branches" + if ! $execute; then + info "Run with --execute to apply." + fi +} + +# --- Prune Branches --- + +# Shared implementation: fetch --prune, list merged branches, optionally delete. +# Sets global _PRUNE_BRANCHES_COUNT to the number of branches acted on. +# Arguments: execute (true|false) +_do_prune_branches() { + local execute="${1:-false}" + _PRUNE_BRANCHES_COUNT=0 + + # Step 1: fetch --prune to clean stale remote-tracking refs + info "Fetching and pruning stale remote-tracking refs..." + git -C "$SYMPHONY_ROOT" fetch --prune + echo "" + + # Step 2: resolve the currently checked-out branch so we can skip it + local current_branch + current_branch="$(git -C "$SYMPHONY_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")" + + # Step 3: list local branches fully merged into main + info "Local branches merged into main (skipping main, master, current):" + local found=false + while IFS= read -r raw_branch; do + # Strip leading whitespace and the "* " current-branch marker + local branch + branch="$(echo "$raw_branch" | xargs)" + branch="${branch#\* }" + [[ -z "$branch" ]] && continue + [[ "$branch" == "main" || "$branch" == "master" || "$branch" == "$current_branch" ]] && continue + found=true + if $execute; then + git -C "$SYMPHONY_ROOT" branch -d "$branch" + ok " $branch — deleted" + else + info " $branch — would delete" + fi + _PRUNE_BRANCHES_COUNT=$((_PRUNE_BRANCHES_COUNT + 1)) + done < <(git -C "$SYMPHONY_ROOT" branch --merged main 2>/dev/null) + + if ! $found; then + info " (none found)" + fi + echo "" +} + +cmd_prune_branches() { + local execute=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --execute) execute=true ;; + --dry-run) execute=false ;; + *) die "Unknown flag: $1" ;; + esac + shift + done + + if $execute; then + info "symphony-ctl prune-branches [EXECUTING]" + else + info "symphony-ctl prune-branches [DRY RUN]" + fi + echo "" + + _do_prune_branches "$execute" + info "Summary: $_PRUNE_BRANCHES_COUNT merged branch(es)" + if ! $execute; then + info "Run with --execute to apply." + fi +} + +# --- Analyze --- + +# Format milliseconds into a human-readable duration string. +_fmt_duration() { + local ms="${1:-0}" + ms="${ms%%.*}" # strip any decimal fraction jq may emit + ms="${ms:-0}" + local s=$((ms / 1000)) + local m=$((s / 60)) + local h=$((m / 60)) + if [[ $ms -lt 1000 ]]; then echo "${ms}ms" + elif [[ $s -lt 60 ]]; then echo "${s}s" + elif [[ $m -lt 60 ]]; then printf "%dm %ds" "$m" "$((s % 60))" + else printf "%dh %dm" "$h" "$((m % 60))" + fi +} + +# Compute all analysis aggregates and return a JSON object via stdout. +# Arguments: stage_json turn_json log_path +_analyze_compute() { + local stages="$1" turns="$2" log_path="$3" + + jq -n \ + --argjson stages "$stages" \ + --argjson turns "$turns" \ + --arg log_path "$log_path" \ + ' + def safe_div(a; b): if b == 0 then 0.0 else (a / b) end; + + ($stages | length) as $stage_count | + ($turns | length) as $turn_event_count | + ($stages | [.[].total_tokens // 0] | add // 0) as $total_tokens | + ($stages | [.[].input_tokens // 0] | add // 0) as $total_input | + ($stages | [.[].output_tokens // 0] | add // 0) as $total_output | + ($stages | [.[].cache_read_tokens // 0] | add // 0) as $total_cache_read | + ($stages | [.[].cache_write_tokens // 0] | add // 0) as $total_cache_write | + ($stages | [.[].duration_ms // 0] | add // 0) as $total_duration | + ($stages | [.[].turn_count // 0] | add // 0) as $total_turns | + ($stages | [.[] | select(.outcome == "completed")] | length) as $completed | + ($stages | [.[] | select(.outcome == "failed")] | length) as $failed | + ($stages | [.[] | .issue_identifier // "unknown"] | unique | length) as $issue_count | + + safe_div($total_tokens; $stage_count) as $avg_tokens | + safe_div($total_turns; $stage_count) as $avg_turns | + safe_div($total_cache_read * 100; ($total_input + $total_cache_read)) as $cache_hit_pct | + + # Per-issue aggregates (sorted by total tokens desc) + ($stages | group_by(.issue_identifier // "unknown") | map({ + issue: (.[0].issue_identifier // "unknown"), + stages: length, + turns: ([.[].turn_count // 0] | add // 0), + total_tokens: ([.[].total_tokens // 0] | add // 0), + input_tokens: ([.[].input_tokens // 0] | add // 0), + output_tokens: ([.[].output_tokens // 0] | add // 0), + cache_read_tokens: ([.[].cache_read_tokens // 0] | add // 0), + duration_ms: ([.[].duration_ms // 0] | add // 0), + completed: ([.[] | select(.outcome == "completed")] | length), + failed: ([.[] | select(.outcome == "failed")] | length) + }) | sort_by(-.total_tokens)) as $per_issue | + + # Per-stage-name averages (sorted alphabetically by stage name) + ($stages | group_by(.stage_name // "unknown") | map({ + stage: (.[0].stage_name // "unknown"), + count: length, + avg_turns: safe_div([.[].turn_count // 0] | add // 0; length), + avg_tokens: safe_div([.[].total_tokens // 0] | add // 0; length), + avg_cache_read: safe_div([.[].cache_read_tokens // 0] | add // 0; length), + avg_duration_ms: safe_div([.[].duration_ms // 0] | add // 0; length), + completed: ([.[] | select(.outcome == "completed")] | length), + failed: ([.[] | select(.outcome == "failed")] | length) + }) | sort_by(.stage)) as $per_stage | + + # Per-turn stats from turn_completed events + ($turns | { + count: length, + avg_tokens: safe_div([.[].total_tokens // 0] | add // 0; length), + avg_input: safe_div([.[].input_tokens // 0] | add // 0; length), + avg_cache_read: safe_div([.[].cache_read_tokens // 0] | add // 0; length) + }) as $per_turn | + + # Outliers: stages where total_tokens or turn_count > 2x overall average + ([$stages[] | + select( + (($avg_tokens > 0) and ((.total_tokens // 0) > ($avg_tokens * 2))) or + (($avg_turns > 0) and ((.turn_count // 0) > ($avg_turns * 2))) + ) | + { + issue: (.issue_identifier // "unknown"), + stage: (.stage_name // "unknown"), + total_tokens: (.total_tokens // 0), + turn_count: (.turn_count // 0), + token_ratio: (if $avg_tokens > 0 then ((.total_tokens // 0) / $avg_tokens) else 0 end), + turn_ratio: (if $avg_turns > 0 then ((.turn_count // 0) / $avg_turns ) else 0 end) + } + ]) as $outliers | + + { + log_path: $log_path, + summary: { + stage_count: $stage_count, + turn_event_count: $turn_event_count, + issue_count: $issue_count, + total_tokens: $total_tokens, + total_input_tokens: $total_input, + total_output_tokens: $total_output, + total_cache_read_tokens: $total_cache_read, + total_cache_write_tokens: $total_cache_write, + total_turns: $total_turns, + total_duration_ms: $total_duration, + completed: $completed, + failed: $failed, + avg_tokens_per_stage: $avg_tokens, + avg_turns_per_stage: $avg_turns, + cache_hit_pct: $cache_hit_pct + }, + per_issue: $per_issue, + per_stage: $per_stage, + per_turn: $per_turn, + outliers: $outliers + } + ' +} + +# Print a human-readable report from the JSON produced by _analyze_compute. +_analyze_print() { + local report="$1" + + local BAR="════════════════════════════════════════════════════════════" + local SEP="────────────────────────────────────────────────────────────" + + # --- Header --- + local log_path + log_path="$(echo "$report" | jq -r '.log_path')" + echo "" + echo "$BAR" + echo " SYMPHONY RUN ANALYSIS" + printf " Log: %s\n" "$log_path" + echo "$BAR" + echo "" + + # --- Run Summary --- + local stage_count issue_count completed failed + local total_tokens total_input total_output total_cache_read + local cache_hit_pct total_dur_ms avg_turns avg_tokens turn_event_count + stage_count=$( echo "$report" | jq -r '.summary.stage_count') + issue_count=$( echo "$report" | jq -r '.summary.issue_count') + completed=$( echo "$report" | jq -r '.summary.completed') + failed=$( echo "$report" | jq -r '.summary.failed') + total_tokens=$( echo "$report" | jq -r '.summary.total_tokens') + total_input=$( echo "$report" | jq -r '.summary.total_input_tokens') + total_output=$( echo "$report" | jq -r '.summary.total_output_tokens') + total_cache_read=$( echo "$report" | jq -r '.summary.total_cache_read_tokens') + cache_hit_pct=$( echo "$report" | jq -r '.summary.cache_hit_pct | . * 10 | round / 10') + total_dur_ms=$( echo "$report" | jq -r '.summary.total_duration_ms') + avg_turns=$( echo "$report" | jq -r '.summary.avg_turns_per_stage | . * 10 | round / 10') + avg_tokens=$( echo "$report" | jq -r '.summary.avg_tokens_per_stage | round') + turn_event_count=$( echo "$report" | jq -r '.summary.turn_event_count') + + echo "Run Summary" + echo "$SEP" + printf " Stages: %d (%d completed, %d failed)\n" "$stage_count" "$completed" "$failed" + printf " Issues: %d\n" "$issue_count" + printf " Turns logged: %d\n" "$turn_event_count" + printf " Total time: %s\n" "$(_fmt_duration "$total_dur_ms")" + printf " Tokens: %d total\n" "$total_tokens" + printf " Input: %d\n" "$total_input" + printf " Output: %d\n" "$total_output" + printf " Cache hit: %d (%.1f%% of input)\n" "$total_cache_read" "$cache_hit_pct" + printf " Avg/stage: %.1f turns, %d tokens\n" "$avg_turns" "$avg_tokens" + echo "" + + # --- Per-Issue Table --- + local issue_row_count + issue_row_count=$(echo "$report" | jq -r '.per_issue | length') + + if [[ "$issue_row_count" -gt 0 ]]; then + echo "Per-Issue Summary" + echo "$SEP" + printf " %-14s %6s %5s %10s %10s %s\n" \ + "ISSUE" "STAGES" "TURNS" "TOKENS" "DURATION" "STATUS" + while IFS=$'\t' read -r issue stages turns tokens dur status; do + printf " %-14s %6s %5s %10d %10s %s\n" \ + "$issue" "$stages" "$turns" "$tokens" "$(_fmt_duration "$dur")" "$status" + done < <(echo "$report" | jq -r ' + .per_issue[] | + [ + (.issue // "unknown"), + (.stages | tostring), + (.turns | tostring), + (.total_tokens | tostring), + (.duration_ms | tostring), + (if .failed > 0 then "FAILED(\(.failed))" else "ok" end) + ] | @tsv + ') + echo "" + fi + + # --- Per-Stage Averages --- + local stage_row_count + stage_row_count=$(echo "$report" | jq -r '.per_stage | length') + + if [[ "$stage_row_count" -gt 0 ]]; then + echo "Per-Stage Averages" + echo "$SEP" + printf " %-14s %5s %9s %10s %10s %s\n" \ + "STAGE" "COUNT" "AVG TURNS" "AVG TOKENS" "AVG TIME" "OK/FAIL" + while IFS=$'\t' read -r stage count avg_t avg_tok avg_dur ok_fail; do + printf " %-14s %5s %9s %10d %10s %s\n" \ + "$stage" "$count" "$avg_t" "$avg_tok" "$(_fmt_duration "$avg_dur")" "$ok_fail" + done < <(echo "$report" | jq -r ' + .per_stage[] | + [ + (.stage // "unknown"), + (.count | tostring), + (.avg_turns | . * 10 | round / 10 | tostring), + (.avg_tokens | round | tostring), + (.avg_duration_ms | round | tostring), + "\(.completed)/\(.failed)" + ] | @tsv + ') + echo "" + fi + + # --- Per-Turn Granularity --- + local turn_count turn_avg_tokens turn_avg_input turn_avg_cache + turn_count=$( echo "$report" | jq -r '.per_turn.count') + turn_avg_tokens=$( echo "$report" | jq -r '.per_turn.avg_tokens | round') + turn_avg_input=$( echo "$report" | jq -r '.per_turn.avg_input | round') + turn_avg_cache=$( echo "$report" | jq -r '.per_turn.avg_cache_read | round') + + if [[ "$turn_count" -gt 0 ]]; then + echo "Per-Turn Granularity" + echo "$SEP" + printf " Turns observed: %d\n" "$turn_count" + printf " Avg tokens/turn: %d total (%d input, %d cache read)\n" \ + "$turn_avg_tokens" "$turn_avg_input" "$turn_avg_cache" + echo "" + fi + + # --- Cache Efficiency --- + echo "Cache Efficiency" + echo "$SEP" + printf " Overall: %.1f%% of input served from cache (%d tokens)\n" \ + "$cache_hit_pct" "$total_cache_read" + if [[ "$issue_row_count" -gt 0 ]]; then + while IFS=$'\t' read -r issue cr pct; do + printf " %-14s: %.1f%% cache hit (%d tokens)\n" "$issue" "$pct" "$cr" + done < <(echo "$report" | jq -r ' + .per_issue[] | + (if (.input_tokens + .cache_read_tokens) > 0 + then (.cache_read_tokens * 100 / (.input_tokens + .cache_read_tokens)) + else 0 end) as $pct | + [ + (.issue // "unknown"), + (.cache_read_tokens | tostring), + ($pct | . * 10 | round / 10 | tostring) + ] | @tsv + ') + fi + echo "" + + # --- Outlier Flags --- + local outlier_count + outlier_count=$(echo "$report" | jq -r '.outliers | length') + + echo "Outlier Flags" + echo "$SEP" + if [[ "$outlier_count" -gt 0 ]]; then + while IFS=$'\t' read -r issue stage tokens turns token_ratio turn_ratio; do + printf " %s / %s: %d tokens (%.1fx avg), %d turns (%.1fx avg)\n" \ + "$issue" "$stage" "$tokens" "$token_ratio" "$turns" "$turn_ratio" + done < <(echo "$report" | jq -r ' + .outliers[] | + [ + (.issue // "unknown"), + (.stage // "unknown"), + (.total_tokens | tostring), + (.turn_count | tostring), + (.token_ratio | . * 10 | round / 10 | tostring), + (.turn_ratio | . * 10 | round / 10 | tostring) + ] | @tsv + ') + else + echo " (none)" + fi + echo "" +} + +cmd_analyze() { + local json_output=false + local log_path="" + + # Parse flags and positional argument + while [[ $# -gt 0 ]]; do + case "$1" in + --json) + json_output=true ;; + -*) + die "Unknown flag: $1" ;; + *) + [[ -z "$log_path" ]] || die "Unexpected argument: $1" + log_path="$1" + ;; + esac + shift + done + + # Default: symphony.jsonl under $LOG_DIR + if [[ -z "$log_path" ]]; then + log_path="$LOG_DIR/symphony.jsonl" + [[ -f "$log_path" ]] || die "No symphony.jsonl found at $log_path. Pass a path explicitly." + fi + + [[ -f "$log_path" ]] || die "Log file not found: $log_path" + command -v jq &>/dev/null || die "jq is required for the analyze command. Install with: brew install jq" + + # Slurp only the event types we care about (ignore all others) + local stage_json turn_json + stage_json="$(jq -cs '[.[] | select(.event == "stage_completed")]' "$log_path" 2>/dev/null || echo '[]')" + turn_json="$(jq -cs '[.[] | select(.event == "turn_completed")]' "$log_path" 2>/dev/null || echo '[]')" + + # Compute aggregates + local report + report="$(_analyze_compute "$stage_json" "$turn_json" "$log_path")" + + if $json_output; then + echo "$report" + else + _analyze_print "$report" + fi +} + +# --- Main --- + +usage() { + cat < + +Commands: + install Register the launchd service (does not start it) + uninstall Stop and remove the launchd service + start Start the service + stop Stop the service + restart Stop and start the service + status Show service status and recent logs + logs Open full log in pager (default: stderr, pass 'stdout' for stdout) + tail Tail both stdout and stderr logs + install-logrotate Install newsyslog config for log rotation (requires sudo) + cleanup Detect stale pipeline artifacts (dry-run by default) + --execute Actually remove/close artifacts + --skip-github Skip PR/branch cleanup + --skip-linear Skip Linear API queries + --prune-branches Also run branch pruning phase + prune-branches Prune merged local branches (dry-run by default) + --execute Delete merged branches (git branch -d) + --dry-run Print what would be deleted (default) + analyze Analyze a JSONL run log and print a report + [path] Path to symphony.jsonl + (default: \$LOG_DIR/symphony.jsonl) + --json Output machine-readable JSON instead of text + +Environment: + SYMPHONY_PROJECT Project name for label/logs (default: symphony) + SYMPHONY_ENV_FILE Path to .env file (default: /.env) + SYMPHONY_WORKFLOW Path to WORKFLOW.md (default: pipeline-config/workflows/WORKFLOW-\$SYMPHONY_PROJECT.md) + SYMPHONY_NODE Path to node binary (default: auto-detected) + +EOF +} + +case "${1:-}" in + install) cmd_install ;; + uninstall) cmd_uninstall ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) cmd_status ;; + logs) cmd_logs "${2:-stderr}" ;; + tail) cmd_tail ;; + install-logrotate) cmd_install_logrotate ;; + cleanup) shift; cmd_cleanup "$@" ;; + prune-branches) shift; cmd_prune_branches "$@" ;; + analyze) shift; cmd_analyze "$@" ;; + -h|--help) usage ;; + --version|-V) "$NODE_BIN" "$CLI_JS" --version ;; + *) usage; exit 1 ;; +esac diff --git a/ops/symphony-deploy b/ops/symphony-deploy new file mode 100755 index 00000000..d662d2d8 --- /dev/null +++ b/ops/symphony-deploy @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +set -euo pipefail + +# symphony-deploy — pull, build, and restart symphony-ts on the server +# Usage: symphony-deploy [--dry-run] [--no-restart] [--symphony] [--config] + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +SYMPHONY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Defaults — override via environment +CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/projects/claude-config}" +CTL="$SCRIPT_DIR/symphony-ctl" +export SOPS_AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/keys.txt}" + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' +fi + +info() { echo -e "${CYAN}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; } +die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } + +# --- Helpers --- + +usage() { + cat </dev/null || echo "(unknown)" + else + echo "(symphony-ctl not found)" + fi +} + +# --- Pull a git repo --- +# Arguments: repo_path repo_label +# Sets globals: _PRE_SHA _POST_SHA _LOCKFILE_CHANGED _ENV_ENC_CHANGED +pull_repo() { + local repo_path="$1" + local label="$2" + + info "Pulling $label ($repo_path)..." + _PRE_SHA="$(git -C "$repo_path" rev-parse HEAD)" + + if $DRY_RUN; then + info "[dry-run] git -C $repo_path pull --ff-only" + _POST_SHA="$_PRE_SHA" + _LOCKFILE_CHANGED=false + _ENV_ENC_CHANGED=false + return + fi + + git -C "$repo_path" pull --ff-only || die "git pull --ff-only failed in $repo_path. Branches may have diverged — resolve manually." + _POST_SHA="$(git -C "$repo_path" rev-parse HEAD)" + + if [[ "$_PRE_SHA" == "$_POST_SHA" ]]; then + ok "$label already up to date (${_PRE_SHA:0:8})" + _LOCKFILE_CHANGED=false + _ENV_ENC_CHANGED=false + return + fi + + ok "$label updated: ${_PRE_SHA:0:8} → ${_POST_SHA:0:8}" + + # Check what changed between old and new SHA + local changed_files + changed_files="$(git -C "$repo_path" diff --name-only "$_PRE_SHA" "$_POST_SHA")" + + _LOCKFILE_CHANGED=false + if echo "$changed_files" | grep -q '^pnpm-lock\.yaml$'; then + _LOCKFILE_CHANGED=true + fi + + _ENV_ENC_CHANGED=false + if echo "$changed_files" | grep -q '^\.env\.enc$'; then + _ENV_ENC_CHANGED=true + fi +} + +# --- Main --- + +if $DRY_RUN; then + info "symphony-deploy [DRY RUN]" +else + info "symphony-deploy" +fi +echo "" + +NEED_ENV_RESTART=false +SYMPHONY_UPDATED=false +SYMPH_PRE_SHA="" +SYMPH_POST_SHA="" + +# --- 1. Symphony-ts repo --- +if $DO_SYMPHONY; then + info "=== symphony-ts ===" + [[ -d "$SYMPHONY_ROOT/.git" ]] || die "Not a git repo: $SYMPHONY_ROOT" + + # Pre-deploy version + info "Pre-deploy version: $(get_version)" + + info "Ensuring symphony-ts is on main..." + run_or_dry git -C "$SYMPHONY_ROOT" checkout main + + pull_repo "$SYMPHONY_ROOT" "symphony-ts" + SYMPH_PRE_SHA="$_PRE_SHA" + SYMPH_POST_SHA="$_POST_SHA" + + if [[ "$SYMPH_PRE_SHA" != "$SYMPH_POST_SHA" ]]; then + SYMPHONY_UPDATED=true + + # pnpm install if lockfile changed + if $_LOCKFILE_CHANGED; then + info "pnpm-lock.yaml changed — installing dependencies..." + run_or_dry pnpm install --frozen-lockfile --dir "$SYMPHONY_ROOT" + ok "Dependencies installed" + else + ok "pnpm-lock.yaml unchanged — skipping install" + fi + + # Always rebuild if code changed + info "Building..." + run_or_dry pnpm run --dir "$SYMPHONY_ROOT" build + ok "Build complete" + fi + + # Decrypt .env.enc if it's newer than .env + local_env="$SYMPHONY_ROOT/.env" + local_enc="$SYMPHONY_ROOT/.env.enc" + if [[ -f "$local_enc" ]]; then + if [[ ! -f "$local_env" ]] || [[ "$local_enc" -nt "$local_env" ]]; then + info ".env.enc is newer than .env — decrypting..." + if $DRY_RUN; then + info "[dry-run] sops --decrypt --input-type dotenv --output-type dotenv $local_enc > $local_env" + else + sops --decrypt --input-type dotenv --output-type dotenv "$local_enc" > "$local_env" + fi + ok ".env decrypted" + NEED_ENV_RESTART=true + else + ok ".env is current — skipping decrypt" + fi + fi + + echo "" +fi + +# --- 2. Claude-config repo --- +if $DO_CONFIG; then + info "=== claude-config ===" + + if [[ ! -d "$CLAUDE_CONFIG_DIR" ]]; then + warn "claude-config dir not found at $CLAUDE_CONFIG_DIR — skipping" + elif [[ ! -d "$CLAUDE_CONFIG_DIR/.git" ]]; then + warn "$CLAUDE_CONFIG_DIR is not a git repo — skipping" + else + pull_repo "$CLAUDE_CONFIG_DIR" "claude-config" + CONFIG_PRE_SHA="$_PRE_SHA" + CONFIG_POST_SHA="$_POST_SHA" + + # Apply config changes (symlinks skills, CLAUDE.md, RTK.md, merges settings.json) + if [[ -x "$CLAUDE_CONFIG_DIR/deploy.sh" ]]; then + info "Applying claude-config..." + run_or_dry "$CLAUDE_CONFIG_DIR/deploy.sh" + ok "claude-config applied" + fi + fi + + echo "" +fi + +# --- 3. Service restart --- +if ! $NO_RESTART && $DO_SYMPHONY; then + info "=== Service ===" + + if ! service_installed; then + info "Service not installed — skipping restart" + elif $NEED_ENV_RESTART; then + # .env changed — must uninstall/install to rebake env vars into plist + info ".env was refreshed — reinstalling service (plist bakes env vars)..." + run_or_dry "$CTL" uninstall + run_or_dry "$CTL" install + run_or_dry "$CTL" start + ok "Service reinstalled and started" + elif $SYMPHONY_UPDATED; then + info "Code updated — restarting service..." + run_or_dry "$CTL" restart + ok "Service restarted" + else + ok "No changes — service left as-is" + fi + + echo "" +fi + +# --- 3b. Slack bridge restart --- +if ! $NO_RESTART && $DO_SYMPHONY; then + SLACK_CTL="$SCRIPT_DIR/slack-bridge-ctl" + SLACK_PLIST="$HOME/Library/LaunchAgents/com.slack-bridge.plist" + + if [[ ! -x "$SLACK_CTL" ]]; then + info "slack-bridge-ctl not found — skipping" + elif [[ ! -f "$SLACK_PLIST" ]]; then + info "Slack bridge not installed — skipping" + elif $NEED_ENV_RESTART; then + info ".env was refreshed — reinstalling slack bridge..." + run_or_dry "$SLACK_CTL" uninstall + run_or_dry "$SLACK_CTL" install + run_or_dry "$SLACK_CTL" start + ok "Slack bridge reinstalled and started" + elif $SYMPHONY_UPDATED; then + info "Code updated — restarting slack bridge..." + run_or_dry "$SLACK_CTL" restart + ok "Slack bridge restarted" + else + ok "No changes — slack bridge left as-is" + fi + + echo "" +fi + +# --- 4. Summary --- +info "=== Summary ===" + +if $DO_SYMPHONY; then + printf " symphony-ts: %s → %s\n" "${SYMPH_PRE_SHA:0:8}" "${SYMPH_POST_SHA:0:8}" + info "Post-deploy version: $(get_version)" +fi + +if $DO_CONFIG; then + if [[ -n "${CONFIG_PRE_SHA:-}" ]]; then + printf " claude-config: %s → %s\n" "${CONFIG_PRE_SHA:0:8}" "${CONFIG_POST_SHA:0:8}" + else + printf " claude-config: (skipped)\n" + fi +fi + +if $DRY_RUN; then + echo "" + info "Dry run complete — no changes were made." +fi diff --git a/ops/symphony-onboard b/ops/symphony-onboard new file mode 100755 index 00000000..9257091c --- /dev/null +++ b/ops/symphony-onboard @@ -0,0 +1,552 @@ +#!/usr/bin/env bash +set -euo pipefail + +# symphony-onboard — onboard a new project into the Symphony pipeline +# Usage: symphony-onboard --product --team-key --team-name --description [--repo ] [--dry-run] + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +SYMPHONY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +TEMPLATE_DIR="$SYMPHONY_ROOT/pipeline-config/templates" +WORKFLOW_DIR="$SYMPHONY_ROOT/pipeline-config/workflows" +PORTS_FILE="$SYMPHONY_ROOT/pipeline-config/ports.json" +RUN_PIPELINE="$SYMPHONY_ROOT/run-pipeline.sh" + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' +fi + +info() { echo -e "${CYAN}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; } +die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } + +# --- Temp directory cleanup --- + +TMPDIR_ONBOARD="" +cleanup() { + if [[ -n "$TMPDIR_ONBOARD" && -d "$TMPDIR_ONBOARD" ]]; then + rm -rf "$TMPDIR_ONBOARD" + fi +} +trap cleanup EXIT + +# --- Helpers --- + +usage() { + cat < Product name (e.g., "my-project") + --team-key Linear team key (e.g., "MYPROJ") + --team-name Linear team display name (e.g., "My Project Team") + --description Project description for Linear + +Optional flags: + --repo GitHub repository (default: mobilyze-llc/{product}) + --dry-run Show what each step would do without executing + -h, --help Show this help message + +Environment: + LINEAR_API_KEY Linear API token (required) + +Steps performed: + 1. Duplicate detection (check ports.json) + 2. Linear team creation (idempotent) + 3. Linear project creation + team linking (idempotent) + 4. Port auto-allocation (max+1 from ports.json) + 5. run-pipeline.sh auto-registration + 6. Generate WORKFLOW file from template + 7. Verify repo exists + 8. Generate CLAUDE.md in target repo + 9. Copy CI minimal workflow to target repo + 10. Set up GitHub merge queue via Rulesets API + 11. Summary + +EOF +} + +run_or_dry() { + if $DRY_RUN; then + info "[dry-run] $*" + else + "$@" + fi +} + +# --- Precondition checks --- + +check_gh_auth() { + gh auth status >/dev/null 2>&1 || die "GitHub CLI not authenticated. Run 'gh auth login' first." +} + +check_linear_key() { + [[ -n "${LINEAR_API_KEY:-}" ]] || die "LINEAR_API_KEY environment variable is not set." +} + +check_repo_exists() { + local repo="$1" + gh api "/repos/$repo" --silent >/dev/null 2>&1 || die "Repository '$repo' not found or not accessible." +} + +check_templates() { + [[ -f "$TEMPLATE_DIR/WORKFLOW-template.md" ]] || die "WORKFLOW template not found at $TEMPLATE_DIR/WORKFLOW-template.md" + [[ -f "$TEMPLATE_DIR/CLAUDE.md.tmpl" ]] || die "CLAUDE.md template not found at $TEMPLATE_DIR/CLAUDE.md.tmpl" + [[ -f "$TEMPLATE_DIR/ci-minimal.yml" ]] || die "CI minimal template not found at $TEMPLATE_DIR/ci-minimal.yml" +} + +check_jq() { + command -v jq &>/dev/null || die "jq is required but not found. Install it first." +} + +check_ports_file() { + [[ -f "$PORTS_FILE" ]] || die "ports.json not found at $PORTS_FILE" +} + +check_run_pipeline() { + [[ -f "$RUN_PIPELINE" ]] || die "run-pipeline.sh not found at $RUN_PIPELINE" +} + +check_linear_cli() { + command -v linear &>/dev/null || die "linear CLI is required but not found. Install it first." +} + +# --- Flags --- + +DRY_RUN=false +PRODUCT="" +TEAM_KEY="" +TEAM_NAME="" +DESCRIPTION="" +REPO="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --product) PRODUCT="${2:-}"; [[ -n "$PRODUCT" ]] || die "--product requires a value"; shift ;; + --team-key) TEAM_KEY="${2:-}"; [[ -n "$TEAM_KEY" ]] || die "--team-key requires a value"; shift ;; + --team-name) TEAM_NAME="${2:-}"; [[ -n "$TEAM_NAME" ]] || die "--team-name requires a value"; shift ;; + --description) DESCRIPTION="${2:-}"; [[ -n "$DESCRIPTION" ]] || die "--description requires a value"; shift ;; + --repo) REPO="${2:-}"; [[ -n "$REPO" ]] || die "--repo requires a value"; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Unknown flag: $1" ;; + esac + shift +done + +# Validate required flags +[[ -n "$PRODUCT" ]] || die "Missing required flag: --product" +[[ -n "$TEAM_KEY" ]] || die "Missing required flag: --team-key" +[[ -n "$TEAM_NAME" ]] || die "Missing required flag: --team-name" +[[ -n "$DESCRIPTION" ]] || die "Missing required flag: --description" + +# Default repo to mobilyze-llc/{product} when not provided +if [[ -z "$REPO" ]]; then + REPO="mobilyze-llc/$PRODUCT" +fi + +# These will be populated by steps 3 and 4 +PROJECT_SLUG="" +PORT="" + +# --- Main --- + +if $DRY_RUN; then + info "symphony-onboard [DRY RUN]" +else + info "symphony-onboard" +fi +info " product: $PRODUCT" +info " team-key: $TEAM_KEY" +info " team-name: $TEAM_NAME" +info " description: $DESCRIPTION" +info " repo: $REPO" +echo "" + +# Create temp directory for intermediate files +TMPDIR_ONBOARD="$(mktemp -d)" + +# --- Preconditions --- + +info "=== Precondition checks ===" +check_gh_auth +ok "GitHub CLI authenticated" + +check_linear_key +ok "LINEAR_API_KEY is set" + +check_jq +ok "jq is available" + +check_templates +ok "All templates found" + +check_ports_file +ok "ports.json found" + +check_run_pipeline +ok "run-pipeline.sh found" + +check_linear_cli +ok "linear CLI available" + +echo "" + +# --- Step 1: Duplicate detection --- + +info "=== Step 1: Duplicate detection ===" + +EXISTING_PORT="$(jq -r --arg p "$PRODUCT" '.[$p] // empty' "$PORTS_FILE")" +if [[ -n "$EXISTING_PORT" ]]; then + die "Product '$PRODUCT' already exists in ports.json (port: $EXISTING_PORT). Aborting to prevent duplicate onboarding." +fi +ok "Product '$PRODUCT' not found in ports.json — proceeding" + +echo "" + +# --- Step 2: Linear team creation (idempotent) --- + +info "=== Step 2: Linear team creation ===" + +if $DRY_RUN; then + info "[dry-run] Would check for existing Linear team with key '$TEAM_KEY'" + info "[dry-run] Would create team '$TEAM_NAME' (key: $TEAM_KEY) if not found" + TEAM_ID="dry-run-team-id" +else + # Check if team already exists + TEAM_QUERY='query { teams(filter: { key: { eq: "'"$TEAM_KEY"'" } }) { nodes { id name key } } }' + TEAM_RESULT="$(linear api "$TEAM_QUERY" 2>/dev/null || true)" + + if [[ -z "$TEAM_RESULT" ]]; then + die "Failed to query Linear API for teams. Check LINEAR_API_KEY and network connectivity." + fi + + TEAM_COUNT="$(echo "$TEAM_RESULT" | jq -r '.data.teams.nodes | length' 2>/dev/null || echo "0")" + + if [[ "$TEAM_COUNT" -gt 0 ]]; then + TEAM_ID="$(echo "$TEAM_RESULT" | jq -r '.data.teams.nodes[0].id')" + TEAM_EXISTING_NAME="$(echo "$TEAM_RESULT" | jq -r '.data.teams.nodes[0].name')" + ok "Linear team already exists: $TEAM_EXISTING_NAME (key: $TEAM_KEY, id: $TEAM_ID) — skipping" + else + # Create team + CREATE_TEAM_QUERY='mutation { teamCreate(input: { name: "'"$TEAM_NAME"'", key: "'"$TEAM_KEY"'", description: "'"$DESCRIPTION"'" }) { success team { id name key } } }' + CREATE_TEAM_RESULT="$(linear api "$CREATE_TEAM_QUERY" 2>/dev/null || true)" + + if [[ -z "$CREATE_TEAM_RESULT" ]]; then + die "Failed to create Linear team. Check LINEAR_API_KEY and network connectivity." + fi + + CREATE_SUCCESS="$(echo "$CREATE_TEAM_RESULT" | jq -r '.data.teamCreate.success' 2>/dev/null || echo "false")" + if [[ "$CREATE_SUCCESS" != "true" ]]; then + die "Linear team creation failed. Response: $CREATE_TEAM_RESULT" + fi + + TEAM_ID="$(echo "$CREATE_TEAM_RESULT" | jq -r '.data.teamCreate.team.id')" + ok "Linear team created: $TEAM_NAME (key: $TEAM_KEY, id: $TEAM_ID)" + fi +fi + +echo "" + +# --- Step 3: Linear project creation + team linking (idempotent) --- + +info "=== Step 3: Linear project creation + team linking ===" + +if $DRY_RUN; then + info "[dry-run] Would check for existing Linear project named '$PRODUCT'" + info "[dry-run] Would create project and link to team '$TEAM_KEY' if not found" + PROJECT_SLUG="dry-run-slug" +else + # Check if project already exists by name + PROJECT_QUERY='query { projects(filter: { name: { eq: "'"$PRODUCT"'" } }) { nodes { id name slugId teams { nodes { id key } } } } }' + PROJECT_RESULT="$(linear api "$PROJECT_QUERY" 2>/dev/null || true)" + + if [[ -z "$PROJECT_RESULT" ]]; then + die "Failed to query Linear API for projects. Check LINEAR_API_KEY and network connectivity." + fi + + PROJECT_COUNT="$(echo "$PROJECT_RESULT" | jq -r '.data.projects.nodes | length' 2>/dev/null || echo "0")" + + if [[ "$PROJECT_COUNT" -gt 0 ]]; then + PROJECT_SLUG="$(echo "$PROJECT_RESULT" | jq -r '.data.projects.nodes[0].slugId')" + PROJECT_ID="$(echo "$PROJECT_RESULT" | jq -r '.data.projects.nodes[0].id')" + ok "Linear project already exists: $PRODUCT (slug: $PROJECT_SLUG) — skipping creation" + + # Check if team is already linked + LINKED_TEAM_COUNT="$(echo "$PROJECT_RESULT" | jq -r --arg tk "$TEAM_KEY" '[.data.projects.nodes[0].teams.nodes[] | select(.key == $tk)] | length' 2>/dev/null || echo "0")" + if [[ "$LINKED_TEAM_COUNT" -gt 0 ]]; then + ok "Team '$TEAM_KEY' already linked to project — skipping" + else + warn "Team '$TEAM_KEY' not linked to project '$PRODUCT'. Link it manually via the Linear UI." + fi + else + # Create project with team link + CREATE_PROJECT_QUERY='mutation { projectCreate(input: { name: "'"$PRODUCT"'", description: "'"$DESCRIPTION"'", teamIds: ["'"$TEAM_ID"'"] }) { success project { id name slugId } } }' + CREATE_PROJECT_RESULT="$(linear api "$CREATE_PROJECT_QUERY" 2>/dev/null || true)" + + if [[ -z "$CREATE_PROJECT_RESULT" ]]; then + die "Failed to create Linear project. Check LINEAR_API_KEY and network connectivity." + fi + + CREATE_PROJECT_SUCCESS="$(echo "$CREATE_PROJECT_RESULT" | jq -r '.data.projectCreate.success' 2>/dev/null || echo "false")" + if [[ "$CREATE_PROJECT_SUCCESS" != "true" ]]; then + die "Linear project creation failed. Response: $CREATE_PROJECT_RESULT" + fi + + PROJECT_SLUG="$(echo "$CREATE_PROJECT_RESULT" | jq -r '.data.projectCreate.project.slugId')" + PROJECT_ID="$(echo "$CREATE_PROJECT_RESULT" | jq -r '.data.projectCreate.project.id')" + ok "Linear project created: $PRODUCT (slug: $PROJECT_SLUG, id: $PROJECT_ID)" + ok "Team '$TEAM_KEY' linked to project" + fi +fi + +echo "" + +# --- Step 4: Port auto-allocation --- + +info "=== Step 4: Port auto-allocation ===" + +if $DRY_RUN; then + MAX_PORT="$(jq '[to_entries[] | .value] | max' "$PORTS_FILE" 2>/dev/null || echo "4320")" + PORT=$((MAX_PORT + 1)) + info "[dry-run] Would allocate port $PORT (max existing: $MAX_PORT)" + info "[dry-run] Would write '$PRODUCT': $PORT to ports.json" +else + # Read max port and allocate next + MAX_PORT="$(jq '[to_entries[] | .value] | max' "$PORTS_FILE")" + PORT=$((MAX_PORT + 1)) + + # Write new entry to ports.json + jq --arg p "$PRODUCT" --argjson port "$PORT" '. + {($p): $port}' "$PORTS_FILE" > "$TMPDIR_ONBOARD/ports.json" + cp "$TMPDIR_ONBOARD/ports.json" "$PORTS_FILE" + ok "Port $PORT allocated for '$PRODUCT' (written to ports.json)" +fi + +echo "" + +# --- Step 5: run-pipeline.sh auto-registration --- + +info "=== Step 5: run-pipeline.sh auto-registration ===" + +# Derive workflow filename and default repo URL +WORKFLOW_NAME="$(echo "$PRODUCT" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')" +REPO_URL="https://github.com/$REPO.git" + +if $DRY_RUN; then + info "[dry-run] Would insert case entry for '$PRODUCT' in run-pipeline.sh before *) catch-all" + info "[dry-run] WORKFLOW=pipeline-config/workflows/WORKFLOW-${WORKFLOW_NAME}.md" + info "[dry-run] DEFAULT_REPO_URL=$REPO_URL" +else + # Check if product is already registered + if grep -q "^ ${PRODUCT})" "$RUN_PIPELINE" 2>/dev/null; then + ok "Product '$PRODUCT' already registered in run-pipeline.sh — skipping" + else + # Insert new case entry before the *) catch-all + CASE_BLOCK=" ${PRODUCT})\\ + WORKFLOW=\"pipeline-config/workflows/WORKFLOW-${WORKFLOW_NAME}.md\"\\ + DEFAULT_REPO_URL=\"${REPO_URL}\"\\ + ;;" + sed -i'' -e "/^ \*)$/i\\ +${CASE_BLOCK}" "$RUN_PIPELINE" + ok "Product '$PRODUCT' registered in run-pipeline.sh" + fi +fi + +echo "" + +# --- Step 6: Generate WORKFLOW file --- + +info "=== Step 6: Generate WORKFLOW file ===" + +WORKFLOW_FILE="$WORKFLOW_DIR/WORKFLOW-${WORKFLOW_NAME}.md" + +if [[ -f "$WORKFLOW_FILE" ]]; then + ok "WORKFLOW file already exists at $WORKFLOW_FILE — skipping" +else + info "Generating $WORKFLOW_FILE..." + if $DRY_RUN; then + info "[dry-run] Would copy WORKFLOW-template.md → WORKFLOW-${WORKFLOW_NAME}.md" + info "[dry-run] Would substitute project_slug: $PROJECT_SLUG" + info "[dry-run] Would substitute port: $PORT" + else + cp "$TEMPLATE_DIR/WORKFLOW-template.md" "$WORKFLOW_FILE" + # Substitute project_slug (only the placeholder value, not the key) + sed -i'' -e "s|project_slug: |project_slug: $PROJECT_SLUG|" "$WORKFLOW_FILE" + # Substitute port (only the default value under server:) + sed -i'' -e "s|port: 4321|port: $PORT|" "$WORKFLOW_FILE" + ok "WORKFLOW file created at $WORKFLOW_FILE" + fi +fi + +echo "" + +# --- Step 7: Verify repo exists --- + +info "=== Step 7: Verify repo exists ===" + +if ! $DRY_RUN; then + check_repo_exists "$REPO" + ok "Repository '$REPO' is accessible" +else + info "[dry-run] Would verify repository '$REPO' exists" +fi + +echo "" + +# --- Step 8: Generate CLAUDE.md in target repo --- + +info "=== Step 8: Generate CLAUDE.md in target repo ===" + +if $DRY_RUN; then + info "[dry-run] Would check if CLAUDE.md exists in $REPO" + info "[dry-run] Would generate CLAUDE.md from template with substitutions:" + info "[dry-run] {{PROJECT_NAME}} → $PRODUCT" + info "[dry-run] {{PORT}} → $PORT" + info "[dry-run] {{REPO_URL}} → https://github.com/$REPO" + info "[dry-run] Would upload CLAUDE.md to $REPO via GitHub API" +else + # Check if CLAUDE.md already exists in the repo + EXISTING_CLAUDE="$(gh api "/repos/$REPO/contents/CLAUDE.md" --jq '.sha' 2>/dev/null || echo "")" + + if [[ -n "$EXISTING_CLAUDE" ]]; then + ok "CLAUDE.md already exists in $REPO — skipping" + else + # Generate CLAUDE.md from template + CLAUDE_MD_TMP="$TMPDIR_ONBOARD/CLAUDE.md" + sed \ + -e "s|{{PROJECT_NAME}}|$PRODUCT|g" \ + -e "s|{{PORT}}|$PORT|g" \ + -e "s|{{REPO_URL}}|https://github.com/$REPO|g" \ + "$TEMPLATE_DIR/CLAUDE.md.tmpl" > "$CLAUDE_MD_TMP" + + # Upload via GitHub API (create file) + CONTENT_B64="$(base64 < "$CLAUDE_MD_TMP")" + gh api "/repos/$REPO/contents/CLAUDE.md" \ + --method PUT \ + --field message="Add CLAUDE.md for Symphony pipeline" \ + --field content="$CONTENT_B64" \ + --silent >/dev/null 2>&1 + ok "CLAUDE.md uploaded to $REPO" + fi +fi + +echo "" + +# --- Step 9: Copy CI minimal workflow to target repo --- + +info "=== Step 9: Copy CI minimal workflow to target repo ===" + +if $DRY_RUN; then + info "[dry-run] Would check if .github/workflows/ci.yml exists in $REPO" + info "[dry-run] Would upload ci-minimal.yml as .github/workflows/ci.yml to $REPO" +else + EXISTING_CI="$(gh api "/repos/$REPO/contents/.github/workflows/ci.yml" --jq '.sha' 2>/dev/null || echo "")" + + if [[ -n "$EXISTING_CI" ]]; then + ok "CI workflow already exists in $REPO — skipping" + else + CI_CONTENT_B64="$(base64 < "$TEMPLATE_DIR/ci-minimal.yml")" + gh api "/repos/$REPO/contents/.github/workflows/ci.yml" \ + --method PUT \ + --field message="Add minimal CI workflow for Symphony pipeline" \ + --field content="$CI_CONTENT_B64" \ + --silent >/dev/null 2>&1 + ok "CI workflow uploaded to $REPO" + fi +fi + +echo "" + +# --- Step 10: Set up GitHub merge queue via Rulesets API --- + +info "=== Step 10: Set up GitHub merge queue via Rulesets API ===" + +if $DRY_RUN; then + info "[dry-run] Would check existing rulesets for $REPO" + info "[dry-run] Would create merge queue ruleset if not present" +else + # Check if a merge queue ruleset already exists + EXISTING_RULESETS="$(gh api "/repos/$REPO/rulesets" 2>/dev/null || echo "[]")" + HAS_MERGE_QUEUE="$(echo "$EXISTING_RULESETS" | jq '[.[] | select(.name == "symphony-merge-queue")] | length' 2>/dev/null || echo "0")" + + if [[ "$HAS_MERGE_QUEUE" -gt 0 ]]; then + ok "Merge queue ruleset already exists in $REPO — skipping" + else + # Create ruleset JSON in temp file + RULESET_JSON="$TMPDIR_ONBOARD/ruleset.json" + cat > "$RULESET_JSON" <<'RULESET_EOF' +{ + "name": "symphony-merge-queue", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/main"], + "exclude": [] + } + }, + "rules": [ + { + "type": "merge_queue", + "parameters": { + "check_response_timeout_minutes": 60, + "grouping_strategy": "ALLGREEN", + "max_entries_to_build": 5, + "max_entries_to_merge": 5, + "merge_method": "SQUASH", + "min_entries_to_merge": 1, + "min_entries_to_merge_wait_minutes": 5 + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "test" + } + ], + "strict_required_status_checks_policy": false + } + } + ], + "bypass_actors": [] +} +RULESET_EOF + + gh api "/repos/$REPO/rulesets" \ + --method POST \ + --input "$RULESET_JSON" \ + --silent >/dev/null 2>&1 + ok "Merge queue ruleset created in $REPO" + fi +fi + +echo "" + +# --- Step 11: Summary --- + +info "=== Step 11: Summary ===" +info " product: $PRODUCT" +info " team: $TEAM_NAME (key: $TEAM_KEY)" +info " project-slug: $PROJECT_SLUG" +info " port: $PORT" +info " repo: $REPO" +info " WORKFLOW: $WORKFLOW_FILE" +info " CLAUDE.md: $REPO/CLAUDE.md" +info " CI workflow: $REPO/.github/workflows/ci.yml" +info " Merge queue: $REPO (symphony-merge-queue ruleset)" +info " run-pipeline: $RUN_PIPELINE (case entry for $PRODUCT)" + +if $DRY_RUN; then + echo "" + info "Dry run complete — no changes were made." +fi + +ok "Onboarding complete for $PRODUCT" diff --git a/ops/token-report-ui/.gitignore b/ops/token-report-ui/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/ops/token-report-ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ops/token-report-ui/index.html b/ops/token-report-ui/index.html new file mode 100644 index 00000000..bc07436a --- /dev/null +++ b/ops/token-report-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Token Report + + +
+ + + diff --git a/ops/token-report-ui/package.json b/ops/token-report-ui/package.json new file mode 100644 index 00000000..a08eba31 --- /dev/null +++ b/ops/token-report-ui/package.json @@ -0,0 +1,33 @@ +{ + "name": "token-report-ui", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vp dev", + "build": "tsc && vp build", + "test": "vp test", + "check": "vp check", + "preview": "vp preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~5.9.3", + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite-plugin-singlefile": "^2.3.2", + "vite-plus": "latest" + }, + "packageManager": "pnpm@10.30.2", + "pnpm": { + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + } + } +} diff --git a/ops/token-report-ui/pnpm-lock.yaml b/ops/token-report-ui/pnpm-lock.yaml new file mode 100644 index 00000000..3967cd22 --- /dev/null +++ b/ops/token-report-ui/pnpm-lock.yaml @@ -0,0 +1,1955 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + +importers: + + .: + dependencies: + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@latest + version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3)' + vite-plugin-singlefile: + specifier: ^2.3.2 + version: 2.3.2(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))(rollup@4.60.0) + vite-plus: + specifier: latest + version: 0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))(happy-dom@20.8.8)(jsdom@29.0.1)(typescript@5.9.3) + +packages: + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@oxc-project/runtime@0.121.0': + resolution: {integrity: sha512-p0bQukD8OEHxzY4T9OlANBbEFGnOnjo1CYi50HES7OD36UO2yPh6T+uOJKLtlg06eclxroipRCpQGMpeH8EJ/g==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@oxfmt/binding-android-arm-eabi@0.42.0': + resolution: {integrity: sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.42.0': + resolution: {integrity: sha512-t+aAjHxcr5eOBphFHdg1ouQU9qmZZoRxnX7UOJSaTwSoKsb6TYezNKO0YbWytGXCECObRqNcUxPoPr0KaraAIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.42.0': + resolution: {integrity: sha512-ulpSEYMKg61C5bRMZinFHrKJYRoKGVbvMEXA5zM1puX3O9T6Q4XXDbft20yrDijpYWeuG59z3Nabt+npeTsM1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.42.0': + resolution: {integrity: sha512-ttxLKhQYPdFiM8I/Ri37cvqChE4Xa562nNOsZFcv1CKTVLeEozXjKuYClNvxkXmNlcF55nzM80P+CQkdFBu+uQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.42.0': + resolution: {integrity: sha512-Og7QS3yI3tdIKYZ58SXik0rADxIk2jmd+/YvuHRyKULWpG4V2fR5V4hvKm624Mc0cQET35waPXiCQWvjQEjwYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.42.0': + resolution: {integrity: sha512-jwLOw/3CW4H6Vxcry4/buQHk7zm9Ne2YsidzTL1kpiMe4qqrRCwev3dkyWe2YkFmP+iZCQ7zku4KwjcLRoh8ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.42.0': + resolution: {integrity: sha512-XwXu2vkMtiq2h7tfvN+WA/9/5/1IoGAVCFPiiQUvcAuG3efR97KNcRGM8BetmbYouFotQ2bDal3yyjUx6IPsTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.42.0': + resolution: {integrity: sha512-ea7s/XUJoT7ENAtUQDudFe3nkSM3e3Qpz4nJFRdzO2wbgXEcjnchKLEsV3+t4ev3r8nWxIYr9NRjPWtnyIFJVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.42.0': + resolution: {integrity: sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.42.0': + resolution: {integrity: sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.42.0': + resolution: {integrity: sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.42.0': + resolution: {integrity: sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.42.0': + resolution: {integrity: sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.42.0': + resolution: {integrity: sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.42.0': + resolution: {integrity: sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.42.0': + resolution: {integrity: sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.42.0': + resolution: {integrity: sha512-mn//WV60A+IetORDxYieYGAoQso4KnVRRjORDewMcod4irlRe0OSC7YPhhwaexYNPQz/GCFk+v9iUcZ2W22yxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.42.0': + resolution: {integrity: sha512-3gWltUrvuz4LPJXWivoAxZ28Of2O4N7OGuM5/X3ubPXCEV8hmgECLZzjz7UYvSDUS3grfdccQwmjynm+51EFpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.42.0': + resolution: {integrity: sha512-Wg4TMAfQRL9J9AZevJ/ZNy3uyyDztDYQtGr4P8UyyzIhLhFrdSmz1J/9JT+rv0fiCDLaFOBQnj3f3K3+a5PzDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@0.17.3': + resolution: {integrity: sha512-5aDl4mxXWs+Bj02pNrX6YY6v9KMZjLIytXoqolLEo0dfBNVeZUonZgJAa/w0aUmijwIRrBhxEzb42oLuUtfkGw==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.17.3': + resolution: {integrity: sha512-gPBy4DS5ueCgXzko20XsNZzDe/Cxde056B+QuPLGvz05CGEAtmRfpImwnyY2lAXXjPL+SmnC/OYexu8zI12yHQ==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.17.3': + resolution: {integrity: sha512-+pkunvCfB6pB0G9qHVVXUao3nqzXQPo4O3DReIi+5nGa+bOU3J3Srgy+Zb8VyOL+WDsSMJ+U7+r09cKHWhz3hg==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.17.3': + resolution: {integrity: sha512-/kW5oXtBThu4FjmgIBthdmMjWLzT3M1TEDQhxDu7hQU5xDeTd60CDXb2SSwKCbue9xu7MbiFoJu83LN0Z/d38g==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.17.3': + resolution: {integrity: sha512-NMELRvbz4Ed4dxg8WiqZxtu3k4OJEp2B9KInZW+BMfqEqbwZdEJY83tbqz2hD1EjKO2akrqBQ0GpRUJEkd8kKw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.17.3': + resolution: {integrity: sha512-+pJ7r8J3SLPws5uoidVplZc8R/lpKyKPE6LoPGv9BME00Y1VjT6jWGx/dtUN8PWvcu3iTC6k+8u3ojFSJNmWTg==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.57.0': + resolution: {integrity: sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.57.0': + resolution: {integrity: sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.57.0': + resolution: {integrity: sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.57.0': + resolution: {integrity: sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.57.0': + resolution: {integrity: sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.57.0': + resolution: {integrity: sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.57.0': + resolution: {integrity: sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.57.0': + resolution: {integrity: sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.57.0': + resolution: {integrity: sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.57.0': + resolution: {integrity: sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.57.0': + resolution: {integrity: sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.57.0': + resolution: {integrity: sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.57.0': + resolution: {integrity: sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.57.0': + resolution: {integrity: sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.57.0': + resolution: {integrity: sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.57.0': + resolution: {integrity: sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.57.0': + resolution: {integrity: sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.57.0': + resolution: {integrity: sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.57.0': + resolution: {integrity: sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@voidzero-dev/vite-plus-core@0.1.14': + resolution: {integrity: sha512-CCWzdkfW0fo0cQNlIsYp5fOuH2IwKuPZEb2UY2Z8gXcp5pG74A82H2Pthj0heAuvYTAnfT7kEC6zM+RbiBgQbg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.4 + '@tsdown/exe': 0.21.4 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.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 + typescript: ^5.0.0 + unplugin-unused: ^0.5.0 + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@0.1.14': + resolution: {integrity: sha512-q2ESUSbapwsxVRe/KevKATahNRraoX5nti3HT9S3266OHT5sMroBY14jaxTv74ekjQc9E6EPhyLGQWuWQuuBRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@0.1.14': + resolution: {integrity: sha512-UpcDZc9G99E/4HDRoobvYHxMvFOG5uv3RwEcq0HF70u4DsnEMl1z8RaJLeWV7a09LGwj9Q+YWC3Z4INWnTLs8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.14': + resolution: {integrity: sha512-GIjn35RABUEDB9gHD26nRq7T72Te+Qy2+NIzogwEaUE728PvPkatF5gMCeF4sigCoc8c4qxDwsG+A2A2LYGnDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.14': + resolution: {integrity: sha512-qo2RToGirG0XCcxZ2AEOuonLM256z6dNbJzDDIo5gWYA+cIKigFQJbkPyr25zsT1tsP2aY0OTxt2038XbVlRkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.14': + resolution: {integrity: sha512-BsMWKZfdfGcYLxxLyaePpg6NW54xqzzcfq8sFUwKfwby0kgOKQ4WymUXyBvO9nnBb0ZPsJQrV0sx+Onac/LTaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.14': + resolution: {integrity: sha512-mOrEpj7ntW9RopGbcOYG/L0pOs0qHzUG4Vz7NXbuf4dbOSlY4JjyoMOIWxjKQORQht02Hzuf8YrMGNwa6AjVSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-test@0.1.14': + resolution: {integrity: sha512-rjF+qpYD+5+THOJZ3gbE3+cxsk5sW7nJ0ODK7y6ZKeS4amREUMedEDYykzKBwR7OZDC/WwE90A0iLWCr6qAXhA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/ui': 4.1.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.14': + resolution: {integrity: sha512-7iC+Ig+8D/zACy0IJf7w/vQ7duTjux9Ttmm3KOBdVWH4dl3JihydA7+SQVMhz71a4WiqJ6nPidoG8D6hUP4MVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.14': + resolution: {integrity: sha512-yRJ/8yAYFluNHx0Ej6Kevx65MIeM3wFKklnxosVZRlz2ZRL1Ea1Qh3tWATr3Ipk1ciRxBv8KJgp6zXqjxtZSoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + happy-dom@20.8.8: + resolution: {integrity: sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==} + engines: {node: '>=20.0.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + oxfmt@0.42.0: + resolution: {integrity: sha512-QhejGErLSMReNuZ6vxgFHDyGoPbjTRNi6uGHjy0cvIjOQFqD6xmr/T+3L41ixR3NIgzcNiJ6ylQKpvShTgDfqg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint-tsgolint@0.17.3: + resolution: {integrity: sha512-1eh4bcpOMw0e7+YYVxmhFc2mo/V6hJ2+zfukqf+GprvVn3y94b69M/xNrYLmx5A+VdYe0i/bJ2xOs6Hp/jRmRA==} + hasBin: true + + oxlint@1.57.0: + resolution: {integrity: sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.15.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + vite-plugin-singlefile@2.3.2: + resolution: {integrity: sha512-b8SxCi/gG7K298oJDcKOuZeU6gf6wIcCJAaEqUmmZXdjfuONlkyNyWZC3tEbN6QockRCNUd3it9eGTtpHGoYmg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.11 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plus@0.1.14: + resolution: {integrity: sha512-p4pWlpZZNiEsHxPWNdeIU9iuPix3ydm3ficb0dXPggoyIkdotfXtvn2NPX9KwfiQImU72EVEs4+VYBZYNcUYrw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + 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 + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + optional: true + + '@asamuzakjp/dom-selector@7.0.4': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + optional: true + + '@asamuzakjp/nwsapi@2.3.9': + optional: true + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + optional: true + + '@csstools/color-helpers@6.0.2': + optional: true + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + optional: true + + '@csstools/css-tokenizer@4.0.0': + optional: true + + '@exodus/bytes@1.15.0': + optional: true + + '@oxc-project/runtime@0.121.0': {} + + '@oxc-project/types@0.122.0': {} + + '@oxfmt/binding-android-arm-eabi@0.42.0': + optional: true + + '@oxfmt/binding-android-arm64@0.42.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.42.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.42.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.42.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.42.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.42.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.42.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.42.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.42.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.42.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.42.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.42.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.42.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.42.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.42.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.42.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.42.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.42.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@0.17.3': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.17.3': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.17.3': + optional: true + + '@oxlint-tsgolint/linux-x64@0.17.3': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.17.3': + optional: true + + '@oxlint-tsgolint/win32-x64@0.17.3': + optional: true + + '@oxlint/binding-android-arm-eabi@1.57.0': + optional: true + + '@oxlint/binding-android-arm64@1.57.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.57.0': + optional: true + + '@oxlint/binding-darwin-x64@1.57.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.57.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.57.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.57.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.57.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.57.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.57.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.57.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.57.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.57.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.57.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.57.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.57.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.57.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.57.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.57.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/whatwg-mimetype@3.0.2': + optional: true + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.0 + optional: true + + '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3)' + + '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3)': + dependencies: + '@oxc-project/runtime': 0.121.0 + '@oxc-project/types': 0.122.0 + lightningcss: 1.32.0 + postcss: 8.5.8 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + typescript: 5.9.3 + + '@voidzero-dev/vite-plus-darwin-arm64@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))(happy-dom@20.8.8)(jsdom@29.0.1)(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.14(@types/node@25.5.0)(typescript@5.9.3) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + vite: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3)' + ws: 8.20.0 + optionalDependencies: + '@types/node': 25.5.0 + happy-dom: 20.8.8 + jsdom: 29.0.1 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.14': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.14': + optional: true + + assertion-error@2.0.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + optional: true + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cac@7.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + optional: true + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + + decimal.js@10.6.0: + optional: true + + detect-libc@2.1.2: {} + + entities@6.0.1: + optional: true + + entities@7.0.1: + optional: true + + es-module-lexer@1.7.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + happy-dom@20.8.8: + dependencies: + '@types/node': 25.5.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: + optional: true + + isexe@2.0.0: {} + + jsdom@29.0.1: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.4 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.5 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@11.2.7: + optional: true + + mdn-data@2.27.1: + optional: true + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mrmime@2.0.1: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + oxfmt@0.42.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.42.0 + '@oxfmt/binding-android-arm64': 0.42.0 + '@oxfmt/binding-darwin-arm64': 0.42.0 + '@oxfmt/binding-darwin-x64': 0.42.0 + '@oxfmt/binding-freebsd-x64': 0.42.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.42.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.42.0 + '@oxfmt/binding-linux-arm64-gnu': 0.42.0 + '@oxfmt/binding-linux-arm64-musl': 0.42.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.42.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.42.0 + '@oxfmt/binding-linux-riscv64-musl': 0.42.0 + '@oxfmt/binding-linux-s390x-gnu': 0.42.0 + '@oxfmt/binding-linux-x64-gnu': 0.42.0 + '@oxfmt/binding-linux-x64-musl': 0.42.0 + '@oxfmt/binding-openharmony-arm64': 0.42.0 + '@oxfmt/binding-win32-arm64-msvc': 0.42.0 + '@oxfmt/binding-win32-ia32-msvc': 0.42.0 + '@oxfmt/binding-win32-x64-msvc': 0.42.0 + + oxlint-tsgolint@0.17.3: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.17.3 + '@oxlint-tsgolint/darwin-x64': 0.17.3 + '@oxlint-tsgolint/linux-arm64': 0.17.3 + '@oxlint-tsgolint/linux-x64': 0.17.3 + '@oxlint-tsgolint/win32-arm64': 0.17.3 + '@oxlint-tsgolint/win32-x64': 0.17.3 + + oxlint@1.57.0(oxlint-tsgolint@0.17.3): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.57.0 + '@oxlint/binding-android-arm64': 1.57.0 + '@oxlint/binding-darwin-arm64': 1.57.0 + '@oxlint/binding-darwin-x64': 1.57.0 + '@oxlint/binding-freebsd-x64': 1.57.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.57.0 + '@oxlint/binding-linux-arm-musleabihf': 1.57.0 + '@oxlint/binding-linux-arm64-gnu': 1.57.0 + '@oxlint/binding-linux-arm64-musl': 1.57.0 + '@oxlint/binding-linux-ppc64-gnu': 1.57.0 + '@oxlint/binding-linux-riscv64-gnu': 1.57.0 + '@oxlint/binding-linux-riscv64-musl': 1.57.0 + '@oxlint/binding-linux-s390x-gnu': 1.57.0 + '@oxlint/binding-linux-x64-gnu': 1.57.0 + '@oxlint/binding-linux-x64-musl': 1.57.0 + '@oxlint/binding-openharmony-arm64': 1.57.0 + '@oxlint/binding-win32-arm64-msvc': 1.57.0 + '@oxlint/binding-win32-ia32-msvc': 1.57.0 + '@oxlint/binding-win32-x64-msvc': 1.57.0 + oxlint-tsgolint: 0.17.3 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + optional: true + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + + pngjs@7.0.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + punycode@2.3.1: + optional: true + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react@19.2.4: {} + + require-from-string@2.0.2: + optional: true + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + optional: true + + scheduler@0.27.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + std-env@4.0.0: {} + + symbol-tree@3.2.4: + optional: true + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + tldts-core@7.0.27: + optional: true + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + optional: true + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + optional: true + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + optional: true + + typescript@5.9.3: {} + + undici-types@7.18.2: + optional: true + + undici@7.24.5: + optional: true + + vite-plugin-singlefile@2.3.2(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))(rollup@4.60.0): + dependencies: + micromatch: 4.0.8 + rollup: 4.60.0 + vite: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3)' + + vite-plus@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))(happy-dom@20.8.8)(jsdom@29.0.1)(typescript@5.9.3): + dependencies: + '@oxc-project/types': 0.122.0 + '@voidzero-dev/vite-plus-core': 0.1.14(@types/node@25.5.0)(typescript@5.9.3) + '@voidzero-dev/vite-plus-test': 0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(typescript@5.9.3))(happy-dom@20.8.8)(jsdom@29.0.1)(typescript@5.9.3) + cac: 7.0.0 + cross-spawn: 7.0.6 + oxfmt: 0.42.0 + oxlint: 1.57.0(oxlint-tsgolint@0.17.3) + oxlint-tsgolint: 0.17.3 + picocolors: 1.1.1 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.14 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.14 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.14 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.14 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.14 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.14 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.14 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.14 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - utf-8-validate + - vite + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + optional: true + + webidl-conversions@8.0.1: + optional: true + + whatwg-mimetype@3.0.0: + optional: true + + whatwg-mimetype@5.0.0: + optional: true + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + ws@8.20.0: {} + + xml-name-validator@5.0.0: + optional: true + + xmlchars@2.2.0: + optional: true diff --git a/ops/token-report-ui/src/App.test.tsx b/ops/token-report-ui/src/App.test.tsx new file mode 100644 index 00000000..7953fd3d --- /dev/null +++ b/ops/token-report-ui/src/App.test.tsx @@ -0,0 +1,437 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import App from "./App.tsx"; +import { + EfficiencyScorecard, + ExecutiveSummary, + IssueLeaderboard, + OutlierAnalysis, + PerProductBreakdown, + PerStageTrend, + PerTicketCostTrend, + ReportFooter, + ReportHeader, + StageEfficiency, + StageUtilizationChart, + TicketCostChart, +} from "./components/index.ts"; +import analysisData from "./data/analysis.json"; +import { + buildAreaString, + buildPointsString, + computeYGrid, + extractSortedDates, + formatAxisValue, + formatDateLabel, + linearScale, + pickTickIndices, + round, +} from "./lib/chart-utils.ts"; +import type { AnalysisData } from "./types.ts"; + +const data = analysisData as AnalysisData; + +describe("App", () => { + it("renders without crashing", () => { + const html = renderToString(); + expect(html).toContain("Symphony Token Report"); + }); + + it("renders all 10 section headings", () => { + const html = renderToString(); + expect(html).toContain("Executive Summary"); + expect(html).toContain("Efficiency Scorecard"); + expect(html).toContain("Per-Stage Utilization Trend"); + expect(html).toContain("Per-Ticket Cost Trend"); + expect(html).toContain("Outlier Analysis"); + expect(html).toContain("Issue Leaderboard"); + expect(html).toContain("Stage Efficiency"); + expect(html).toContain("Per-Product Breakdown"); + expect(html).toContain("SYMPH-131"); + }); +}); + +describe("analysis.json shape", () => { + it("has required top-level keys", () => { + expect(analysisData).toHaveProperty("cold_start_tier"); + expect(analysisData).toHaveProperty("analyzed_at"); + expect(analysisData).toHaveProperty("data_span_days"); + expect(analysisData).toHaveProperty("record_count"); + expect(analysisData).toHaveProperty("efficiency_scorecard"); + expect(analysisData).toHaveProperty("executive_summary"); + expect(analysisData).toHaveProperty("per_stage_spend"); + expect(analysisData).toHaveProperty("per_stage_trend"); + expect(analysisData).toHaveProperty("per_ticket_trend"); + expect(analysisData).toHaveProperty("per_product"); + expect(analysisData).toHaveProperty("inflections"); + expect(analysisData).toHaveProperty("outliers"); + }); + + it("has correct efficiency_scorecard metrics", () => { + const sc = analysisData.efficiency_scorecard; + for (const key of [ + "cache_efficiency", + "output_ratio", + "wasted_context", + "tokens_per_turn", + "first_pass_rate", + ] as const) { + expect(sc[key]).toHaveProperty("current"); + expect(sc[key]).toHaveProperty("trend_7d"); + expect(sc[key]).toHaveProperty("trend_30d"); + } + }); + + it("has correct executive_summary shape", () => { + const es = analysisData.executive_summary; + expect(es.total_tokens).toHaveProperty("value"); + expect(es.total_stages).toHaveProperty("value"); + expect(es.unique_issues).toHaveProperty("value"); + expect(es).toHaveProperty("data_span_days"); + }); +}); + +describe("ReportHeader", () => { + it("renders header with metadata", () => { + const html = renderToString( + , + ); + expect(html).toContain("Symphony Token Report"); + expect(html).toContain("2026-03-20"); + expect(html).toContain("312"); + expect(html).toContain("45"); + expect(html).toContain("day span"); + }); +}); + +describe("ExecutiveSummary", () => { + it("renders KPI cards with data", () => { + const html = renderToString( + , + ); + expect(html).toContain("Executive Summary"); + expect(html).toContain("Total Tokens"); + expect(html).toContain("18,420,000"); + expect(html).toContain("Issues Processed"); + expect(html).toContain("Cache Hit Rate"); + expect(html).toContain("72"); + expect(html).toContain("%"); + }); +}); + +describe("EfficiencyScorecard", () => { + it("renders all 6 metric rows", () => { + const html = renderToString( + , + ); + expect(html).toContain("Efficiency Scorecard"); + expect(html).toContain("Cache Efficiency"); + expect(html).toContain("Output Ratio"); + expect(html).toContain("Wasted Context"); + expect(html).toContain("Tokens / Turn"); + expect(html).toContain("First-Pass Rate"); + expect(html).toContain("Failure Rate (all stages)"); + }); +}); + +describe("PerStageTrend", () => { + it("renders trend section with inflections", () => { + const inflections = Array.isArray(data.inflections) ? data.inflections : []; + const html = renderToString( + , + ); + expect(html).toContain("Per-Stage Utilization Trend"); + expect(html).toContain("Inflection"); + }); +}); + +describe("PerTicketCostTrend", () => { + it("renders ticket cost stats", () => { + const html = renderToString( + , + ); + expect(html).toContain("Per-Ticket Cost Trend"); + expect(html).toContain("52,000"); + expect(html).toContain("59,000"); + expect(html).toContain("47"); + expect(html).toContain("tickets"); + }); +}); + +describe("OutlierAnalysis", () => { + it("renders outlier cards", () => { + const outliers = Array.isArray(data.outliers) ? data.outliers : []; + const html = renderToString(); + expect(html).toContain("Outlier Analysis"); + expect(html).toContain("SYMPH-98"); + expect(html).toContain("JONY-42"); + expect(html).toContain("3.2"); + }); + + it("renders empty state", () => { + const html = renderToString(); + expect(html).toContain("No outliers detected"); + }); +}); + +describe("IssueLeaderboard", () => { + it("renders table with empty data", () => { + const html = renderToString(); + expect(html).toContain("Issue Leaderboard"); + expect(html).toContain(" { + const items = [ + { identifier: "SYMPH-100", title: "Test issue", tokens: 100000 }, + ]; + const html = renderToString(); + expect(html).toContain("SYMPH-100"); + expect(html).toContain("Test issue"); + expect(html).toContain("100,000"); + }); +}); + +describe("StageEfficiency", () => { + it("renders stage cards", () => { + const html = renderToString( + , + ); + expect(html).toContain("Stage Efficiency"); + expect(html).toContain("investigate"); + expect(html).toContain("implement"); + expect(html).toContain("validate"); + }); +}); + +describe("PerProductBreakdown", () => { + it("renders product table with share bars", () => { + const html = renderToString( + , + ); + expect(html).toContain("Per-Product Breakdown"); + expect(html).toContain("symphony-ts"); + expect(html).toContain("jony-agent"); + expect(html).toContain("stickerlabs"); + expect(html).toContain("product-bar"); + }); +}); + +describe("ReportFooter", () => { + it("renders footer text", () => { + const html = renderToString(); + expect(html).toContain("SYMPH-131"); + expect(html).toContain("token-report.mjs"); + }); +}); + +// ─── chart-utils unit tests ─── + +describe("chart-utils", () => { + it("round() rounds to specified decimals", () => { + expect(round(Math.PI, 2)).toBe(3.14); + expect(round(Math.PI, 0)).toBe(3); + expect(round(1000.5)).toBe(1001); + }); + + it("linearScale maps value to range", () => { + expect(linearScale(50, 0, 100, 0, 200)).toBe(100); + expect(linearScale(0, 0, 100, 0, 200)).toBe(0); + expect(linearScale(100, 0, 100, 0, 200)).toBe(200); + }); + + it("linearScale clamps out-of-range values", () => { + expect(linearScale(150, 0, 100, 0, 200)).toBe(200); + expect(linearScale(-10, 0, 100, 0, 200)).toBe(0); + }); + + it("formatDateLabel converts YYYY-MM-DD to short label", () => { + expect(formatDateLabel("2026-03-05")).toBe("Mar 05"); + expect(formatDateLabel("2026-01-15")).toBe("Jan 15"); + expect(formatDateLabel("2026-12-31")).toBe("Dec 31"); + }); + + it("pickTickIndices returns evenly spaced indices", () => { + expect(pickTickIndices(10, 3)).toEqual([0, 5, 9]); + expect(pickTickIndices(3, 5)).toEqual([0, 1, 2]); + expect(pickTickIndices(5, 5)).toEqual([0, 1, 2, 3, 4]); + }); + + it("computeYGrid returns values from max to min", () => { + const grid = computeYGrid(0, 100, 4); + expect(grid).toEqual([100, 75, 50, 25, 0]); + }); + + it("formatAxisValue uses K/M suffixes", () => { + expect(formatAxisValue(1500000)).toBe("1.5M"); + expect(formatAxisValue(52000)).toBe("52K"); + expect(formatAxisValue(500)).toBe("500"); + }); + + it("buildPointsString joins coordinates", () => { + const result = buildPointsString([ + [10, 20], + [30, 40], + ]); + expect(result).toBe("10,20 30,40"); + }); + + it("buildAreaString creates closed polygon", () => { + const result = buildAreaString( + [ + [10, 20], + [30, 40], + ], + 100, + ); + expect(result).toContain("10,20"); + expect(result).toContain("30,40"); + expect(result).toContain("30,100"); + expect(result).toContain("10,100"); + }); + + it("buildAreaString returns empty for no coords", () => { + expect(buildAreaString([], 100)).toBe(""); + }); + + it("extractSortedDates extracts dates from stage trend data", () => { + const stageData = { + implement: { daily_avg: { "2026-03-01": 100, "2026-03-03": 300 } }, + review: { daily_avg: { "2026-03-02": 200, "2026-03-01": 150 } }, + }; + const dates = extractSortedDates(stageData); + expect(dates).toEqual(["2026-03-01", "2026-03-02", "2026-03-03"]); + }); + + it("extractSortedDates handles scalar daily_avg", () => { + const stageData = { + implement: { daily_avg: 5000 }, + }; + const dates = extractSortedDates(stageData); + expect(dates).toEqual([]); + }); +}); + +// ─── StageUtilizationChart tests ─── + +describe("StageUtilizationChart", () => { + const dateKeyedTrend: Record< + string, + { daily_avg: Record; wow_delta: number } + > = { + investigate: { + daily_avg: { + "2026-03-01": 50000, + "2026-03-02": 55000, + "2026-03-03": 60000, + }, + wow_delta: -0.05, + }, + implement: { + daily_avg: { + "2026-03-01": 150000, + "2026-03-02": 160000, + "2026-03-03": 170000, + }, + wow_delta: 0.03, + }, + }; + + it("renders stacked area chart with date-keyed data", () => { + const html = renderToString( + , + ); + expect(html).toContain("svg"); + expect(html).toContain("Per-stage utilization stacked area chart"); + // Should have polygon elements for areas + expect(html).toContain("polygon"); + // Should have polyline elements for lines + expect(html).toContain("polyline"); + // Should contain date labels + expect(html).toContain("Mar 01"); + expect(html).toContain("Mar 03"); + // Should contain legend items + expect(html).toContain("investigate"); + expect(html).toContain("implement"); + }); + + it("renders insufficient data state for scalar daily_avg", () => { + const html = renderToString( + , + ); + // Sample analysis.json has scalar daily_avg, so no dates → insufficient data + expect(html).toContain("Insufficient data"); + }); + + it("renders config change markers", () => { + const html = renderToString( + , + ); + expect(html).toContain("\u2699"); // gear icon + }); +}); + +// ─── TicketCostChart tests ─── + +describe("TicketCostChart", () => { + const sampleSeries = [45000, 52000, 48000, 61000, 55000, 58000, 53000]; + + it("renders line chart with series data", () => { + const html = renderToString( + , + ); + expect(html).toContain("svg"); + expect(html).toContain("Per-ticket cost trend chart"); + // Should have polyline for main data line + expect(html).toContain("polyline"); + // Should have polygon for area fill + expect(html).toContain("polygon"); + // Should have median reference line label + expect(html).toContain("med"); + // Should have mean reference line label + expect(html).toContain("avg"); + // Should have legend + expect(html).toContain("Median"); + expect(html).toContain("Mean"); + expect(html).toContain("Per-ticket"); + }); + + it("renders insufficient data state without series", () => { + const html = renderToString( + , + ); + expect(html).toContain("Insufficient series data"); + // Should still show median/mean as static text + expect(html).toContain("52K"); + expect(html).toContain("59K"); + }); + + it("renders with minimal series data", () => { + const html = renderToString( + , + ); + expect(html).toContain("polyline"); + expect(html).toContain("med"); + }); +}); diff --git a/ops/token-report-ui/src/App.tsx b/ops/token-report-ui/src/App.tsx new file mode 100644 index 00000000..9a4ec041 --- /dev/null +++ b/ops/token-report-ui/src/App.tsx @@ -0,0 +1,86 @@ +import { + EfficiencyScorecard, + ExecutiveSummary, + IssueLeaderboard, + OutlierAnalysis, + PerProductBreakdown, + PerStageTrend, + PerTicketCostTrend, + ReportFooter, + ReportHeader, + StageEfficiency, + reportCSS, +} from "./components/index.ts"; +import analysisData from "./data/analysis.json"; +import type { AnalysisData, Inflection, Outlier } from "./types.ts"; + +const data = analysisData as AnalysisData; + +/** Normalize inflections/outliers from the dual union shape. */ +function normalizeInflections(raw: AnalysisData["inflections"]): Inflection[] { + if (Array.isArray(raw)) return raw; + return raw?.items ?? []; +} + +function normalizeOutliers(raw: AnalysisData["outliers"]): Outlier[] { + if (Array.isArray(raw)) return raw; + return raw?.items ?? []; +} + +export default function App() { + const es = data.executive_summary; + const sc = data.efficiency_scorecard; + const inflections = normalizeInflections(data.inflections); + const outliers = normalizeOutliers(data.outliers); + + // Compute derived ExecutiveSummary props from raw analysis.json + const totalTokens = es.total_tokens.value; + const tokensPerIssueMedian = data.per_ticket_trend.median; + const tokensPerIssueMean = data.per_ticket_trend.mean; + const uniqueIssues = es.unique_issues.value; + const cacheHitRate = (sc.cache_efficiency.current ?? 0) * 100; + + // WoW deltas computed from scorecard trends where available + const cacheWow = + sc.cache_efficiency.trend_7d != null + ? Math.round( + ((sc.cache_efficiency.current - sc.cache_efficiency.trend_7d) / + (sc.cache_efficiency.trend_7d || 1)) * + 100, + ) + : null; + + return ( + <> + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: report CSS is a static build-time string, not user input */} + + + +

Symphony Token Report

+

Generated ${escHtml(today)} · ${fmtNum(analysis.record_count ?? 0)} records · ${analysis.data_span_days ?? 0} day span

+ + +

Executive Summary

+
+
+
Total Tokens
+
${fmtNum(es.total_tokens?.value)}
+
${wowBadge(tokensDelta)}
+
+
+
Tokens / Issue (median)
+
${fmtNum(tokensPerIssueMedian)}
+
mean: ${fmtNum(tokensPerIssueMean)} ${wowBadge(tokPerIssueWow)}
+
+
+
Issues Processed
+
${fmtNum(es.unique_issues?.value)}
+
+
+
Cache Hit Rate
+
${round(cacheHitRate, 1)}%
+
${wowBadge(cacheWow)}
+
+
+ + +

Efficiency Scorecard

+
+ Cache Efficiency + ${round(sc.cache_efficiency?.current ?? 0, 1)}% + ${sparklineSvg(cacheEffSeries)} +
+
+ Output Ratio + ${round(sc.output_ratio?.current ?? 0, 1)}% + ${sparklineSvg(outputRatioSeries, { stroke: "#3fb950" })} +
+
+ Wasted Context + ${round(sc.wasted_context?.current ?? 0, 1)}% + ${sparklineSvg(wastedCtxSeries, { stroke: "#d29922" })} +
+
+ Tokens / Turn + ${fmtNum(sc.tokens_per_turn?.current ?? 0)} + ${sparklineSvg(tokPerTurnSeries, { stroke: "#bc8cff" })} +
+
+ First-Pass Rate + ${round(sc.first_pass_rate?.current ?? 0, 1)}% + ${sparklineSvg(firstPassSeries, { stroke: "#56d364" })} +
+
+ Failure Rate (all stages) + ${(() => { + const fr = sc.failure_rate?.current ?? {}; + const rates = Object.values(fr); + return rates.length > 0 ? `${round(mean(rates), 1)}%` : "0%"; + })()} + ${sparklineSvg(failureRateSeries, { stroke: "#f85149" })} +
+ + +

Per-Stage Utilization Trend

+
+${multiLineSvg(perStageTrend, configChanges)} +${ + inflections.length > 0 + ? inflections + .map( + (inf) => ` +
+
⚡ Inflection: ${escHtml(inf.stage)} — ${escHtml(inf.direction)} ${inf.pct_change}%
+
7d avg: ${fmtNum(inf.avg_7d)} · 30d avg: ${fmtNum(inf.avg_30d)}${inf.attributions?.length > 0 ? ` · ${inf.attributions.map((a) => escHtml(a.description)).join("; ")}` : ""}
+
`, + ) + .join("") + : "" +} +
+ + +

Per-Ticket Cost Trend

+
+
Rolling median tokens per ticket · median: ${fmtNum(perTicket.median)} · mean: ${fmtNum(perTicket.mean)} · ${perTicket.ticket_count} tickets
+ ${sparklineSvg(perTicketSeries, { width: 580, height: 60, stroke: "#58a6ff", strokeWidth: 2 })} +
+ + +

Outlier Analysis

+${ + outliers.length === 0 + ? '

No outliers detected (>2σ threshold)

' + : outliers + .map( + (o) => ` +
+
${escHtml(o.issue_identifier)} — ${escHtml(o.issue_title)} — ${fmtNum(o.total_tokens)} tokens (z=${o.z_score})
+
${escHtml(o.hypothesis ?? "No hypothesis available")}
+ ${o.parent ? `
Parent: ${escHtml(o.parent.identifier)} (${escHtml(o.parent.complexity)}, ${o.parent.task_count} tasks)
` : ""} +
`, + ) + .join("") +} + + +

Issue Leaderboard

+ + + +${leaderboard + .slice(0, 25) + .map( + (item, i) => + ` `, + ) + .join("\n")} + +
#IssueTitleTokens
${i + 1}${escHtml(item.identifier)}${escHtml(item.title)}${fmtNum(item.tokens)}
+ + +

Stage Efficiency

+${Object.entries(perStageSpend) + .map( + ([stage, data]) => ` +
+
+ ${escHtml(stage)} + ${fmtNum(data.total_tokens)} tokens · ${data.count} runs · ${data.completed} ok · ${data.failed} fail +
+
+ 30d trend: + ${sparklineSvg(stageSparklines[stage] ?? [], { stroke: "#58a6ff" })} +
+
`, + ) + .join("")} + + +

Per-Product Breakdown

+ + + +${(() => { + const totalTokens = + Object.values(perProduct).reduce((s, p) => s + (p.total_tokens ?? 0), 0) || + 1; + return Object.entries(perProduct) + .sort((a, b) => (b[1].total_tokens ?? 0) - (a[1].total_tokens ?? 0)) + .map(([name, data]) => { + const pct = round((data.total_tokens / totalTokens) * 100, 1); + return ` `; + }) + .join("\n"); +})()} + +
ProductTokensStagesIssuesShare
${escHtml(name)}${fmtNum(data.total_tokens)}${data.total_stages}${data.unique_issues}
${pct}%
+ +
Symphony Token Report · Self-contained · Generated by token-report.mjs · SYMPH-131
+ +`; + + return html; +} + +/** + * Render subcommand: generate HTML report file. + */ +function runRender() { + const analysis = computeAnalysis(); + const html = renderHtml(analysis); + const today = dateKey(new Date()); + const outPath = join(REPORTS_DIR, `${today}.html`); + mkdirSync(REPORTS_DIR, { recursive: true }); + writeFileSync(outPath, html); + info(`Report written to ${outPath}`); +} + +// --------------------------------------------------------------------------- +// Slack subcommand — SYMPH-131 +// --------------------------------------------------------------------------- + +/** + * Post narrative Slack digest via Bot Token API (SYMPH-139). + * + * 9-section markdown digest with interpretive commentary. + * Set DRY_RUN=1 to log to stderr instead of posting. + */ +function runSlack() { + const botToken = process.env.SLACK_BOT_TOKEN; + if (!botToken) { + warn("SLACK_BOT_TOKEN not set — skipping Slack digest"); + return; + } + const channelId = process.env.SLACK_CHANNEL_ID || "C0ANRJRBYGL"; + + const analysis = computeAnalysis(); + const es = analysis.executive_summary ?? {}; + const sc = analysis.efficiency_scorecard ?? {}; + const outliers = Array.isArray(analysis.outliers) + ? analysis.outliers + : (analysis.outliers?.items ?? []); + const inflections = Array.isArray(analysis.inflections) + ? analysis.inflections + : (analysis.inflections?.items ?? []); + const perStageSpend = analysis.per_stage_spend ?? {}; + const perProduct = analysis.per_product ?? {}; + const perTicket = analysis.per_ticket_trend ?? {}; + + // Compute tokens-per-issue + const records = readJsonl(TOKEN_HISTORY_PATH); + const issueTokens = {}; + for (const r of records) { + if (r.issue_identifier) + issueTokens[r.issue_identifier] = + (issueTokens[r.issue_identifier] ?? 0) + (r.total_total_tokens ?? 0); + } + const issueValues = Object.values(issueTokens); + const medianTPI = fmtNum(median(issueValues)); + const meanTPI = fmtNum(mean(issueValues)); + + // Top consumer + let topConsumer = "—"; + if (issueValues.length > 0) { + const sorted = Object.entries(issueTokens).sort((a, b) => b[1] - a[1]); + topConsumer = `${sorted[0][0]} (${fmtNum(sorted[0][1])})`; + } + + // Report link — always use BASE_URL; fall back to pro16.local:{port} + const reportPort = process.env.TOKEN_REPORT_PORT || "8090"; + const baseUrl = process.env.BASE_URL || `pro16.local:${reportPort}`; + const today = dateKey(new Date()); + const reportUrl = `http://${baseUrl}/${today}.html`; + + // --- Section 1: Title --- + const sections = []; + sections.push(`*🎵 Symphony Token Digest — ${today}*`); + + // --- Section 2: Executive Summary --- + const spanDays = analysis.data_span_days ?? 0; + const tier = analysis.cold_start_tier ?? "unknown"; + sections.push( + `*Executive Summary*\n> *${fmtNum(es.total_tokens?.value)}* tokens across *${fmtNum(es.unique_issues?.value)}* issues over *${spanDays}d* (tier: ${tier})\n> ${fmtNum(es.total_stages?.value ?? 0)} total stages completed`, + ); + + // --- Section 3: Tokens per Issue --- + sections.push( + `*Tokens per Issue*\n> Median: *${medianTPI}* · Mean: *${meanTPI}* · Issues tracked: *${issueValues.length}*\n> Top consumer: *${topConsumer}*\n${ + perTicket.ticket_count > 0 + ? `> Rolling trend — median: ${fmtNum(perTicket.median)}, mean: ${fmtNum(perTicket.mean)}` + : "> _No rolling trend data yet_" + }`, + ); + + // --- Section 4: Efficiency Scorecard --- + const cacheEff = round(sc.cache_efficiency?.current ?? 0, 1); + const cacheTrend7d = round(sc.cache_efficiency?.trend_7d ?? 0, 1); + const outputRatio = round(sc.output_ratio?.current ?? 0, 1); + const firstPass = round(sc.first_pass_rate?.current ?? 0, 1); + const tokPerTurn = fmtNum(sc.tokens_per_turn?.current ?? 0); + const wastedCtx = round(sc.wasted_context?.current ?? 0, 1); + sections.push( + `*Efficiency Scorecard*\n> Cache hit rate: *${cacheEff}%* (7d trend: ${cacheTrend7d >= 0 ? "+" : ""}${cacheTrend7d}%)\n> Output ratio: *${outputRatio}%* · First-pass success: *${firstPass}%*\n> Tokens/turn: *${tokPerTurn}* · Wasted context: *${wastedCtx}%*`, + ); + + // --- Section 5: Per-Stage Spend --- + const stageEntries = Object.entries(perStageSpend); + if (stageEntries.length > 0) { + const stageLines = stageEntries + .sort((a, b) => (b[1]?.total ?? 0) - (a[1]?.total ?? 0)) + .slice(0, 5) + .map( + ([stage, data]) => + `> • ${stage}: *${fmtNum(data?.total ?? 0)}* tokens (${fmtNum(data?.count ?? 0)} stages)`, + ); + sections.push(`*Per-Stage Spend (top 5)*\n${stageLines.join("\n")}`); + } else { + sections.push("*Per-Stage Spend*\n> _No stage data available_"); + } + + // --- Section 6: Per-Product Breakdown --- + const productEntries = Object.entries(perProduct); + if (productEntries.length > 0) { + const productLines = productEntries + .sort((a, b) => (b[1]?.total_tokens ?? 0) - (a[1]?.total_tokens ?? 0)) + .slice(0, 5) + .map( + ([product, data]) => + `> • ${product}: *${fmtNum(data?.total_tokens ?? 0)}* tokens (${fmtNum(data?.stage_count ?? 0)} stages)`, + ); + sections.push( + `*Per-Product Breakdown (top 5)*\n${productLines.join("\n")}`, + ); + } else { + sections.push("*Per-Product Breakdown*\n> _No product data available_"); + } + + // --- Section 7: Outliers --- + if (outliers.length > 0) { + const outlierLines = outliers + .slice(0, 5) + .map( + (o) => + `> • ⚠️ ${o.issue_identifier}: *${fmtNum(o.total_tokens)}* tokens (z=${round(o.z_score, 2)})${o.hypothesis ? ` — ${o.hypothesis}` : ""}`, + ); + sections.push( + `*Outliers* (>${"2σ"} from mean)\n${outlierLines.join("\n")}`, + ); + } else { + sections.push( + "*Outliers*\n> ✅ No outliers detected — all issues within 2σ of mean", + ); + } + + // --- Section 8: Inflections --- + if (inflections.length > 0) { + const inflectionLines = inflections + .slice(0, 5) + .map( + (inf) => + `> • ⚡ ${inf.stage}: ${inf.direction} *${round(inf.pct_change, 1)}%* (7d avg crossed 30d avg)`, + ); + sections.push(`*Trend Inflections*\n${inflectionLines.join("\n")}`); + } else { + sections.push( + `*Trend Inflections*\n> _No inflection points detected${spanDays < 30 ? " (requires ≥30d of data)" : ""}_`, + ); + } + + // --- Section 9: Report Link --- + sections.push(`📊 <${reportUrl}|View full HTML report>`); + + const message = sections.join("\n\n"); + + // DRY_RUN support: log to stderr instead of posting + if (process.env.DRY_RUN) { + process.stderr.write(`[DRY_RUN] Slack digest message:\n${message}\n`); + info("DRY_RUN set — Slack digest logged to stderr, not posted"); + return; + } + + const payload = JSON.stringify({ channel: channelId, text: message }); + + try { + const response = execFileSync( + "curl", + [ + "-s", + "-X", + "POST", + "https://slack.com/api/chat.postMessage", + "-H", + `Authorization: Bearer ${botToken}`, + "-H", + "Content-type: application/json; charset=utf-8", + "-d", + payload, + ], + { + encoding: "utf-8", + timeout: 30000, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let parsed; + try { + parsed = JSON.parse(response); + } catch { + warn(`Slack post returned non-JSON response: ${response.slice(0, 200)}`); + return; + } + if (parsed.ok) { + info("Slack digest posted"); + } else { + warn(`Slack API error: ${parsed.error ?? "unknown"}`); + } + } catch (err) { + warn(`Slack post failed: ${err.message}`); + // Graceful degradation: don't throw + } +} + +// --------------------------------------------------------------------------- +// Rotate subcommand — SYMPH-131 +// --------------------------------------------------------------------------- + +import { + createReadStream, + createWriteStream, + unlinkSync, + utimesSync, +} from "node:fs"; +import { pipeline } from "node:stream/promises"; +import { createGzip } from "node:zlib"; + +/** + * Log rotation: compress/delete old JSONL logs and HTML reports. + * + * Raw JSONL: compress >7d, delete >14d, skip mtime <2h + * HTML reports: delete >90d + * + * Replaces com.symphony.newsyslog.conf for symphony logs. + */ +async function runRotate() { + const now = Date.now(); + const TWO_HOURS_MS = 2 * 60 * 60 * 1000; + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000; + const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; + + // Rotate JSONL files in data dir + if (existsSync(DATA_DIR)) { + const files = readdirSync(DATA_DIR); + for (const file of files) { + const filePath = join(DATA_DIR, file); + let st; + try { + st = statSync(filePath); + } catch { + continue; + } + if (!st.isFile()) continue; + + const age = now - st.mtimeMs; + + // Safety: never touch files modified less than 2 hours ago + if (age < TWO_HOURS_MS) continue; + + // Delete compressed files older than 14 days + if (file.endsWith(".jsonl.gz") && age > FOURTEEN_DAYS_MS) { + info(`Deleting old compressed log: ${file}`); + unlinkSync(filePath); + continue; + } + + // Compress JSONL files older than 7 days + if (file.endsWith(".jsonl") && age > SEVEN_DAYS_MS) { + info(`Compressing old log: ${file}`); + const gzPath = `${filePath}.gz`; + try { + await pipeline( + createReadStream(filePath), + createGzip(), + createWriteStream(gzPath), + ); + // Preserve mtime on compressed file + utimesSync(gzPath, st.atime, st.mtime); + unlinkSync(filePath); + } catch (err) { + warn(`Failed to compress ${file}: ${err.message}`); + } + } + } + } + + // Delete old HTML reports + if (existsSync(REPORTS_DIR)) { + const files = readdirSync(REPORTS_DIR); + for (const file of files) { + if (!file.endsWith(".html")) continue; + const filePath = join(REPORTS_DIR, file); + let st; + try { + st = statSync(filePath); + } catch { + continue; + } + if (!st.isFile()) continue; + const age = now - st.mtimeMs; + if (age > NINETY_DAYS_MS) { + info(`Deleting old report: ${file}`); + unlinkSync(filePath); + } + } + } + + info("Log rotation complete"); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function ensureDirs(...dirs) { + for (const dir of dirs) { + mkdirSync(dir, { recursive: true }); + } +} + +const subcommand = process.argv[2]; + +if (!subcommand || subcommand === "extract") { + ensureDirs( + DATA_DIR, + HWM_DIR, + LINEAR_CACHE_DIR, + join(SYMPHONY_HOME, "logs"), + REPORTS_DIR, + ); + runExtract(); +} else if (subcommand === "analyze") { + ensureDirs(DATA_DIR, LINEAR_CACHE_DIR); + runAnalyze(); +} else if (subcommand === "render") { + ensureDirs(DATA_DIR, LINEAR_CACHE_DIR, REPORTS_DIR); + runRender(); +} else if (subcommand === "slack") { + ensureDirs(DATA_DIR, LINEAR_CACHE_DIR); + runSlack(); +} else if (subcommand === "rotate") { + runRotate().catch((err) => { + process.stderr.write(`ERROR: rotate failed: ${err.message}\n`); + process.exit(1); + }); +} else { + process.stderr.write( + `Unknown subcommand: ${subcommand}\nUsage: token-report.mjs [extract|analyze|render|slack|rotate]\n`, + ); + process.exit(1); +} diff --git a/ops/token-report.sh b/ops/token-report.sh new file mode 100755 index 00000000..809c8e28 --- /dev/null +++ b/ops/token-report.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +# token-report.sh — Wrapper for token history extraction, analysis, reporting, and rotation +# +# Responsibilities: +# - Validate/set default env vars (SYMPHONY_HOME, SYMPHONY_LOG_DIR) +# - Create directory tree +# - Acquire lockfile via shlock (concurrent execution guard) +# - Route to node ops/token-report.mjs +# - Orchestrate daily pipeline (extract → analyze → render → slack → rotate) +# - Release lockfile via trap +# +# Usage: token-report.sh [extract|analyze|render|slack|rotate|daily] +# +# SYMPH-129, SYMPH-131 + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +SYMPHONY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --------------------------------------------------------------------------- +# Environment defaults +# --------------------------------------------------------------------------- + +export SYMPHONY_HOME="${SYMPHONY_HOME:-$HOME/.symphony}" +export SYMPHONY_LOG_DIR="${SYMPHONY_LOG_DIR:-$HOME/Library/Logs/symphony}" + +# --------------------------------------------------------------------------- +# Directory tree creation +# --------------------------------------------------------------------------- + +mkdir -p "$SYMPHONY_HOME"/{data/.hwm,data/linear-cache,logs,reports} + +# --------------------------------------------------------------------------- +# Lockfile management +# --------------------------------------------------------------------------- + +LOCKFILE="$SYMPHONY_HOME/data/.lock" + +cleanup_lock() { + rm -f "$LOCKFILE" +} + +acquire_lock() { + if command -v shlock >/dev/null 2>&1; then + if ! shlock -p $$ -f "$LOCKFILE"; then + echo "Another instance is running, skipping" >&2 + exit 0 + fi + else + # Fallback: simple mkdir-based lock for systems without shlock + if ! mkdir "$LOCKFILE.d" 2>/dev/null; then + echo "Another instance is running, skipping" >&2 + exit 0 + fi + # Override cleanup to remove directory lock + cleanup_lock() { + rm -f "$LOCKFILE" + rmdir "$LOCKFILE.d" 2>/dev/null || true + } + fi + trap cleanup_lock EXIT INT TERM +} + +# --------------------------------------------------------------------------- +# Subcommand routing +# --------------------------------------------------------------------------- + +SUBCOMMAND="${1:-extract}" +NODE_BIN="${SYMPHONY_NODE:-$(which node 2>/dev/null || echo /opt/homebrew/bin/node)}" +UI_DIR="$SCRIPT_DIR/token-report-ui" + +# --------------------------------------------------------------------------- +# Build helper: copy analysis.json → React app src/data, pnpm build, +# copy dist/index.html → reports/{date}.html +# --------------------------------------------------------------------------- + +build_report() { + local analysis_json="$SYMPHONY_HOME/data/analysis.json" + if [ ! -f "$analysis_json" ]; then + echo "ERROR: $analysis_json not found — run analyze first" >&2 + return 1 + fi + + # Copy live analysis data into React app source for static bundling + mkdir -p "$UI_DIR/src/data" + cp "$analysis_json" "$UI_DIR/src/data/analysis.json" + + # Build the React app (produces dist/index.html as single-file bundle) + (cd "$UI_DIR" && pnpm build) + echo "INFO: React app build complete" >&2 + + # Copy built HTML to reports directory + local today + today="$(date +%Y-%m-%d)" + mkdir -p "$SYMPHONY_HOME/reports" + cp "$UI_DIR/dist/index.html" "$SYMPHONY_HOME/reports/${today}.html" + echo "INFO: Report written to $SYMPHONY_HOME/reports/${today}.html" >&2 +} + +case "$SUBCOMMAND" in + extract) + acquire_lock + "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" extract + ;; + analyze) + acquire_lock + "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" analyze + ;; + render) + acquire_lock + # Build React app report from analysis.json (SYMPH-145) + build_report + ;; + slack) + "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" slack + ;; + rotate) + "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" rotate + ;; + daily) + acquire_lock + + # Daily pipeline: extract → analyze → build → slack → rotate + # If extract/analyze/build fail → skip subsequent, exit non-zero + # Slack failure → log warning, continue to rotate (graceful degradation) + # Rotate failure → log warning, exit non-zero + + echo "INFO: Starting daily pipeline" >&2 + + "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" extract + echo "INFO: extract complete" >&2 + + "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" analyze > /dev/null + echo "INFO: analyze complete" >&2 + + build_report + echo "INFO: build complete" >&2 + + # Slack: graceful degradation — failure logs warning but continues + if ! "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" slack; then + echo "WARN: Slack step failed, continuing to rotate" >&2 + fi + + # Rotate: failure is non-zero exit + if ! "$NODE_BIN" "$SCRIPT_DIR/token-report.mjs" rotate; then + echo "WARN: Rotate step failed" >&2 + exit 1 + fi + + echo "INFO: Daily pipeline complete" >&2 + ;; + *) + echo "Usage: token-report.sh [extract|analyze|render|slack|rotate|daily]" >&2 + exit 1 + ;; +esac diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..4ae1d980 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1859 @@ +{ + "name": "symphony-ts", + "version": "0.1.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "symphony-ts", + "version": "0.1.8", + "license": "Apache-2.0", + "dependencies": { + "graphql": "^16.13.1", + "liquidjs": "^10.24.0", + "yaml": "^2.8.2", + "zod": "^4.3.6" + }, + "bin": { + "symphony": "dist/src/cli/main.js" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.13.14", + "typescript": "^5.8.2", + "vitest": "^3.0.8" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "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.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/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/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/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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/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/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "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/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/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/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "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/liquidjs": { + "version": "10.25.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.0.tgz", + "integrity": "sha512-XpO7AiGULTG4xcTlwkcTI5JreFG7b6esLCLp+aUSh7YuQErJZEoUXre9u9rbdb0057pfWG4l0VursvLd5Q/eAw==", + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, + "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/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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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/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/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/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.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "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/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/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "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.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "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.4", + "@vitest/ui": "3.2.4", + "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/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 6dcbf925..2d730f07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "symphony-ts", - "version": "0.1.8", + "version": "2026.03.23.1", "license": "Apache-2.0", "type": "module", "description": "TypeScript implementation of Symphony", @@ -37,7 +37,7 @@ "build": "tsc -p tsconfig.build.json", "prepack": "pnpm build", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", + "test": "node scripts/test.mjs", "test:watch": "vitest", "lint": "biome check .", "format": "biome format --write ." @@ -49,6 +49,14 @@ "vitest": "^3.0.8" }, "dependencies": { + "@ai-sdk/provider": "^3.0.8", + "@google/gemini-cli-core": "^0.33.2", + "@google/genai": "^1.45.0", + "@slack/bolt": "^4.6.0", + "@slack/web-api": "^7.15.0", + "ai": "^6.0.116", + "ai-sdk-provider-claude-code": "^3.4.4", + "ai-sdk-provider-gemini-cli": "^2.0.1", "graphql": "^16.13.1", "liquidjs": "^10.24.0", "yaml": "^2.8.2", diff --git a/pipeline-config/WORKFLOW-flat.md b/pipeline-config/WORKFLOW-flat.md new file mode 100644 index 00000000..1e748fa8 --- /dev/null +++ b/pipeline-config/WORKFLOW-flat.md @@ -0,0 +1,108 @@ +--- +tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: 1fa66498be91 + active_states: + - Todo + terminal_states: + - Done + - Cancelled + +polling: + interval_ms: 30000 + +workspace: + root: ./workspaces + +agent: + max_concurrent_agents: 1 + max_turns: 30 + max_retry_backoff_ms: 300000 + +runner: + kind: claude-code + model: claude-sonnet-4-5 + +hooks: + after_create: | + set -euo pipefail + if [ -z "${REPO_URL:-}" ]; then + echo "ERROR: REPO_URL environment variable is not set" >&2 + exit 1 + fi + echo "Cloning $REPO_URL into workspace..." + git clone --depth 1 "$REPO_URL" . + if [ -f package.json ]; then + if [ -f bun.lock ]; then + bun install --frozen-lockfile + elif [ -f pnpm-lock.yaml ]; then + pnpm install --frozen-lockfile + elif [ -f yarn.lock ]; then + yarn install --frozen-lockfile + else + npm install + fi + fi + echo "Workspace setup complete." + before_run: | + set -euo pipefail + echo "Syncing workspace with upstream main..." + git fetch origin main + if ! git rebase origin/main 2>/dev/null; then + echo "WARNING: Rebase failed, aborting rebase" >&2 + git rebase --abort + fi + echo "Workspace synced." + timeout_ms: 120000 + +server: + port: 4321 + +observability: + dashboard_enabled: true + refresh_ms: 5000 +--- + +You are running in headless/unattended mode. Do NOT use interactive skills, slash commands, or plan mode. Do not prompt for user input. Complete your work autonomously. + +Implement only what your task specifies. If you encounter missing functionality that another task covers, add a TODO comment rather than implementing it. Do not refactor surrounding code or add unsolicited improvements. + +Never hardcode localhost or 127.0.0.1. Use the $BASE_URL environment variable for all URL references. Set BASE_URL=localhost: during local development. + +# Implementation: {{ issue.identifier }} — {{ issue.title }} + +You are implementing Linear issue {{ issue.identifier }}. + +## Issue Description + +{{ issue.description }} + +{% if issue.labels.size > 0 %} +Labels: {{ issue.labels | join: ", " }} +{% endif %} + +## Implementation Steps + +1. Read any investigation notes from previous comments on this issue. +2. Create a feature branch from the issue's suggested branch name{% if issue.branch_name %} (`{{ issue.branch_name }}`){% endif %}, or use `{{ issue.identifier | downcase }}/`. +3. Implement the task per the issue description. +4. Write tests as needed. +5. Run all `# Verify:` commands from the spec. You are not done until every verify command exits 0. +6. Commit your changes with message format: `feat({{ issue.identifier }}): `. +7. Open a PR targeting this repo (not its upstream fork parent) via `gh pr create --repo $(git remote get-url origin | sed "s|.*github.com/||;s|\.git$||")` with the issue description in the PR body. +8. Link the PR to the Linear issue by including `{{ issue.identifier }}` in the PR title or body. + +## Scope Discipline + +- If your task requires a capability that doesn't exist in the codebase and isn't specified in the spec, stop and comment what's missing on the issue. Don't scaffold unspecced infrastructure. +- Tests must be runnable against $BASE_URL (no localhost assumptions in committed tests). + +## Documentation Maintenance + +- If you add a new module, API endpoint, or significant abstraction, update the relevant docs/ file and the AGENTS.md Documentation Map entry. If no relevant doc exists, create one following the docs/ conventions (# Title, > Last updated header). +- If a docs/ file you reference during implementation is stale or missing, update/create it as part of your implementation. Include the update in the same PR as your code changes — never in a separate PR. +- If you make a non-obvious architectural decision during implementation, create a design doc in docs/design-docs/ following the ADR format (numbered, with Status line). Add it to the AGENTS.md design docs table. +- When you complete your implementation, update the > Last updated date on any docs/ file you modified. +- Do not update docs/generated/ files — those are auto-generated and will be overwritten. +- Commit doc updates in the same PR as code changes, not separately. diff --git a/pipeline-config/WORKFLOW-instrumentation.md b/pipeline-config/WORKFLOW-instrumentation.md new file mode 100644 index 00000000..81d5b2c0 --- /dev/null +++ b/pipeline-config/WORKFLOW-instrumentation.md @@ -0,0 +1,429 @@ +--- +tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: fdba14472043 + active_states: + - Todo + - In Progress + - In Review + - Blocked + - Resume + terminal_states: + - Done + - Cancelled + +escalation_state: Blocked + +polling: + interval_ms: 30000 + +workspace: + root: ./workspaces + +agent: + max_concurrent_agents: 1 + max_turns: 30 + max_retry_backoff_ms: 300000 + +codex: + stall_timeout_ms: 1800000 + +runner: + kind: claude-code + model: claude-sonnet-4-6 + +hooks: + after_create: | + set -euo pipefail + if [ -z "${REPO_URL:-}" ]; then + echo "ERROR: REPO_URL environment variable is not set" >&2 + exit 1 + fi + echo "Cloning $REPO_URL into workspace..." + git clone --depth 1 "$REPO_URL" . + if [ -f package.json ]; then + if [ -f bun.lock ]; then + bun install --frozen-lockfile + elif [ -f pnpm-lock.yaml ]; then + pnpm install --frozen-lockfile + elif [ -f yarn.lock ]; then + yarn install --frozen-lockfile + else + npm install + fi + fi + # --- Build code graph (best-effort) --- + if command -v code-review-graph >/dev/null 2>&1; then + echo "Building code review graph..." + code-review-graph build --repo . || echo "WARNING: code-review-graph build failed, continuing without graph" >&2 + else + echo "WARNING: code-review-graph not installed, skipping graph build" >&2 + fi + echo "Workspace setup complete." + before_run: | + set -euo pipefail + echo "Syncing workspace with upstream..." + + # --- Git lock handling --- + wait_for_git_lock() { + local attempt=0 + while [ -f .git/index.lock ] && [ $attempt -lt 6 ]; do + echo "WARNING: .git/index.lock exists, waiting 5s (attempt $((attempt+1))/6)..." >&2 + sleep 5 + attempt=$((attempt+1)) + done + if [ -f .git/index.lock ]; then + echo "WARNING: .git/index.lock still exists after 30s, removing stale lock" >&2 + rm -f .git/index.lock + fi + } + + # --- Git fetch with retry --- + fetch_ok=false + for attempt in 1 2 3; do + wait_for_git_lock + if git fetch origin 2>/dev/null; then + fetch_ok=true + break + fi + echo "WARNING: git fetch failed (attempt $attempt/3), retrying in 2s..." >&2 + sleep 2 + done + if [ "$fetch_ok" = false ]; then + echo "WARNING: git fetch failed after 3 attempts, continuing with stale refs" >&2 + fi + + # --- Rebase (best-effort) --- + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then + echo "On $CURRENT_BRANCH — rebasing onto latest..." + wait_for_git_lock + if ! git rebase "origin/$CURRENT_BRANCH" 2>/dev/null; then + echo "WARNING: Rebase failed, aborting rebase" >&2 + git rebase --abort 2>/dev/null || true + fi + else + echo "On feature branch $CURRENT_BRANCH — skipping rebase, fetch only." + fi + echo "Workspace synced." + before_remove: | + set -uo pipefail + BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [ -z "$BRANCH" ] || [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "HEAD" ]; then + exit 0 + fi + echo "Cleaning up branch $BRANCH..." + # Close any open PR for this branch (also deletes the remote branch via --delete-branch) + PR_NUM=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$PR_NUM" ]; then + echo "Closing PR #$PR_NUM and deleting remote branch..." + gh pr close "$PR_NUM" --delete-branch 2>/dev/null || true + else + # No open PR — just delete the remote branch if it exists + echo "No open PR found, deleting remote branch..." + git push origin --delete "$BRANCH" 2>/dev/null || true + fi + echo "Cleanup complete." + timeout_ms: 120000 + +server: + port: 4321 + +observability: + dashboard_enabled: true + refresh_ms: 5000 + +stages: + initial_stage: investigate + + investigate: + type: agent + runner: claude-code + model: claude-sonnet-4-6 + max_turns: 8 + linear_state: In Progress + mcp_servers: + code-review-graph: + command: uvx + args: + - code-review-graph + - serve + on_complete: implement + + implement: + type: agent + runner: claude-code + model: claude-sonnet-4-6 + max_turns: 30 + mcp_servers: + code-review-graph: + command: uvx + args: + - code-review-graph + - serve + on_complete: review + + review: + type: agent + runner: claude-code + model: claude-opus-4-6 + max_turns: 15 + max_rework: 3 + linear_state: In Review + on_complete: merge + on_rework: implement + + merge: + type: agent + runner: claude-code + model: claude-sonnet-4-6 + max_turns: 5 + on_complete: done + + done: + type: terminal + linear_state: Done +--- + +You are running in headless/unattended mode. Do NOT use interactive skills, slash commands, or plan mode. Do not prompt for user input. Complete your work autonomously. + +Implement only what your task specifies. If you encounter missing functionality that another task covers, add a TODO comment rather than implementing it. Do not refactor surrounding code or add unsolicited improvements. + +Never hardcode localhost or 127.0.0.1. Use the $BASE_URL environment variable for all URL references. Set BASE_URL=localhost: during local development. + +# {{ issue.identifier }} — {{ issue.title }} + +You are working on Linear issue {{ issue.identifier }}. + +## Issue Description + +{{ issue.description }} + +{% if issue.labels.size > 0 %} +Labels: {{ issue.labels | join: ", " }} +{% endif %} + +{% if stageName == "investigate" %} +## Stage: Investigation +You are in the INVESTIGATE stage. Your job is to analyze the issue and create an implementation plan. + +{% if issue.state == "Resume" %} +## RESUME CONTEXT +This issue was previously blocked. Check the issue comments for a `## Resume Context` comment explaining what changed. Focus your investigation on the blocking reasons and what has been updated. +{% endif %} + +- Read the codebase to understand existing patterns and architecture +- Identify which files need to change and what the approach should be +- Post a comment on the Linear issue (via `gh`) with your investigation findings and proposed implementation plan +- Do NOT implement code, create branches, or open PRs in this stage — investigation only + +### Workpad (investigate) +After completing your investigation, create the workpad comment on this Linear issue. +**Preferred**: Write the workpad content to a local `workpad.md` file and call `sync_workpad` with `issue_id` and `file_path`. Save the returned `comment_id` for future updates. +**Fallback** (if `sync_workpad` is unavailable): +1. First, search for an existing workpad comment using `linear_graphql`: + ```graphql + query { issue(id: "{{ issue.id }}") { comments { nodes { id body } } } } + ``` + Look for a comment whose body starts with `## Workpad`. +2. If no workpad comment exists, create one using `commentCreate`. If one exists, update it using `commentUpdate`. +3. Use this template for the workpad body: + ``` + ## Workpad + **Environment**: :@ + + ### Plan + - [ ] Step 1 derived from issue description + - [ ] Step 2 ... + - [ ] Substep if needed + + ### Acceptance Criteria + - [ ] Criterion from issue requirements + - [ ] ... + + ### Validation + - `` + - `` + + ### Notes + - Investigation complete. Plan posted. + + ### Confusions + (Only add this section if something in the issue was genuinely unclear.) + ``` +4. Fill the Plan and Acceptance Criteria sections from your investigation findings. + +### Required: Structured Map + +After your prose findings, you MUST include a structured map section in the workpad with the following format: + +``` +### Files to Change +- path/to/file.ts:LINE_START-LINE_END — what needs to change and why + +### Read Order +1. path/to/primary.ts (primary change target) +2. path/to/types.ts (type definitions needed) +3. path/to/related.test.ts (test file to update) + +### Key Dependencies +- FunctionX is called from A, B, C +- InterfaceY is used in D, E +``` + +This structured map helps the implementation agent navigate the codebase efficiently without re-reading files you already explored. + +## Completion Signals +When you are done: +- If investigation is complete and workpad is posted: output `[STAGE_COMPLETE]` +- If the spec is ambiguous or contradictory: output `[STAGE_FAILED: spec]` with an explanation +- If you hit infrastructure issues (API limits, network errors): output `[STAGE_FAILED: infra]` with details +{% endif %} + +{% if stageName == "implement" %} +## Stage: Implementation +You are in the IMPLEMENT stage. An investigation was done in the previous stage — check issue comments for the plan. + +{% if reworkCount > 0 %} +## REWORK ATTEMPT {{ reworkCount }} +This is a rework attempt. Read ALL comments on this Linear issue starting with `## Review Findings`. These contain the specific findings you must fix. +- Fix ONLY the identified findings +- Do not modify code outside the affected files unless strictly necessary +- Do not reinterpret the spec +- If a finding conflicts with the spec, output `[STAGE_FAILED: spec]` with an explanation +{% endif %} + +## Implementation Steps + +1. Read any investigation notes from previous comments on this issue. +2. Create a feature branch from the issue's suggested branch name{% if issue.branch_name %} (`{{ issue.branch_name }}`){% endif %}, or use `{{ issue.identifier | downcase }}/`. +3. Implement the task per the issue description. +4. Write tests as needed. +5. Run all `# Verify:` commands from the spec. You are not done until every verify command exits 0. +6. Before creating the PR, capture structured tool output: + - Run `npx tsc --noEmit 2>&1` and include output in PR body under `## Tool Output > TypeScript` + - Run `npm test 2>&1` and include summary in PR body under `## Tool Output > Tests` + - Run `semgrep scan --config auto --json 2>&1` (if available) and include raw output in PR body under `## SAST Output` + - Do NOT filter or interpret SAST results — include them verbatim. +7. Commit your changes with message format: `feat({{ issue.identifier }}): `. +8. Open a PR targeting this repo (not its upstream fork parent) via `gh pr create --repo $(git remote get-url origin | sed "s|.*github.com/||;s|\.git$||")` with the issue description in the PR body. Include the Tool Output and SAST Output sections. +9. Link the PR to the Linear issue by including `{{ issue.identifier }}` in the PR title or body. + +### Workpad (implement) +Update the workpad comment at these milestones during implementation. +**Preferred**: Edit your local `workpad.md` file and call `sync_workpad` with `issue_id`, `file_path`, and `comment_id` (from the investigate stage). +**Fallback** (if `sync_workpad` is unavailable): +1. Search for the existing workpad comment (body starts with `## Workpad`) using `linear_graphql`: + ```graphql + query { issue(id: "{{ issue.id }}") { comments { nodes { id body } } } } + ``` +2. Update it using `commentUpdate` with the comment's `id`. +3. At each milestone, update the relevant sections: + - **After starting implementation**: Check off Plan items as you complete them. + - **After implementation is done**: Add a Notes entry (e.g., `- Implementation complete. PR # opened.`), update Validation with actual commands run. + - **After all tests pass**: Check off Acceptance Criteria items, add a Notes entry confirming validation. +4. Do NOT update the workpad after every small code change — only at the milestones above. +5. If no workpad comment exists (e.g., investigation stage was skipped), create one using the template from the investigate stage instructions. + +10. **If your changes are app-touching** (UI, API responses visible to users, frontend assets), capture a screenshot after validation passes and embed it in the workpad: + - Take a screenshot (e.g., `npx playwright screenshot` or `curl` the endpoint and save the response). + - Upload it using the fileUpload flow described in the **Media in Workpads** section. + - Add the image to the workpad comment under Notes: `![screenshot after validation](assetUrl)`. + - Skip this step for non-visual changes (library code, configs, internal refactors). + +## Completion Signals +When you are done: +- If all verify commands pass and PR is created: output `[STAGE_COMPLETE]` +- If you cannot resolve a verify failure after 3 attempts: output `[STAGE_FAILED: verify]` with the failing command and output +- If the spec is ambiguous or contradictory: output `[STAGE_FAILED: spec]` with an explanation +- If you hit infrastructure issues (API limits, network errors): output `[STAGE_FAILED: infra]` with details +{% endif %} + +{% if stageName == "review" %} +## Stage: Review +You are a review agent. Load and execute the /pipeline-review skill. + +The PR for this issue is on the current branch. The issue description contains the frozen spec. The PR body contains Tool Output and SAST Output sections from the implementation agent. + +If all findings are clean or only P3/theoretical: output `[STAGE_COMPLETE]` +If surviving P1/P2 findings exist: post them as a `## Review Findings` comment on the Linear issue, then output `[STAGE_FAILED: review]` with a one-line summary. +{% endif %} + +{% if stageName == "merge" %} +## Stage: Merge +You are in the MERGE stage. The PR has been reviewed and approved. +- Merge the PR via `gh pr merge --squash --delete-branch --repo $(git remote get-url origin | sed "s|.*github.com/||;s|\.git$||")` +- Verify the merge succeeded on the main branch +- Do NOT modify code in this stage + +### Workpad (merge) +After merging the PR, update the workpad comment one final time. +**Preferred**: Edit your local `workpad.md` file and call `sync_workpad` with `issue_id`, `file_path`, and `comment_id`. +**Fallback** (if `sync_workpad` is unavailable): +1. Search for the existing workpad comment (body starts with `## Workpad`) using `linear_graphql`: + ```graphql + query { issue(id: "{{ issue.id }}") { comments { nodes { id body } } } } + ``` +2. Update it using `commentUpdate`: + - Check off all remaining Plan and Acceptance Criteria items. + - Add a final Notes entry: `- PR merged. Issue complete.` + +- When you have successfully merged the PR, output the exact text `[STAGE_COMPLETE]` as the very last line of your final message. +{% endif %} + +## Scope Discipline + +- If your task requires a capability that doesn't exist in the codebase and isn't specified in the spec, stop and comment what's missing on the issue. Don't scaffold unspecced infrastructure. +- Tests must be runnable against $BASE_URL (no localhost assumptions in committed tests). + +## Workpad Rules + +You maintain a single persistent `## Workpad` comment on the Linear issue. This is your structured progress document. + +**Critical rules:** +- **Never create multiple workpad comments.** Always search for an existing comment with `## Workpad` in its body before creating a new one. +- **Update at milestones only** — plan finalized, implementation done, validation complete. Do NOT sync after every minor change. +- **Prefer `sync_workpad` over raw GraphQL.** Write your workpad content to a local `workpad.md` file, then call `sync_workpad` with `issue_id`, `file_path`, and optionally `comment_id` (returned from the first sync). This keeps the workpad body out of your conversation context and saves tokens. Fall back to `linear_graphql` only if `sync_workpad` is unavailable. +- **`linear_graphql` fallback patterns** (use only if `sync_workpad` is unavailable): + - Search comments: `query { issue(id: "") { comments { nodes { id body } } } }` + - Create comment: `mutation { commentCreate(input: { issueId: "", body: "" }) { comment { id } } }` + - Update comment: `mutation { commentUpdate(id: "", input: { body: "" }) { comment { id } } }` +- **Never use `__type` or `__schema` introspection queries** against the Linear API. Use the exact patterns above. + +## Media in Workpads (fileUpload) + +When you capture evidence (screenshots, recordings, logs) during implementation, embed them in the workpad using Linear's `fileUpload` API. This is a 3-step flow: + +**Step 1: Get upload URL** via `linear_graphql`: +```graphql +mutation($filename: String!, $contentType: String!, $size: Int!) { + fileUpload(filename: $filename, contentType: $contentType, size: $size, makePublic: true) { + success + uploadFile { uploadUrl assetUrl headers { key value } } + } +} +``` + +**Step 2: Upload file bytes** using `curl`: +```bash +# Build header flags from the returned headers array +curl -X PUT -H "Content-Type: " \ + -H ": " -H ": " \ + --data-binary @ "" +``` + +**Step 3: Embed in workpad** — add `![description](assetUrl)` to the workpad comment body (either via `sync_workpad` or `commentUpdate`). + +**Supported content types**: `image/png`, `image/jpeg`, `image/gif`, `video/mp4`, `application/pdf`. + +**When to capture media**: Only when evidence adds value — screenshots of UI changes, recordings of interaction flows, or error screenshots for debugging. Do not upload media for non-visual tasks (e.g., pure API or library changes). + +## Documentation Maintenance + +- If you add a new module, API endpoint, or significant abstraction, update the relevant docs/ file and the AGENTS.md Documentation Map entry. If no relevant doc exists, create one following the docs/ conventions (# Title, > Last updated header). +- If a docs/ file you reference during implementation is stale or missing, update/create it as part of your implementation. Include the update in the same PR as your code changes — never in a separate PR. +- If you make a non-obvious architectural decision during implementation, create a design doc in docs/design-docs/ following the ADR format (numbered, with Status line). Add it to the AGENTS.md design docs table. +- When you complete your implementation, update the > Last updated date on any docs/ file you modified. +- Do not update docs/generated/ files — those are auto-generated and will be overwritten. +- Commit doc updates in the same PR as code changes, not separately. diff --git a/pipeline-config/WORKFLOW-staged.md b/pipeline-config/WORKFLOW-staged.md new file mode 100644 index 00000000..ca7d7f43 --- /dev/null +++ b/pipeline-config/WORKFLOW-staged.md @@ -0,0 +1,409 @@ +--- +tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: 1fa66498be91 + active_states: + - Todo + - In Progress + - In Review + - Blocked + - Resume + terminal_states: + - Done + - Cancelled + +escalation_state: Blocked + +polling: + interval_ms: 30000 + +workspace: + root: ./workspaces + +agent: + max_concurrent_agents: 1 + max_turns: 30 + max_retry_backoff_ms: 300000 + +codex: + stall_timeout_ms: 1800000 + +runner: + kind: claude-code + model: claude-sonnet-4-5 + +hooks: + after_create: | + set -euo pipefail + if [ -z "${REPO_URL:-}" ]; then + echo "ERROR: REPO_URL environment variable is not set" >&2 + exit 1 + fi + echo "Cloning $REPO_URL into workspace..." + git clone --depth 1 "$REPO_URL" . + if [ -f package.json ]; then + if [ -f bun.lock ]; then + bun install --frozen-lockfile + elif [ -f pnpm-lock.yaml ]; then + pnpm install --frozen-lockfile + elif [ -f yarn.lock ]; then + yarn install --frozen-lockfile + else + npm install + fi + fi + # --- Build code graph (best-effort) --- + if command -v code-review-graph >/dev/null 2>&1; then + echo "Building code review graph..." + code-review-graph build --repo . || echo "WARNING: code-review-graph build failed, continuing without graph" >&2 + else + echo "WARNING: code-review-graph not installed, skipping graph build" >&2 + fi + echo "Workspace setup complete." + before_run: | + set -euo pipefail + echo "Syncing workspace with upstream..." + + # --- Git lock handling --- + wait_for_git_lock() { + local attempt=0 + while [ -f .git/index.lock ] && [ $attempt -lt 6 ]; do + echo "WARNING: .git/index.lock exists, waiting 5s (attempt $((attempt+1))/6)..." >&2 + sleep 5 + attempt=$((attempt+1)) + done + if [ -f .git/index.lock ]; then + echo "WARNING: .git/index.lock still exists after 30s, removing stale lock" >&2 + rm -f .git/index.lock + fi + } + + # --- Git fetch with retry --- + fetch_ok=false + for attempt in 1 2 3; do + wait_for_git_lock + if git fetch origin 2>/dev/null; then + fetch_ok=true + break + fi + echo "WARNING: git fetch failed (attempt $attempt/3), retrying in 2s..." >&2 + sleep 2 + done + if [ "$fetch_ok" = false ]; then + echo "WARNING: git fetch failed after 3 attempts, continuing with stale refs" >&2 + fi + + # --- Rebase (best-effort) --- + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then + echo "On $CURRENT_BRANCH — rebasing onto latest..." + wait_for_git_lock + if ! git rebase "origin/$CURRENT_BRANCH" 2>/dev/null; then + echo "WARNING: Rebase failed, aborting rebase" >&2 + git rebase --abort 2>/dev/null || true + fi + else + echo "On feature branch $CURRENT_BRANCH — skipping rebase, fetch only." + fi + echo "Workspace synced." + before_remove: | + set -uo pipefail + BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [ -z "$BRANCH" ] || [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "HEAD" ]; then + exit 0 + fi + echo "Cleaning up branch $BRANCH..." + # Close any open PR for this branch (also deletes the remote branch via --delete-branch) + PR_NUM=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$PR_NUM" ]; then + echo "Closing PR #$PR_NUM and deleting remote branch..." + gh pr close "$PR_NUM" --delete-branch 2>/dev/null || true + else + # No open PR — just delete the remote branch if it exists + echo "No open PR found, deleting remote branch..." + git push origin --delete "$BRANCH" 2>/dev/null || true + fi + echo "Cleanup complete." + timeout_ms: 120000 + +server: + port: 4321 + +observability: + dashboard_enabled: true + refresh_ms: 5000 + +stages: + initial_stage: investigate + + investigate: + type: agent + runner: claude-code + model: claude-sonnet-4-5 + max_turns: 8 + linear_state: In Progress + mcp_servers: + code-review-graph: + command: uvx + args: + - code-review-graph + - serve + on_complete: implement + + implement: + type: agent + runner: claude-code + model: claude-sonnet-4-5 + max_turns: 30 + mcp_servers: + code-review-graph: + command: uvx + args: + - code-review-graph + - serve + on_complete: review + + review: + type: agent + runner: claude-code + model: claude-opus-4-6 + max_turns: 15 + max_rework: 3 + linear_state: In Review + on_complete: merge + on_rework: implement + + merge: + type: agent + runner: claude-code + model: claude-sonnet-4-5 + max_turns: 5 + on_complete: done + + done: + type: terminal + linear_state: Done +--- + +You are running in headless/unattended mode. Do NOT use interactive skills, slash commands, or plan mode. Do not prompt for user input. Complete your work autonomously. + +Implement only what your task specifies. If you encounter missing functionality that another task covers, add a TODO comment rather than implementing it. Do not refactor surrounding code or add unsolicited improvements. + +Never hardcode localhost or 127.0.0.1. Use the $BASE_URL environment variable for all URL references. Set BASE_URL=localhost: during local development. + +# {{ issue.identifier }} — {{ issue.title }} + +You are working on Linear issue {{ issue.identifier }}. + +## Issue Description + +{{ issue.description }} + +{% if issue.labels.size > 0 %} +Labels: {{ issue.labels | join: ", " }} +{% endif %} + +{% if stageName == "investigate" %} +## Stage: Investigation +You are in the INVESTIGATE stage. Your job is to analyze the issue and create an implementation plan. + +{% if issue.state == "Resume" %} +## RESUME CONTEXT +This issue was previously blocked. Check the issue comments for a `## Resume Context` comment explaining what changed. Focus your investigation on the blocking reasons and what has been updated. +{% endif %} + +- Read the codebase to understand existing patterns and architecture +- Identify which files need to change and what the approach should be +- Post a comment on the Linear issue (via `gh`) with your investigation findings and proposed implementation plan +- Do NOT implement code, create branches, or open PRs in this stage — investigation only + +### Workpad (investigate) +After completing your investigation, create the workpad comment on this Linear issue. +**Preferred**: Write the workpad content to a local `workpad.md` file and call `sync_workpad` with `issue_id` and `file_path`. Save the returned `comment_id` for future updates. +**Fallback** (if `sync_workpad` is unavailable): +1. First, search for an existing workpad comment using `linear_graphql`: + ```graphql + query { issue(id: "{{ issue.id }}") { comments { nodes { id body } } } } + ``` + Look for a comment whose body starts with `## Workpad`. +2. If no workpad comment exists, create one using `commentCreate`. If one exists, update it using `commentUpdate`. +3. Use this template for the workpad body: + ``` + ## Workpad + **Environment**: :@ + + ### Plan + - [ ] Step 1 derived from issue description + - [ ] Step 2 ... + - [ ] Substep if needed + + ### Acceptance Criteria + - [ ] Criterion from issue requirements + - [ ] ... + + ### Validation + - `` + - `` + + ### Notes + - Investigation complete. Plan posted. + + ### Confusions + (Only add this section if something in the issue was genuinely unclear.) + ``` +4. Fill the Plan and Acceptance Criteria sections from your investigation findings. + +## Completion Signals +When you are done: +- If investigation is complete and workpad is posted: output `[STAGE_COMPLETE]` +- If the spec is ambiguous or contradictory: output `[STAGE_FAILED: spec]` with an explanation +- If you hit infrastructure issues (API limits, network errors): output `[STAGE_FAILED: infra]` with details +{% endif %} + +{% if stageName == "implement" %} +## Stage: Implementation +You are in the IMPLEMENT stage. An investigation was done in the previous stage — check issue comments for the plan. + +{% if reworkCount > 0 %} +## REWORK ATTEMPT {{ reworkCount }} +This is a rework attempt. Read ALL comments on this Linear issue starting with `## Review Findings`. These contain the specific findings you must fix. +- Fix ONLY the identified findings +- Do not modify code outside the affected files unless strictly necessary +- Do not reinterpret the spec +- If a finding conflicts with the spec, output `[STAGE_FAILED: spec]` with an explanation +{% endif %} + +## Implementation Steps + +1. Read any investigation notes from previous comments on this issue. +2. Create a feature branch from the issue's suggested branch name{% if issue.branch_name %} (`{{ issue.branch_name }}`){% endif %}, or use `{{ issue.identifier | downcase }}/`. +3. Implement the task per the issue description. +4. Write tests as needed. +5. Run all `# Verify:` commands from the spec. You are not done until every verify command exits 0. +6. Before creating the PR, capture structured tool output: + - Run `npx tsc --noEmit 2>&1` and include output in PR body under `## Tool Output > TypeScript` + - Run `npm test 2>&1` and include summary in PR body under `## Tool Output > Tests` + - Run `semgrep scan --config auto --json 2>&1` (if available) and include raw output in PR body under `## SAST Output` + - Do NOT filter or interpret SAST results — include them verbatim. +7. Commit your changes with message format: `feat({{ issue.identifier }}): `. +8. Open a PR targeting this repo (not its upstream fork parent) via `gh pr create --repo $(git remote get-url origin | sed "s|.*github.com/||;s|\.git$||")` with the issue description in the PR body. Include the Tool Output and SAST Output sections. +9. Link the PR to the Linear issue by including `{{ issue.identifier }}` in the PR title or body. + +### Workpad (implement) +Update the workpad comment at these milestones during implementation. +**Preferred**: Edit your local `workpad.md` file and call `sync_workpad` with `issue_id`, `file_path`, and `comment_id` (from the investigate stage). +**Fallback** (if `sync_workpad` is unavailable): +1. Search for the existing workpad comment (body starts with `## Workpad`) using `linear_graphql`: + ```graphql + query { issue(id: "{{ issue.id }}") { comments { nodes { id body } } } } + ``` +2. Update it using `commentUpdate` with the comment's `id`. +3. At each milestone, update the relevant sections: + - **After starting implementation**: Check off Plan items as you complete them. + - **After implementation is done**: Add a Notes entry (e.g., `- Implementation complete. PR # opened.`), update Validation with actual commands run. + - **After all tests pass**: Check off Acceptance Criteria items, add a Notes entry confirming validation. +4. Do NOT update the workpad after every small code change — only at the milestones above. +5. If no workpad comment exists (e.g., investigation stage was skipped), create one using the template from the investigate stage instructions. + +10. **If your changes are app-touching** (UI, API responses visible to users, frontend assets), capture a screenshot after validation passes and embed it in the workpad: + - Take a screenshot (e.g., `npx playwright screenshot` or `curl` the endpoint and save the response). + - Upload it using the fileUpload flow described in the **Media in Workpads** section. + - Add the image to the workpad comment under Notes: `![screenshot after validation](assetUrl)`. + - Skip this step for non-visual changes (library code, configs, internal refactors). + +## Completion Signals +When you are done: +- If all verify commands pass and PR is created: output `[STAGE_COMPLETE]` +- If you cannot resolve a verify failure after 3 attempts: output `[STAGE_FAILED: verify]` with the failing command and output +- If the spec is ambiguous or contradictory: output `[STAGE_FAILED: spec]` with an explanation +- If you hit infrastructure issues (API limits, network errors): output `[STAGE_FAILED: infra]` with details +{% endif %} + +{% if stageName == "review" %} +## Stage: Review +You are a review agent. Load and execute the /pipeline-review skill. + +The PR for this issue is on the current branch. The issue description contains the frozen spec. The PR body contains Tool Output and SAST Output sections from the implementation agent. + +If all findings are clean or only P3/theoretical: output `[STAGE_COMPLETE]` +If surviving P1/P2 findings exist: post them as a `## Review Findings` comment on the Linear issue, then output `[STAGE_FAILED: review]` with a one-line summary. +{% endif %} + +{% if stageName == "merge" %} +## Stage: Merge +You are in the MERGE stage. The PR has been reviewed and approved. +- Merge the PR via `gh pr merge --squash --delete-branch --repo $(git remote get-url origin | sed "s|.*github.com/||;s|\.git$||")` +- Verify the merge succeeded on the main branch +- Do NOT modify code in this stage + +### Workpad (merge) +After merging the PR, update the workpad comment one final time. +**Preferred**: Edit your local `workpad.md` file and call `sync_workpad` with `issue_id`, `file_path`, and `comment_id`. +**Fallback** (if `sync_workpad` is unavailable): +1. Search for the existing workpad comment (body starts with `## Workpad`) using `linear_graphql`: + ```graphql + query { issue(id: "{{ issue.id }}") { comments { nodes { id body } } } } + ``` +2. Update it using `commentUpdate`: + - Check off all remaining Plan and Acceptance Criteria items. + - Add a final Notes entry: `- PR merged. Issue complete.` + +- When you have successfully merged the PR, output the exact text `[STAGE_COMPLETE]` as the very last line of your final message. +{% endif %} + +## Scope Discipline + +- If your task requires a capability that doesn't exist in the codebase and isn't specified in the spec, stop and comment what's missing on the issue. Don't scaffold unspecced infrastructure. +- Tests must be runnable against $BASE_URL (no localhost assumptions in committed tests). + +## Workpad Rules + +You maintain a single persistent `## Workpad` comment on the Linear issue. This is your structured progress document. + +**Critical rules:** +- **Never create multiple workpad comments.** Always search for an existing comment with `## Workpad` in its body before creating a new one. +- **Update at milestones only** — plan finalized, implementation done, validation complete. Do NOT sync after every minor change. +- **Prefer `sync_workpad` over raw GraphQL.** Write your workpad content to a local `workpad.md` file, then call `sync_workpad` with `issue_id`, `file_path`, and optionally `comment_id` (returned from the first sync). This keeps the workpad body out of your conversation context and saves tokens. Fall back to `linear_graphql` only if `sync_workpad` is unavailable. +- **`linear_graphql` fallback patterns** (use only if `sync_workpad` is unavailable): + - Search comments: `query { issue(id: "") { comments { nodes { id body } } } }` + - Create comment: `mutation { commentCreate(input: { issueId: "", body: "" }) { comment { id } } }` + - Update comment: `mutation { commentUpdate(id: "", input: { body: "" }) { comment { id } } }` +- **Never use `__type` or `__schema` introspection queries** against the Linear API. Use the exact patterns above. + +## Media in Workpads (fileUpload) + +When you capture evidence (screenshots, recordings, logs) during implementation, embed them in the workpad using Linear's `fileUpload` API. This is a 3-step flow: + +**Step 1: Get upload URL** via `linear_graphql`: +```graphql +mutation($filename: String!, $contentType: String!, $size: Int!) { + fileUpload(filename: $filename, contentType: $contentType, size: $size, makePublic: true) { + success + uploadFile { uploadUrl assetUrl headers { key value } } + } +} +``` + +**Step 2: Upload file bytes** using `curl`: +```bash +# Build header flags from the returned headers array +curl -X PUT -H "Content-Type: " \ + -H ": " -H ": " \ + --data-binary @ "" +``` + +**Step 3: Embed in workpad** — add `![description](assetUrl)` to the workpad comment body (either via `sync_workpad` or `commentUpdate`). + +**Supported content types**: `image/png`, `image/jpeg`, `image/gif`, `video/mp4`, `application/pdf`. + +**When to capture media**: Only when evidence adds value — screenshots of UI changes, recordings of interaction flows, or error screenshots for debugging. Do not upload media for non-visual tasks (e.g., pure API or library changes). + +## Documentation Maintenance + +- If you add a new module, API endpoint, or significant abstraction, update the relevant docs/ file and the AGENTS.md Documentation Map entry. If no relevant doc exists, create one following the docs/ conventions (# Title, > Last updated header). +- If a docs/ file you reference during implementation is stale or missing, update/create it as part of your implementation. Include the update in the same PR as your code changes — never in a separate PR. +- If you make a non-obvious architectural decision during implementation, create a design doc in docs/design-docs/ following the ADR format (numbered, with Status line). Add it to the AGENTS.md design docs table. +- When you complete your implementation, update the > Last updated date on any docs/ file you modified. +- Do not update docs/generated/ files — those are auto-generated and will be overwritten. +- Commit doc updates in the same PR as code changes, not separately. diff --git a/pipeline-config/WORKFLOW.md b/pipeline-config/WORKFLOW.md new file mode 100644 index 00000000..5ecee134 --- /dev/null +++ b/pipeline-config/WORKFLOW.md @@ -0,0 +1,100 @@ +--- +tracker: + kind: linear + api_key: $LINEAR_API_KEY + project_slug: $LINEAR_PROJECT_SLUG + active_states: + - Todo + - In Progress + - In Review + - Rework + terminal_states: + - Done + - Cancelled + +polling: + interval_ms: 30000 + +workspace: + root: ./workspaces + +agent: + max_concurrent_agents: 3 + max_turns: 30 + max_retry_backoff_ms: 300000 + max_concurrent_agents_by_state: + in progress: 3 + in review: 2 + +runner: + kind: claude-code + model: claude-sonnet-4-5 + +hooks: + after_create: ./hooks/after-create.sh + before_run: ./hooks/before-run.sh + timeout_ms: 120000 + +server: + port: 4321 + +observability: + dashboard_enabled: true + refresh_ms: 5000 + +stages: + initial_stage: investigate + + investigate: + type: agent + runner: claude-code + model: claude-opus-4 + max_turns: 8 + prompt: prompts/investigate.liquid + on_complete: implement + + implement: + type: agent + runner: claude-code + model: claude-sonnet-4-5 + max_turns: 30 + prompt: prompts/implement.liquid + on_complete: review + + review: + type: gate + gate_type: ensemble + max_rework: 3 + reviewers: + - runner: codex + model: gpt-5.3-codex + role: adversarial-reviewer + prompt: prompts/review-adversarial.liquid + - runner: gemini + model: gemini-3-pro + role: security-reviewer + prompt: prompts/review-security.liquid + on_approve: merge + on_rework: implement + + merge: + type: agent + runner: claude-code + model: claude-sonnet-4-5 + max_turns: 5 + prompt: prompts/merge.liquid + on_complete: done + + done: + type: terminal +--- + +{% render 'prompts/global.liquid' %} + +You are working on Linear issue {{ issue.identifier }}: {{ issue.title }}. + +{{ issue.description }} + +{% if issue.labels.size > 0 %} +Labels: {{ issue.labels | join: ", " }} +{% endif %} diff --git a/pipeline-config/design-refs/token-report-v2/EfficiencyScorecard.jsx b/pipeline-config/design-refs/token-report-v2/EfficiencyScorecard.jsx new file mode 100644 index 00000000..303d7628 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/EfficiencyScorecard.jsx @@ -0,0 +1,84 @@ +/** + * Section 2: Efficiency Scorecard + * Converted from token-report.mjs:1688–1723 (6 metric rows with sparklines). + * + * Props: + * - scorecard: { cache_efficiency, output_ratio, wasted_context, tokens_per_turn, first_pass_rate, failure_rate } + * Each metric has .current (number). failure_rate.current is an object of stage→rate. + * - series: { cacheEff, outputRatio, wastedCtx, tokPerTurn, firstPass, failureRate } — arrays of numbers for sparklines + */ +import React from "react"; +import { fmtNum, Sparkline } from "./chartUtils.jsx"; + +function round(n, decimals = 0) { + const f = 10 ** decimals; + return Math.round(n * f) / f; +} + +function mean(arr) { + if (!arr || arr.length === 0) return 0; + return arr.reduce((s, v) => s + v, 0) / arr.length; +} + +export default function EfficiencyScorecard({ scorecard, series }) { + const sc = scorecard ?? {}; + const s = series ?? {}; + + const failureRateCurrent = sc.failure_rate?.current ?? {}; + const rates = Object.values(failureRateCurrent); + const avgFailRate = rates.length > 0 ? `${round(mean(rates), 1)}%` : "0%"; + + const rows = [ + { + name: "Cache Efficiency", + value: `${round(sc.cache_efficiency?.current ?? 0, 1)}%`, + sparkline: s.cacheEff, + stroke: "#58a6ff", + }, + { + name: "Output Ratio", + value: `${round(sc.output_ratio?.current ?? 0, 1)}%`, + sparkline: s.outputRatio, + stroke: "#3fb950", + }, + { + name: "Wasted Context", + value: `${round(sc.wasted_context?.current ?? 0, 1)}%`, + sparkline: s.wastedCtx, + stroke: "#d29922", + }, + { + name: "Tokens / Turn", + value: fmtNum(sc.tokens_per_turn?.current ?? 0), + sparkline: s.tokPerTurn, + stroke: "#bc8cff", + }, + { + name: "First-Pass Rate", + value: `${round(sc.first_pass_rate?.current ?? 0, 1)}%`, + sparkline: s.firstPass, + stroke: "#56d364", + }, + { + name: "Failure Rate (all stages)", + value: avgFailRate, + sparkline: s.failureRate, + stroke: "#f85149", + }, + ]; + + return ( +
+

Efficiency Scorecard

+ {rows.map((row) => ( +
+ {row.name} + {row.value} + + + +
+ ))} +
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/ExecutiveSummary.jsx b/pipeline-config/design-refs/token-report-v2/ExecutiveSummary.jsx new file mode 100644 index 00000000..e6ab58c6 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/ExecutiveSummary.jsx @@ -0,0 +1,65 @@ +/** + * Section 1: Executive Summary + * Converted from token-report.mjs:1664–1686 (4-card KPI grid). + * + * Props: + * - totalTokens: number + * - tokensDelta: number|null (WoW % change) + * - tokensPerIssueMedian: number + * - tokensPerIssueMean: number + * - tokPerIssueWow: number|null + * - uniqueIssues: number + * - cacheHitRate: number (percentage) + * - cacheWow: number|null + */ +import React from "react"; +import { fmtNum, WowBadge } from "./chartUtils.jsx"; + +function round(n, decimals = 0) { + const f = 10 ** decimals; + return Math.round(n * f) / f; +} + +export default function ExecutiveSummary({ + totalTokens, + tokensDelta, + tokensPerIssueMedian, + tokensPerIssueMean, + tokPerIssueWow, + uniqueIssues, + cacheHitRate, + cacheWow, +}) { + return ( +
+

Executive Summary

+
+
+
Total Tokens
+
{fmtNum(totalTokens)}
+
+ +
+
+
+
Tokens / Issue (median)
+
{fmtNum(tokensPerIssueMedian)}
+
+ mean: {fmtNum(tokensPerIssueMean)} +
+
+
+
Issues Processed
+
{fmtNum(uniqueIssues)}
+
+
+
Cache Hit Rate
+
{round(cacheHitRate, 1)}%
+
+ +
+
+
+
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/IssueLeaderboard.jsx b/pipeline-config/design-refs/token-report-v2/IssueLeaderboard.jsx new file mode 100644 index 00000000..0ba69c4e --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/IssueLeaderboard.jsx @@ -0,0 +1,48 @@ +/** + * Section 6: Issue Leaderboard + * Converted from token-report.mjs:1768–1781 (top-25 table). + * + * Props: + * - leaderboard: array of { identifier: string, title: string, tokens: number } + * Pre-sorted by tokens descending. Only the first 25 are rendered. + */ +import React from "react"; +import { fmtNum } from "./chartUtils.jsx"; + +export default function IssueLeaderboard({ leaderboard }) { + const items = Array.isArray(leaderboard) ? leaderboard.slice(0, 25) : []; + + return ( +
+

Issue Leaderboard

+ + + + + + + + + + + {items.map((item, i) => ( + + + + + + + ))} + +
#IssueTitleTokens
{i + 1} + + {item.identifier} + + {item.title}{fmtNum(item.tokens)}
+
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/OutlierAnalysis.jsx b/pipeline-config/design-refs/token-report-v2/OutlierAnalysis.jsx new file mode 100644 index 00000000..ac2843ab --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/OutlierAnalysis.jsx @@ -0,0 +1,66 @@ +/** + * Section 5: Outlier Analysis + * Converted from token-report.mjs:1751–1766. + * + * Props: + * - outliers: array of { + * issue_identifier: string, + * issue_title: string, + * total_tokens: number, + * z_score: number, + * hypothesis?: string, + * parent?: { identifier: string, complexity: string, task_count: number } + * } + */ +import React from "react"; +import { fmtNum } from "./chartUtils.jsx"; + +export default function OutlierAnalysis({ outliers }) { + const items = Array.isArray(outliers) ? outliers : []; + + if (items.length === 0) { + return ( +
+

Outlier Analysis

+

+ No outliers detected (>2σ threshold) +

+
+ ); + } + + return ( +
+

Outlier Analysis

+ {items.map((o, i) => ( +
+
+ + {o.issue_identifier} + + {" "}— {o.issue_title} — {fmtNum(o.total_tokens)} tokens (z={o.z_score}) +
+
+ {o.hypothesis ?? "No hypothesis available"} +
+ {o.parent && ( +
+ Parent: {o.parent.identifier} ({o.parent.complexity}, {o.parent.task_count}{" "} + tasks) +
+ )} +
+ ))} +
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/PerProductBreakdown.jsx b/pipeline-config/design-refs/token-report-v2/PerProductBreakdown.jsx new file mode 100644 index 00000000..6006e665 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/PerProductBreakdown.jsx @@ -0,0 +1,62 @@ +/** + * Section 8: Per-Product Breakdown + * Converted from token-report.mjs:1801–1819. + * + * Props: + * - perProduct: object keyed by product name → { total_tokens, total_stages, unique_issues } + */ +import React from "react"; +import { fmtNum } from "./chartUtils.jsx"; + +function round(n, decimals = 0) { + const f = 10 ** decimals; + return Math.round(n * f) / f; +} + +export default function PerProductBreakdown({ perProduct }) { + const products = perProduct ?? {}; + const totalTokens = + Object.values(products).reduce((s, p) => s + (p.total_tokens ?? 0), 0) || 1; + + const sorted = Object.entries(products).sort( + (a, b) => (b[1].total_tokens ?? 0) - (a[1].total_tokens ?? 0), + ); + + return ( +
+

Per-Product Breakdown

+ + + + + + + + + + + + {sorted.map(([name, data]) => { + const pct = round((data.total_tokens / totalTokens) * 100, 1); + return ( + + + + + + + + ); + })} + +
ProductTokensStagesIssuesShare
{name}{fmtNum(data.total_tokens)}{data.total_stages}{data.unique_issues} +
+ {" "} + {pct}% +
+
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/PerStageTrend.jsx b/pipeline-config/design-refs/token-report-v2/PerStageTrend.jsx new file mode 100644 index 00000000..fe84f94a --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/PerStageTrend.jsx @@ -0,0 +1,53 @@ +/** + * Section 3: Per-Stage Utilization Trend + * Converted from token-report.mjs:1725–1742. + * + * Props: + * - perStageTrend: object keyed by stage name, each with { daily_avg: {date: value}, config_changes: [] } + * - configChanges: array of { date: string } + * - inflections: array of { stage, direction, pct_change, avg_7d, avg_30d, attributions?: [] } + */ +import React from "react"; +import { fmtNum, MultiLineChart } from "./chartUtils.jsx"; + +function escText(str) { + if (str == null) return ""; + return String(str); +} + +export default function PerStageTrend({ perStageTrend, configChanges, inflections }) { + const trend = perStageTrend ?? {}; + const infl = Array.isArray(inflections) ? inflections : []; + + return ( +
+

Per-Stage Utilization Trend

+
+ + {infl.length > 0 && + infl.map((inf, i) => ( +
+
+ ⚡ Inflection: {escText(inf.stage)} — {escText(inf.direction)} {inf.pct_change}% +
+
+ 7d avg: {fmtNum(inf.avg_7d)} · 30d avg: {fmtNum(inf.avg_30d)} + {inf.attributions?.length > 0 + ? ` · ${inf.attributions.map((a) => escText(a.description)).join("; ")}` + : ""} +
+
+ ))} +
+
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/PerTicketCostTrend.jsx b/pipeline-config/design-refs/token-report-v2/PerTicketCostTrend.jsx new file mode 100644 index 00000000..dac5aeea --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/PerTicketCostTrend.jsx @@ -0,0 +1,39 @@ +/** + * Section 4: Per-Ticket Cost Trend + * Converted from token-report.mjs:1744–1749. + * + * Props: + * - perTicket: { median: number, mean: number, ticket_count: number } + * - perTicketSeries: number[] (rolling median values for sparkline) + */ +import React from "react"; +import { fmtNum, Sparkline } from "./chartUtils.jsx"; + +export default function PerTicketCostTrend({ perTicket, perTicketSeries }) { + const pt = perTicket ?? {}; + + return ( +
+

Per-Ticket Cost Trend

+
+
+ Rolling median tokens per ticket · median: {fmtNum(pt.median)} · mean:{" "} + {fmtNum(pt.mean)} · {pt.ticket_count} tickets +
+ +
+
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/ReportFooter.jsx b/pipeline-config/design-refs/token-report-v2/ReportFooter.jsx new file mode 100644 index 00000000..4a52bfef --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/ReportFooter.jsx @@ -0,0 +1,15 @@ +/** + * Section 9: Report Footer + * Converted from token-report.mjs:1821. + * + * No props required — static footer with generation metadata. + */ +import React from "react"; + +export default function ReportFooter() { + return ( +
+ Symphony Token Report · Self-contained · Generated by token-report.mjs · SYMPH-131 +
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/ReportHeader.jsx b/pipeline-config/design-refs/token-report-v2/ReportHeader.jsx new file mode 100644 index 00000000..d280ed14 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/ReportHeader.jsx @@ -0,0 +1,27 @@ +/** + * Section 0: Report Header + * Converted from token-report.mjs:1661–1663 (renderHtml header area). + * + * Props: + * - today: string (formatted date key) + * - recordCount: number + * - dataSpanDays: number + */ +import React from "react"; +import { fmtNum } from "./chartUtils.jsx"; + +export default function ReportHeader({ today, recordCount, dataSpanDays }) { + return ( +
+

+ Symphony Token Report +

+

+ Generated {today} · {fmtNum(recordCount)} records · {dataSpanDays} day span +

+
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/StageEfficiency.jsx b/pipeline-config/design-refs/token-report-v2/StageEfficiency.jsx new file mode 100644 index 00000000..a70b4228 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/StageEfficiency.jsx @@ -0,0 +1,43 @@ +/** + * Section 7: Stage Efficiency + * Converted from token-report.mjs:1783–1799. + * + * Props: + * - perStageSpend: object keyed by stage name → { total_tokens, count, completed, failed } + * - stageSparklines: object keyed by stage name → number[] (30d daily values) + */ +import React from "react"; +import { fmtNum, Sparkline } from "./chartUtils.jsx"; + +export default function StageEfficiency({ perStageSpend, stageSparklines }) { + const spend = perStageSpend ?? {}; + const sparklines = stageSparklines ?? {}; + + return ( +
+

Stage Efficiency

+ {Object.entries(spend).map(([stage, data]) => ( +
+
+ {stage} + + {fmtNum(data.total_tokens)} tokens · {data.count} runs · {data.completed}{" "} + ok · {data.failed} fail + +
+
+ + 30d trend: + + +
+
+ ))} +
+ ); +} diff --git a/pipeline-config/design-refs/token-report-v2/chartUtils.jsx b/pipeline-config/design-refs/token-report-v2/chartUtils.jsx new file mode 100644 index 00000000..fc6c3b17 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/chartUtils.jsx @@ -0,0 +1,250 @@ +/** + * Shared SVG chart utilities for the Token Report v2 design reference. + * Converted from sparklineSvg() and multiLineSvg() in ops/token-report.mjs. + * + * These are pure presentation helpers — no data fetching or analysis logic. + */ +import React from "react"; + +/** + * Round a number to the specified decimal places. + */ +function round(n, decimals = 0) { + const f = 10 ** decimals; + return Math.round(n * f) / f; +} + +/** + * Format a number with thousands separators. + */ +export function fmtNum(n) { + if (n == null || Number.isNaN(n)) return "—"; + return Number(n).toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +/** + * Render a WoW delta badge as a colored span. + */ +export function WowBadge({ delta }) { + if (delta == null) { + return ; + } + const sign = delta > 0 ? "+" : ""; + const color = delta > 0 ? "#f85149" : delta < 0 ? "#3fb950" : "#8b949e"; + return ( + + {sign} + {delta}% WoW + + ); +} + +/** + * Inline SVG sparkline from an array of numeric values. + * Converted from sparklineSvg() in token-report.mjs:1238–1259. + */ +export function Sparkline({ + values, + width = 120, + height = 30, + stroke = "#58a6ff", + strokeWidth = 1.5, +}) { + if (!values || values.length < 2) { + return ( + + ); + } + const minY = Math.min(...values); + const maxY = Math.max(...values); + const rangeY = maxY - minY || 1; + const points = values + .map((v, i) => { + const x = (i / (values.length - 1)) * width; + const y = height - ((v - minY) / rangeY) * (height - 4) - 2; + return `${round(x, 1)},${round(y, 1)}`; + }) + .join(" "); + + return ( + + + + ); +} + +/** + * Multi-line SVG chart for per-stage trends. + * Converted from multiLineSvg() in token-report.mjs:1264–1355. + */ +export function MultiLineChart({ + stageData, + configChanges, + width = 600, + height = 200, +}) { + const colors = [ + "#58a6ff", + "#3fb950", + "#d29922", + "#f85149", + "#bc8cff", + "#79c0ff", + "#56d364", + "#e3b341", + ]; + + const allDates = new Set(); + for (const stage of Object.keys(stageData)) { + for (const d of Object.keys(stageData[stage].daily_avg ?? {})) { + allDates.add(d); + } + } + const sortedDates = [...allDates].sort(); + + if (sortedDates.length < 2) { + return ( + + + Insufficient data for trend chart + + + ); + } + + const stages = Object.keys(stageData); + const allVals = []; + for (const stage of stages) { + const avg = stageData[stage].daily_avg ?? {}; + for (const d of sortedDates) { + if (avg[d] != null) allVals.push(avg[d]); + } + } + + const minY = Math.min(...allVals, 0); + const maxY = Math.max(...allVals, 1); + const rangeY = maxY - minY || 1; + const padL = 50; + const padR = 10; + const padT = 10; + const padB = 25; + const chartW = width - padL - padR; + const chartH = height - padT - padB; + + // Grid lines + const gridLines = []; + for (let i = 0; i <= 4; i++) { + const y = padT + (chartH / 4) * i; + const val = maxY - (rangeY / 4) * i; + gridLines.push( + + + + {fmtNum(val)} + + , + ); + } + + // Config change markers + const markers = (configChanges ?? []).map((cc) => { + const idx = sortedDates.indexOf(cc.date); + if (idx < 0) return null; + const x = padL + (idx / (sortedDates.length - 1)) * chartW; + return ( + + + + ⚙ + + + ); + }); + + // Stage polylines + const lines = stages.map((stage, si) => { + const avg = stageData[stage].daily_avg ?? {}; + const pts = []; + for (const d of sortedDates) { + if (avg[d] != null) { + const x = + padL + (sortedDates.indexOf(d) / (sortedDates.length - 1)) * chartW; + const y = padT + chartH - ((avg[d] - minY) / rangeY) * chartH; + pts.push(`${round(x, 1)},${round(y, 1)}`); + } + } + if (pts.length <= 1) return null; + const color = colors[si % colors.length]; + return ( + + ); + }); + + // Legend + const legend = stages.map((stage, si) => { + const x = padL + si * 100; + const color = colors[si % colors.length]; + return ( + + + + {stage} + + + ); + }); + + return ( + + {gridLines} + {markers} + {lines} + {legend} + + ); +} diff --git a/pipeline-config/design-refs/token-report-v2/index.jsx b/pipeline-config/design-refs/token-report-v2/index.jsx new file mode 100644 index 00000000..06865ec6 --- /dev/null +++ b/pipeline-config/design-refs/token-report-v2/index.jsx @@ -0,0 +1,171 @@ +/** + * Barrel export for Token Report v2 design reference components. + * Composes all 10 sections in report order. + * + * Usage: + * import TokenReport from './index.jsx'; + * + * + * The `analysis` prop matches the computeAnalysis() return shape from token-report.mjs. + * The `computed` prop carries pre-computed derived values (sparkline series, leaderboard, etc.) + * that are generated outside the React layer — all analysis logic stays in token-report.mjs. + */ +import React from "react"; + +export { default as ReportHeader } from "./ReportHeader.jsx"; +export { default as ExecutiveSummary } from "./ExecutiveSummary.jsx"; +export { default as EfficiencyScorecard } from "./EfficiencyScorecard.jsx"; +export { default as PerStageTrend } from "./PerStageTrend.jsx"; +export { default as PerTicketCostTrend } from "./PerTicketCostTrend.jsx"; +export { default as OutlierAnalysis } from "./OutlierAnalysis.jsx"; +export { default as IssueLeaderboard } from "./IssueLeaderboard.jsx"; +export { default as StageEfficiency } from "./StageEfficiency.jsx"; +export { default as PerProductBreakdown } from "./PerProductBreakdown.jsx"; +export { default as ReportFooter } from "./ReportFooter.jsx"; +export { fmtNum, WowBadge, Sparkline, MultiLineChart } from "./chartUtils.jsx"; + +// Re-export the CSS design tokens as a style object for use with inline styles +// or injection into a