diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..8a06b99 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,156 @@ +/** + * Standalone, so the published package lints on its own: every rule is + * written out here rather than extended from a shared config. + * + * @type {import('eslint').Linter.Config} + */ +module.exports = { + root: true, + ignorePatterns: ['node_modules', 'dist', 'build', 'desktop/release'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', '@eslint-community/eslint-comments'], + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + project: [ + './tsconfig.json', + './desktop/tsconfig.json', + './scripts/tsconfig.json', + ], + tsconfigRootDir: __dirname, + ecmaFeatures: { jsx: true }, + }, + env: { + node: true, + es6: true, + }, + overrides: [ + { + // The app's client runs in the browser: Node's globals and modules + // aren't there (the Node types the tests need would hide it otherwise). + files: ['src/app/client/**/*.{ts,tsx}'], + env: { browser: true }, + rules: { + 'no-restricted-globals': [ + 'error', + ...[ + 'process', + 'Buffer', + '__dirname', + '__filename', + 'require', + 'global', + ].map((name) => ({ + name, + message: 'The client runs in the browser.', + })), + ], + // The typescript-eslint version, for `allowTypeImports`: the client + // may share the server's types, never its code. + 'no-restricted-imports': 'off', + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + 'fs', + 'path', + 'os', + 'crypto', + 'child_process', + 'http', + 'https', + 'net', + 'stream', + 'url', + 'util', + 'events', + ].map((name) => ({ + name, + message: 'The client runs in the browser.', + })), + patterns: [ + { group: ['node:*'], message: 'The client runs in the browser.' }, + { + group: ['@repo/*'], + message: 'Lambda is standalone: no @repo/* imports.', + }, + { + group: [ + '../server', + '../server/*', + '../devProxy', + '../../mothership', + '../../mothership/*', + '../../runner', + '../../runner/*', + '../../events', + '../../events/*', + '../../commands/*', + '../../pod-store', + '../../pod-store/*', + ], + allowTypeImports: true, + message: + 'Server code runs in Node, not the browser: `import type` only.', + }, + ], + }, + ], + }, + }, + ], + rules: { + '@eslint-community/eslint-comments/require-description': 'warn', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/consistent-type-assertions': [ + 'warn', + { assertionStyle: 'never' }, + ], + 'arrow-body-style': ['warn', 'as-needed'], + curly: ['warn', 'all'], + 'no-else-return': ['warn', { allowElseIf: false }], + 'prefer-destructuring': ['warn', { array: true, object: true }], + 'no-restricted-syntax': [ + 'warn', + { + selector: 'VariableDeclaration[kind="let"]', + message: + 'Use const instead of let. Prefer functional patterns (ternary operators, early returns, or extracted functions) over let for better code clarity and immutability.', + }, + { + selector: "UnaryExpression[operator='!'][argument.operator='!']", + message: 'Use Boolean(value) instead of !!value for type coercion.', + }, + { + selector: + "CallExpression[callee.property.name='push']:not([callee.object.name='router']):not([callee.object.name='history']):not([callee.object.property.name='router']):not([callee.object.property.name='history'])", + message: + 'Array.push() mutates arrays. Use spread operator [...arr, item] or concat() instead.', + }, + ], + 'no-unneeded-ternary': ['warn', { defaultAssignment: false }], + 'padding-line-between-statements': [ + 'warn', + { blankLine: 'always', prev: '*', next: 'return' }, + { blankLine: 'always', prev: '*', next: 'throw' }, + { blankLine: 'always', prev: '*', next: 'break' }, + { blankLine: 'always', prev: '*', next: 'continue' }, + { blankLine: 'always', prev: '*', next: 'if' }, + { blankLine: 'always', prev: '*', next: 'for' }, + { blankLine: 'always', prev: '*', next: 'while' }, + { blankLine: 'always', prev: '*', next: 'try' }, + { blankLine: 'always', prev: '*', next: 'switch' }, + ], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@repo/*'], + message: 'Lambda is standalone: no @repo/* imports.', + }, + ], + }, + ], + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..324998c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + # Only the Mac app's build needs Electron itself. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + - run: npm ci --no-audit --no-fund + - run: npm run build + - run: npm run lint + - run: npm run typecheck + # Some tests start a real Chromium, and some a real tmux server. + - run: npx playwright install --with-deps chromium + - run: sudo apt-get update && sudo apt-get install -y tmux + - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2d7e779 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,181 @@ +name: Release + +# On every push to main: publish to npm and attach the Mac app to a GitHub +# Release, but only when package.json's version isn't on npm yet. The `build` +# job installs, tests and builds with no secrets beyond the packaging step's +# Apple ones, and hands on only its artifacts. Both `build` and `publish` run +# in the `release` environment, which waits for its reviewer's Approve: the +# Apple secrets are `release` environment secrets, and GitHub gives those only +# to jobs that name it. `publish` runs nothing from the repository: npm +# trusted publishing (OIDC) is tied to this file and that environment, and +# there is no npm token anywhere. +# +# So a release asks for two Approves, one after the other: `build`'s (for the +# Apple secrets), then `publish`'s (for the id-token). That is deliberate. One +# job would hand an id-token that can publish to npm to the install, test and +# build steps, which run other people's code; approving only the first leaves +# the app built and nothing published, and the run can be approved later. +# +# Actions are pinned to commits (the comment names the tag). + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + version: + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.check.outputs.publish }} + version: ${{ steps.check.outputs.version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + - name: Is this version on npm yet? + id: check + run: npx --yes jiti@2.6.1 scripts/shouldPublish.ts + + build: + needs: version + if: needs.version.outputs.publish == 'true' + runs-on: macos-15 + environment: release + permissions: + contents: read + outputs: + name: ${{ steps.pack.outputs.name }} + tarball: ${{ steps.pack.outputs.tarball }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + - run: npm ci --no-audit --no-fund + - run: npm run build + # Some tests start a real Chromium, and some a real tmux server. + - run: npx playwright install --with-deps chromium + - run: brew install tmux + - run: npm test + + # What npm publishes is this tarball, exactly as tested. + - name: Pack the npm package + id: pack + run: | + TARBALL="$(npm pack --ignore-scripts | tail -n 1)" + echo "tarball=${TARBALL}" >> "${GITHUB_OUTPUT}" + echo "name=$(node -p "require('./package.json').name")" >> "${GITHUB_OUTPUT}" + + # The Mac app builds before anything is published: once npm has a + # version it can't be published again, so a failed build after it + # would leave that version with no Release. + - run: npm run build:desktop + - run: bash desktop/tools/fetch-tools.sh + - run: npm ci --prefix desktop/kokoro --omit=dev --no-audit --no-fund + # Native modules rebuilt for Electron here, with no secrets: the + # packaging step then runs no install scripts and downloads nothing. + - run: PATH="${PWD}/node_modules/.bin:${PATH}" electron-builder install-app-deps --platform darwin --arch arm64 + + - name: Package the Mac app (signed and notarised only with the Apple secrets) + env: + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_P12 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + run: | + SIGNING=() + if [ -n "${CSC_LINK}" ] && [ -n "${APPLE_API_KEY_BASE64}" ]; then + export APPLE_API_KEY="${RUNNER_TEMP}/apple-api-key.p8" + trap 'rm -f "${APPLE_API_KEY}"' EXIT + echo "${APPLE_API_KEY_BASE64}" | base64 --decode > "${APPLE_API_KEY}" + SIGNING=(-c.mac.notarize=true) + else + # package-mac.sh ad-hoc signs the bundle when CSC_LINK is unset. + echo "No Apple secrets: building without a Developer ID (ad-hoc signed)." + unset CSC_LINK CSC_KEY_PASSWORD + fi + PATH="${PWD}/node_modules/.bin:${PATH}" bash desktop/tools/package-mac.sh \ + -c.npmRebuild=false \ + -c.publish.provider=github \ + -c.publish.owner="${OWNER}" \ + -c.publish.repo="${REPO}" \ + ${SIGNING[@]+"${SIGNING[@]}"} + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-package + path: ${{ steps.pack.outputs.tarball }} + if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mac-app + path: | + desktop/release/*.dmg + desktop/release/*.zip + desktop/release/*.blockmap + desktop/release/latest-mac.yml + if-no-files-found: error + + publish: + needs: [version, build] + if: needs.version.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write + env: + VERSION: ${{ needs.version.outputs.version }} + NAME: ${{ needs.build.outputs.name }} + TARBALL: ${{ needs.build.outputs.tarball }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + SERVER_URL: ${{ github.server_url }} + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + # Trusted publishing needs npm 11.5.1 or later. + - run: npm install -g npm@^11.5.1 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: npm-package + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: mac-app + path: mac-app + + # A draft first: nobody (and no updater) sees it until npm has the + # version. Each step skips what a previous attempt already did, so a + # failed run can be rerun. + - name: Draft Release v${{ env.VERSION }} with the app and latest-mac.yml + run: | + if gh release view "v${VERSION}" > /dev/null 2>&1; then + gh release upload "v${VERSION}" mac-app/* --clobber + exit 0 + fi + gh release create "v${VERSION}" --draft \ + --target "${SHA}" \ + --title "Lambda ${VERSION}" \ + --notes "Install the Mac app: ${SERVER_URL}/${GH_REPO}#install-the-mac-app" \ + mac-app/* + + - name: Publish to npm + run: | + if [ -n "$(npm view "${NAME}@${VERSION}" version 2> /dev/null)" ]; then + echo "${NAME}@${VERSION} is on npm already." + exit 0 + fi + npm publish "${TARBALL}" --provenance --access public --ignore-scripts + + - name: Publish Release v${{ env.VERSION }} + run: gh release edit "v${VERSION}" --draft=false diff --git a/.gitignore b/.gitignore index 63bccd0..e8f8655 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ -.DS_Store node_modules/ dist/ -.next/ -test-results/ -playwright-report/ -.env* -*.local.json -.shutdown_confirmed -.ralph-stop-log +.eslintcache +# The Mac app's build output and what build:mac fetches. +desktop/release/ +desktop/tools/out/ +desktop/tools/.work/ +desktop/kokoro/node_modules/ diff --git a/README.md b/README.md index 7a0a40a..3de7570 100644 --- a/README.md +++ b/README.md @@ -1,301 +1,68 @@ -# λ - -Three AI agents collaborate via tmux to autonomously build full-stack features — optimizing for how long they can work without you, not how fast they work while you watch. - -**38+ hours unattended. 15 full-stack tickets. One session.** - -https://github.com/user-attachments/assets/3181c3c6-8f70-4e4b-bb92-e467e0ab174f - -Lambda is not a framework. It's an architecture — **division of labor across agents** and **vision-driven validation**. You don't need to clone this repo. Read the philosophy, take what's useful, build your own. - -Most multi-agent setups optimize for parallelism — more agents working simultaneously. The bottleneck they don't address is babysitting. If you're monitoring agents, restarting stuck sessions, and course-correcting drift, you haven't automated the work — you've just given yourself a different job. Lambda prioritizes unattended operation: a system you can walk away from and come back to a PR. - -An orchestrator that can't code. A developer that can't ship. A tester that can't write production code. E2E screenshots and traces as the feedback loop. These ideas are portable to any stack, any agent framework, any LLM. The implementation here is what worked for me — a reference, not a prescription. - -### Proven at scale - -In a single unattended session, Lambda completed 15 full-stack tickets over 38+ hours. One ticket was blocked due to an infrastructure issue — not an agent failure. The session was supposed to end at bedtime — I thought I'd stopped it. Woke up, found it still running and still completing tickets, and decided to just let it go. - -That number didn't come for free. Early runs maxed out at 4 hours before agents drifted, got stuck, or compounded errors into unrecoverable states. It took weeks of tuning — refining quality gates, tightening agent instructions, improving trace analysis, pushing failure-prone workflows into the CLI — before the system crossed a reliability threshold where sessions stopped dying and just kept going. - -**Expect the same ramp-up.** Every codebase has different failure modes. The patterns in this repo (agent separation, quality gates, vision-based validation) are portable, but dialing them in for your project takes iteration. You'll likely start closer to 4 hours than 38. That's normal — the architecture is designed so that each fix compounds, and once you cross the threshold, the runtime extends dramatically. - -## Contents - -- [How it works](#how-it-works) -- [Quick start](#quick-start) -- [Design philosophy](#design-philosophy) -- [Scaling](#scaling) -- [Architecture](#architecture) -- [CLI](#cli) -- [Transitional architecture](#transitional-architecture) -- [Make it yours](#make-it-yours) -- [Project structure](#project-structure) -- [Security considerations](#security-considerations) -- [Requirements](#requirements) - -## How it works - -``` - ┌──────────────┐ - │ James │ - │ orchestrator │ - └──────┬───────┘ - picks task, delegates, - enforces quality gates - ┌───────┴───────┐ - │ │ - ┌────▼────┐ ┌─────▼────┐ - │ John │ │ Sam │ - │ builds │ │ tests │ - └──┬───┬──┘ └─────┬────┘ - │ │ │ - ┌──────────▼┐ └─ fix loop ─┘ - │ Boualam │ - │ cleanup │ ┌──────────┐ - └───────────┘ │ Adrian │ - │ waits │ - └──────────┘ -``` - -James delegates to John and Sam, gating on each step. John builds, self-reviews, and verifies against the ticket. Sam runs E2E tests and analyzes traces. If tests fail, Sam sends diagnostics back and John fixes — the loop repeats until tests pass. James ships the PR. Adrian handles blocking waits on Haiku so the expensive model isn't burning tokens on idle time. - -The quality gates are customizable — they live in `manager/CLAUDE.md`. Make them stricter, add domain-specific checks, whatever your project needs. The point is having gates at all, not these specific ones. - -## Quick start - -> **You don't need any of this to use Lambda's ideas.** The [design philosophy](#design-philosophy) section is the actual value. If you want to build multi-agent workflows in your own stack, start there. The quick start below is for running the reference implementation. - -Prerequisites: - -- [tmux](https://github.com/tmux/tmux) -- Node.js 20+ -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) -- [iTerm2](https://iterm2.com/) recommended — Lambda auto-detects it and opens tmux windows as native tabs - -```bash -git clone https://github.com/adriancarriger/lambda.git -cd lambda -./init.sh # one-time: install deps, build CLI, install Playwright (runs npm link — may need sudo) -lambda start # launch agents and connect -# switch to James's tab and tell him: "Take the next ticket" -``` - -`lambda start` creates a tmux session with three Claude agents and a Next.js dev server, then connects you to it. - -> **Note:** `lambda ship` defaults to dry-run mode (`DEMO_MODE` in `src/config.ts`). Pass `--no-dry-run` to actually push and create PRs, or set `DEMO_MODE = false` in the config. - -In iTerm2, each agent window (James, John, Sam, scratchPad, dev-server) becomes a native tab — no tmux key bindings to learn. In other terminals, use `Ctrl+B w` to switch between tmux windows. - -Disconnected? Run `lambda connect` to reattach. - -### The demo - -The repo ships with a sample Next.js todo app and three demo tasks. Each task has a failing E2E test. The agents make them pass. - -| Task | Feature | Spec | -| ------ | ------------------------------------- | ------------------- | -| DEMO-1 | Delete button on each todo | `tickets/DEMO-1.md` | -| DEMO-2 | Completed checkbox with strikethrough | `tickets/DEMO-2.md` | -| DEMO-3 | Filter bar (All / Active / Completed) | `tickets/DEMO-3.md` | - -Each task has a spec in `tickets/` and a failing test in `e2e/todos.spec.ts`. The agents read the spec, implement the feature, and iterate until the test passes. The demo tasks are intentionally simple — the architecture shines more on complex, multi-file features where the separation of concerns and quality gates actually matter. - -## Design philosophy - -### Do what the human would do - -Most tickets go through the full cycle autonomously. Lambda systematizes the loop a human developer already runs — read the ticket, tell Claude what to build, watch the output, run tests, read the failures, send Claude back to fix — then replaces the human with James. - -The weakest link is flaky E2E tests. When a test passes sometimes and fails others, agents burn cycles chasing phantom failures. Pushing more of that complexity into the CLI (better trace analysis, smarter retry logic) keeps shrinking that gap. - -### Separation of concerns - -John writes the code. Sam evaluates it. Separate agents avoid the trap where the agent that wrote the code convinces itself the code is correct. John also runs his own code review and ticket verification — James doesn't read code, he just gates on John's self-reported grades before handing off to Sam. - -Give a single Claude Code instance a long workflow and it will drift — skip steps, stop following the process, or halt early. James solves this by doing less, not more. He can't write code, can't run shell commands, can't even read the codebase. All he does is delegate, enforce quality gates, and ship when the work passes. He enforces the sequence every time because that's all he does. - -### Reliability over parallelism - -If one agent needs intervention every 20 minutes, running eight of them means you're interrupted every 2.5 minutes. That's not parallelism — it's a full-time monitoring job. Lambda optimizes for unattended runtime: one team that can complete tickets end-to-end without human input. - -The agents are separated for **context isolation**, and context isolation is what makes reliability possible. Sam focuses on testing without John's implementation noise in his context. John focuses on building without Sam's trace analysis cluttering things up. James just enforces the process. Each agent is better at his job because he only sees what's relevant to his job. Focused agents are more reliable agents, and reliability is what makes unattended operation work. - -This also makes Lambda token-efficient. You're not paying for three agents thinking simultaneously — you're paying for one agent working while two wait. Adrian handles the blocking waits on Haiku, so the expensive model isn't burning tokens on repeated `sleep` calls. - -### Tmux as context engineering - -Tmux isn't just a communication channel — it's how each agent gets access to the right context at the right time. John can read any tmux window in the session. If the dev server is throwing compile errors, he checks `lambda tmux dev-server`. If the backend is misbehaving, he reads the logs from whatever window they're in. A React compile error, a backend crash, and a failing test are often the same bug seen from three different angles — John can triangulate, the same way a human developer would glance at different terminal tabs. - -James, by contrast, can only see John's and Sam's windows. He doesn't need dev-server logs or shell access — he just needs to know whether his agents are done and whether they succeeded. The permission hooks enforce this. Different agents, different context, different capabilities. - -### Vision-driven validation - -If you want autonomous front-end development, you need visual feedback. There's no way around it. A text-only agent can't tell you the modal is rendering behind the overlay. - -E2E tests are how Lambda structures that feedback. The agent writes a test, runs it, reads the trace (screenshots, DOM, console), and iterates. This is significantly faster than exploratory QA — the test gives the agent a repeatable, targeted feedback loop. Tests can be throwaway — written fresh for each feature, used purely as a verification mechanism, then discarded. - -### Code-first, vision-second - -Browser MCP tools put the agent behind the steering wheel — look at a screenshot, decide what to click, look at the next screenshot, decide what to type. Every interaction burns vision tokens. A ten-step form fill costs ten rounds of screenshot reasoning. - -Lambda inverts this. The agent writes code that drives the browser. Running that code costs nothing — Playwright executes it deterministically, as many times as needed, for negligible tokens. Vision only enters when the code doesn't work. A failing test produces a trace with screenshots, DOM state, and console logs. The agent reads the trace, fixes the code, and runs again. - -The expensive part (vision-based reasoning) only happens on failure. The cheap part (executing code) handles all the repetition. Write once, run free. - -### Deterministic CLI - -The `lambda` CLI pushes complexity out of the agent's context and into deterministic code. Agents are good at reasoning and writing code. They're less good at reliably executing multi-step shell workflows or remembering to follow a twelve-step process every time. Shipping a PR is `lambda ship`, not "create branch, stage files, commit, push, open PR, and don't forget the ticket ID." - -Whatever repetitive workflow your agents keep getting wrong, push it into a CLI command. The agent calls one command; the CLI handles the rest. - -## Scaling - -Once one team works reliably, you scale by spinning up a second team on a different feature set. Two teams, two backlogs, two machines. Each team pulls from different task filters in your ticketing system. This is how human teams scale — independent pods working on separate features, not more people on one task. - -The todo app in `sample/` is a demo target. Point Lambda at any codebase — replace the sample app, write different tickets, configure different tests. The architecture is the same; only the inputs change. - -This scaling model works in principle but has rough edges in practice. Context windows fill up on long sessions. Flaky tests burn cycles. The architecture is sound; the implementation is still maturing. - -## Architecture - -### Agent roles - -| Agent | Role | Can do | Cannot do | -| ------- | -------------- | ------------------------------------------------------------------- | ----------------------------------------------------------- | -| James | Orchestration | Manage tasks, delegate to John/Sam, enforce quality gates, ship PRs | Write code, run shell commands, read the codebase | -| John | Implementation | Write code, build, self-review, verify ticket completeness | Push, commit, ship, talk to Sam directly | -| Sam | Validation | Run E2E tests, analyze Playwright traces | Write application code | -| Boualam | Cleanup | Format, lint, type check — iterates until all pass | Architectural decisions, adding eslint-disable, refactoring | -| Adrian | Waiting | Block until a pane finishes, burn zero tokens on expensive models | Anything else — only has the `await_pane` tool | - -### Communication - -Agents coordinate through tmux, not Claude Code native Teams. James sends commands and messages via the `lambda tmux` CLI: - -```bash -lambda tmux john 'Implement DEMO-1. See tickets/DEMO-1.md for the spec.' -lambda tmux sam 'Run E2E tests: lambda e2e todos.spec.ts' -lambda tmux john # Read John's output -``` - -The `lambda tmux` command auto-cycles the target pane to "accept edits" mode before sending, so agents never get stuck on permission prompts. - -### Key patterns - -- **Trace analysis**: Sam extracts screenshots, DOM state, and console logs from Playwright traces to produce structured failure diagnostics. -- **Adrian subagent**: Long blocking waits (waiting for John to finish, waiting for E2E to complete) are delegated to a Haiku-powered Adrian subagent. Zero tokens burned on the expensive model while waiting. -- **Boualam subagent**: John delegates format/lint/typecheck busywork to the Boualam subagent that iterates until all checks pass. Keeps John's context clean and focused on implementation. Won't make architectural decisions — escalates back to John after 3 failed attempts. -- **Permission lockdown**: James can only run `lambda` CLI commands — enforced by a permission hook (`manager/scripts/permission-hook.cjs`). John and Sam are blocked from committing or pushing by their own hook (`dev-permission-hook.cjs`). -- **Ralph stop-hook**: Prevents James from exiting while tasks remain in the queue (`manager/scripts/ralph-stop-hook.sh`). - -## CLI - -```bash -# Session -lambda start # Create tmux session, launch agents, connect -lambda start --no-agents # Create session without launching agents -lambda stop # Kill the tmux session -lambda connect # Reattach to an existing session - -# Tmux -lambda tmux [command] # Send command or read output from a pane -lambda await [--long] # Block until pane is idle - -# Tasks -lambda tasks list # Show all tasks from TASKS.md -lambda tasks next # Get next available task -lambda tasks show # Read ticket spec from tickets/.md -lambda tasks done # Mark task as completed -lambda tasks block # Mark task as blocked (--reason) - -# Testing -lambda e2e [args...] # Run Playwright E2E tests -lambda trace diagnose-all # Debug all Playwright traces -lambda trace summary # Summarize trace results -lambda trace errors # Extract errors from a trace - -# Shipping -lambda ship # Branch, commit, push, open PR - -# Agent management -lambda agent compact -w # Compact agent context -lambda agent clear --self # Clear context with handoff message -lambda agent whoami # Print current tmux window name -lambda agent context -w # Check token usage -``` - -## Transitional architecture - -Lambda uses tmux because Claude Code Teams was too experimental when this was built (Feb 2026). Tmux is reliable, simple, and proven over many autonomous sessions. - -Lambda isn't building a moat. If Claude Code Teams fully replicates the needed feature set — reliable multi-agent coordination, permission isolation, context separation — the tmux layer gets replaced. That's a good outcome. The ideas (agent separation, quality gates, vision-based validation) are independent of the transport layer. - -As Claude Code Teams matures and ships pieces of this functionality, Lambda will adopt them incrementally. The architecture is designed so that swap would be a localized change — agent definitions, CLI commands, and permission model stay the same. - -## Make it yours - -This repo is a reference implementation, not the point. Lambda is the architecture — agent separation, quality gates, vision-based validation. If cloning and modifying works for you, great. If you'd rather build your own version from scratch using these ideas, that's probably even better. - -- **Use the workflow, not the repo.** Lambda mimics how human teams already work — tickets, PRs, code review, QA handoff, fix loops. If your team follows this flow, Lambda is a drop-in. If not, take the ideas that fit and leave the rest. -- **Point your own app at it.** Replace `sample/` with your project, update `playwright.config.ts` to point at your dev server, and write your tasks in `TASKS.md`. -- **Add your own tasks.** Write specs in `tickets/`, add entries to `TASKS.md`, and write failing E2E tests. The agents will pick them up. -- **Plug in your ticket system.** The `lambda tasks` commands are a thin interface over `TASKS.md` and `tickets/`. Swap them out to pull from Linear, Jira, GitHub Issues, or whatever you use. There's a commented-out Linear integration stub in `src/commands/tasks/index.ts` as a starting point. -- **Customize agent behavior.** The root `CLAUDE.md` contains instructions for John and Sam. `manager/CLAUDE.md` controls James's orchestration loop. -- **Extend the CLI.** TypeScript + Commander.js in `src/commands/`. Add new commands by creating a directory with an `index.ts` that exports a registration function. -- **Adjust permissions.** James's permissions: `manager/.claude/settings.json` + `manager/scripts/permission-hook.cjs`. John/Sam: `.claude/settings.json` + `.claude/agents/dev-permission-hook.cjs`. - -## Project structure - -``` -lambda/ -├── src/commands/ # CLI command implementations -├── manager/ -│ ├── CLAUDE.md # James orchestration instructions -│ └── scripts/ # Permission hooks, stop-hook -├── sample/ # Next.js todo app (the demo target) -├── e2e/ # Playwright E2E tests -├── tickets/ # Task specs (DEMO-1.md, DEMO-2.md, DEMO-3.md) -├── CLAUDE.md # John/Sam agent instructions -├── TASKS.md # Task queue -├── init.sh # One-time setup (install deps, build CLI) -└── playwright.config.ts # E2E test configuration -``` - -## Security considerations - -Lambda's permission model is enforced by prompt instructions and hooks, not by hard sandboxing. You should understand the limitations before running it unattended. - -**The core issue: tmux is a shared surface.** James can send arbitrary text to John and Sam via `lambda tmux`, including granting permission prompts (`lambda tmux john --raw 'y'`). This is by design — James needs to unblock John when legitimate permission requests come up. But it means John can request escalations and James will typically approve them. - -**What the hooks enforce:** - -- John and Sam are blocked from `git push`, `git commit`, and `lambda ship` by a `PreToolUse` hook (`dev-permission-hook.cjs`). This is a real gate — the hook runs before the command and denies it programmatically. -- James is restricted to `lambda` CLI commands by his own hook (`manager/scripts/permission-hook.cjs`). He can't run arbitrary shell commands. - -**What the hooks don't enforce:** - -- James is explicitly set up to grant John's permission requests (`lambda tmux john --raw 'y'` is in James's command reference). You could instruct James to always deny, but there's no guarantee a prompt-level constraint holds under all conditions. -- John has full filesystem access within his Claude Code session. The hooks block specific commands, not filesystem operations. -- Nothing prevents prompt injection via task specs or code content that agents read during work. - -**My approach:** I run Lambda on a dedicated machine with limited access to sensitive systems. The permission hooks catch accidental mistakes (John trying to push, James running shell commands), and the prompt instructions handle the rest. This is an acceptable tradeoff for my goals. - -**If you need stronger isolation**, wait for Claude Code native Teams, which will provide process-level separation between agents. The README's [transitional architecture section](#transitional-architecture) explains why I haven't migrated yet. The architecture is designed so that migration would be a localized change. - -**Practical recommendations:** - -- Run on a dedicated machine or VM, not your primary development machine -- Don't store secrets (API keys, credentials) in the project directory -- Review PRs before merging -- Audit the permission hooks if you modify them — they're the only hard gates - -## Requirements - -- macOS or Linux -- Node.js 20+ -- tmux -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) +# Lambda + +Run a team of coding agents on your own project: planning pods that turn an +idea into tickets, building pods that turn tickets into shipped code, and the +commands they share. Lambda's agents run on Claude Code, with your own Claude +account. + +## Install + +Two ways, the same Lambda: + +- **The Mac app** (Apple silicon): everything in one download, including the + tools agents need. See [Install the Mac app](#install-the-mac-app). +- **From npm**, on a Mac or Linux with Node 20 or later, tmux and git: + + ```sh + npm i -g @adriancarriger/lambda + lambda start + lambda open + ``` + +Either way, the first time you open Lambda it walks you through three steps +in your browser (or the app's window): connect Claude, pick or start a +project, and choose where tickets live. + +## Install the Mac app + +1. Download `Lambda--arm64.dmg` from the + [latest Release](https://github.com/adriancarriger/lambda/releases/latest). +2. Open it and drag **Lambda** to **Applications**. +3. Open Lambda from Applications. + +**The first time you open it**, macOS stops it with a warning that it +"cannot be opened because Apple cannot check it for malicious software" (or +that it "cannot verify the developer"). That's because these first releases +aren't signed by Apple yet. Lambda's source is public, in this repository. +To open it anyway, once: + +1. Click **Done** (or **OK**) on the warning. +2. Open **System Settings → Privacy & Security**. +3. Scroll to **Security**. Next to the line about Lambda, click + **Open Anyway**, then confirm with your password or Touch ID. +4. Click **Open** when macOS asks once more. + +After that it opens like any other app. Until releases are signed, Lambda +can't update itself: when a new version is out, its menu-bar menu offers +**Download version X.Y.Z**, which opens that Release here. + +Closing the window leaves Lambda in the menu bar (λ), and this Mac keeps +working for your agents. **Quit Lambda** from that menu stops everything. + +## Commands + +`lambda --help` lists them all. A few to start with: + +| Command | What it does | +| -------------------------------- | --------------------------------------------------------------- | +| `lambda start` | Start Lambda, this computer's runner and the project's services | +| `lambda open` | Open Lambda in your browser, signed in | +| `lambda connect claude` | Sign in to Claude for the agents (lasts a year) | +| `lambda connect linear` / `jira` | Use Linear or Jira for this project's tickets | +| `lambda init` | Write `lambda.json` for the project in this folder | +| `lambda doctor` | Show where each command comes from, and what's set up | +| `lambda upgrade` | Update to the newest version | +| `lambda stop --all` | Stop the project, the runner and Lambda | ## License diff --git a/bin/lambda b/bin/lambda index b8d87c4..b63f1f1 100755 --- a/bin/lambda +++ b/bin/lambda @@ -1,11 +1,13 @@ #!/usr/bin/env node -const path = require('path'); -const fs = require('fs'); +const { run } = require('../dist/cli.js'); -const cliPath = path.join(__dirname, '..', 'dist', 'cli.js'); -if (!fs.existsSync(cliPath)) { - console.error('Error: CLI not built yet. Run ./init.sh first.'); - process.exit(1); -} +// Deviation: this calls run(), which builds the program with the exported +// createProgram(), rather than calling createProgram() itself. The dispatch +// has to see argv before commander parses it: an unknown command must reach +// the project CLI with flags commander would otherwise reject or consume, and +// a drop-in has to be loaded (asynchronously, through jiti or import()) and +// registered before parsing, in place of the built-in it replaces. -require(cliPath); +run(process.argv).then((code) => { + process.exitCode = code; +}); diff --git a/commands/pod/checkpoint-complete.md b/commands/pod/checkpoint-complete.md new file mode 100644 index 0000000..937fb38 --- /dev/null +++ b/commands/pod/checkpoint-complete.md @@ -0,0 +1,12 @@ +--- +description: (planning pods) {owner} closed a checkpoint. Sent to the planning manager. +argument-hint: [m:]: Checkpoint N is complete… +--- + +# $ARGUMENTS + +{owner} closed this checkpoint. Ignore it if this `[m:…]` id was already handled (re-delivery after a restart). + +Run `lambda pod status $POD_ID` and continue the pipeline from your CLAUDE.md: CP1 → phase 2, CP2 → phase 3, CP3 → **UX review** (`/pod-phase ux`, phase stays 3), CP4 (the UX review) → phase 4, CP5 → **Finish**. + +A lone ticket's CP3 says how it was closed — **kept one ticket** or **accepted the split** (also the `split:` line of `lambda pod status`). Pass that on to the dev, word for word, with the next phase. diff --git a/commands/pod/freeform.md b/commands/pod/freeform.md new file mode 100644 index 0000000..8359207 --- /dev/null +++ b/commands/pod/freeform.md @@ -0,0 +1,29 @@ +--- +description: (planning pods) A FREEFORM pod's opening message — a conversation with {owner}, no pipeline. Reply via lambda pod reply. +argument-hint: '[m:]: ' +--- + +# Freeform: $ARGUMENTS + +This pod is **freeform**: {owner} wants to talk this through with you, not run the planning pipeline. There are no phases, steps or checkpoints until structured planning starts — then the manager starts the pipeline, and Shape begins from this conversation (same session, nothing lost). Every new Lambda can start here: {owner} just starts typing, and it's your call what this is. + +- **Every reply is a turn in the conversation:** `lambda pod reply --ask ""`, so it lands in {owner}'s inbox as "Your turn". **Write it for scanning** (your dev instructions, "How your messages look" — read its example): a bold one-line answer first, then short labelled lists; never one long paragraph. End with an open door when there's more to settle. Whatever you say last in a turn lands as {owner}'s turn (even an `--fyi`), so make it the answer, not a status line. **When the conversation is finished** (the work's done, nothing left to settle), end with the offer to close: "Anything else, or shall I close this Lambda?" If the answer is close, reply `--fyi` with one line on where things were left, then run `lambda pod done $POD_ID` as your very last action — it ends this session. Long research? `lambda pod reply --fyi "checking "` first, then the answer with `--ask`. +- **Markdown renders**: bold, short lists, small tables, `code`. Several lines → one quoted argument (`"$(cat <<'EOF' … EOF)"`). It is also read aloud, so the words must stand on their own. +- **`[Image: /path]` lines** are screenshots {owner} attached — open each with Read before replying. +- **A prototype {owner} can try in the inbox**: when you design in Claude Design, pick a slug for it, and after each publish photograph it (with `D = $LAMBDA_PODS_ROOT/$POD_ID/designs/`, a `D/brief.json` written as the ux phase writes one and a `D/marks.json` (`[]` when nothing is called out): `lambda design walk Prototype.dc.html --brief D/brief.json --stills --marks D/marks.json --out-dir D`; with no brief, skip the walk and let `snapshot` draw a poster; several boards: `designs///.png`), run `lambda design snapshot $LAMBDA_PODS_ROOT/$POD_ID/designs/`, and put the line it prints (`[Prototype: v]`) **on its own line** in your `lambda pod reply`. Every board is one window, 1440×900 (390×844 for a phone): a long page scrolls inside the design system's app frame, never a taller board — `lambda design snapshot` refuses any other size. **Start each board from the closest reference screen**, as `/design-screen` does: when the project's design system is on the canvas, copy its `ds//screens/.dc.html` and change only what the design changes, with people and money from `window.Sample`; `lambda design walk` warns about any colour that is not a token. The inbox shows it as a card with **Try it**. +- **A decision** always comes with **Context, Problem, Options and a Recommendation** (your dev instructions, "Asking {owner}") — never a bare "A or B?". +- **Look things up for real** — the code on the default branch, the tickets (`lambda tickets get`, `lambda tickets list`), `lambda knowledge list` ({owner}'s past judgements). Read only: run no tests or suites (the only things a pod starts are `lambda shot` and `lambda design render`, `walk` and `snapshot`). +- **Name the pod** once you know what it's about: `lambda pod rename $POD_ID "<3–6 plain words>"` (a name {owner} chose is kept). +- **When it's a planning job, propose structured planning** — don't wait to be asked. It is one when {owner} wants an existing epic, ticket or set of tickets planned, or an idea is settled enough to write tickets for. Pick how, then propose it once: + + - an existing epic → `lambda pod propose-structure $POD_ID --epic --why ""` + - one existing ticket → `--ticket ` (split or one ticket is chosen at checkpoint 3) + - several unrelated tickets → `--batch ,,` + - a new idea → no flag + + The `--why` is read aloud on the pitch: say why it's ready _now_, in {owner}'s terms. Then say in a short `--ask` reply that it's ready to start. It comes up in the inbox with a **Start planning** button; only {owner} starts it (`lambda pod structure` is {owner}'s — never run it). If the talk carries on instead, carry on; propose again (it replaces the last) if the shape changes. Just talking something over is not a planning job — don't push. + +- **A new Lambda for part of this** — when {owner} agrees something should be its own Lambda (its own epic, a side topic), start it yourself; never ask for it to be pasted anywhere: `lambda pod new --spinoff --text ""`. It starts as a freeform chat, linked to this one. Then say in your reply that it's started and what it will pick up. +- **Don't change tickets** unless {owner} asks you to in so many words, and then record it: `lambda pod decide "" --quote ""`. +- What {owner} decides: `lambda pod decide "" --quote ""` — it carries into the structured flow. +- Later messages arrive as `/human-chat`; the same rules hold while `lambda pod status $POD_ID` says `mode: FREEFORM`. diff --git a/commands/pod/human-async.md b/commands/pod/human-async.md new file mode 100644 index 0000000..19e084e --- /dev/null +++ b/commands/pod/human-async.md @@ -0,0 +1,32 @@ +--- +description: (planning pods) {owner} wrote on an inbox item and has already moved on. Do the work, then reply once on that item. +argument-hint: [m:]: +--- + +# {owner} wrote on item: $ARGUMENTS + +{owner} wrote on item `` (a decision question or a checkpoint) and has **already moved on**. The item is out of the inbox until you reply on it — and it comes back the moment you do, with your reply shown (and read aloud) first. So: + +- **Reply once, after the work**, on this item: `lambda pod reply --qid --fyi ""`. Say what you did or what the answer is, in plain words, naming what changed by the numbers on the card ("decision 2", "ticket 3") — never ticket ids. +- **A message with several parts gets a reply split part by part**: write `[{"quote": "", "answer": ""}, …]` to a file and run `lambda pod reply --qid --fyi --reply-to --parts-file `. It shows and reads question 1, answer 1, question 2, answer 2…; a part that became a decision shows its status and a link automatically (when the decision's `--quote` matches the part's). Every part of the message gets an entry. +- **Progress notes** while you work are optional and never bring the item back: `lambda pod reply --qid --progress ""`. They are not your reply — always finish with the real one. + +Steps: + +1. If this `[m:…]` id was already handled earlier in this conversation, it is a re-delivery after a restart — stop here. +2. Read the item (`lambda pod question show `) and the message. Keep its `[m:…]` id — you need it if this leads to new decisions. +3. Work out what {owner} said — and act on each part: + - **An answer** to a decision question (an option picked, or a decision) → record it with the exact words: `lambda pod decide "" --qid --quote ""`; if it reveals a general preference, add it to **Later statements** in `intent.md`; apply it to the tickets; run the **ripple check** and the **stale check** (your dev instructions); `lambda pod resolve `; then reply saying what you took the answer to be and what changed ("Going with B. Updated tickets 2 and 3 …"). + - **A question** ("what happens if…", "why…", "explain 3") → answer it in 2–4 plain sentences. A decision question **stays open** — say so at the end ("The question is still open."). Never treat a question as an answer. + - **A change** on a checkpoint ("2: use the survey date instead") → record it as {owner}'s: `lambda pod decide "" --quote ""`; apply it; run the ripple check; reply with what changed. + - **"refresh your question"** → withdraw it (`lambda pod question withdraw --reason "refresh requested"`) and add a corrected one (same `--from-…` origin as the original). Reply saying so. + - **"1: give me options" / "not sure"** on a checkpoint decision → a full question about it (below). Don't undo the decision until it is answered. +4. **If it needs decisions from {owner} — even one — they go in the queue as real questions, never decided in the chat.** For each: `lambda pod question add --from-message --quote "" --reply "" [--blocks-checkpoint]`, then every section, cold read, commit (your dev instructions). The `--from-message` link is what shows "How we got here" with {owner}'s own words and a link back; one message can lead to several (they are numbered "decision 1 of 3"). Then reply on this item: "That needs N decisions from you — they're next in your inbox." This item then steps aside until they're answered; **after the last is answered and applied, reply on this item again** with what changed — that reply is what brings it back. +5. If the message is genuinely ambiguous, don't guess — ask in your reply (`--ask` is not needed; the reply itself brings the item back). +6. If this was the last open question of a phase whose work is done, post that phase's checkpoint. + +## Checkpoint items + +A message on a checkpoint ends with **the numbered list of decisions exactly as the card shows them** — "2" means item 2 of that list. If a number doesn't match the list, don't guess — say which numbers exist. **Never resolve a checkpoint yourself**: it stays open until {owner} says "checkpoint complete". + +**The UX review (CP4)** also numbers its screens. "screen 2: …" means screen 2 on the card (`lambda pod question show ` lists them in order). A change to a screen means updating every ticket that builds it — its `## UX` sketch and its acceptance criteria — then editing CP4's screens file in place and running `lambda pod checkpoint refresh $POD_ID --cp 4` (no re-post), and replying with which tickets changed. A bare number on CP4 is ambiguous (decision or screen?) — ask. diff --git a/commands/pod/human-chat.md b/commands/pod/human-chat.md new file mode 100644 index 0000000..41deb34 --- /dev/null +++ b/commands/pod/human-chat.md @@ -0,0 +1,24 @@ +--- +description: (planning pods) {owner} is talking to you directly in the pod thread (phase 1 shaping, or dropping in). Reply via lambda pod reply. +argument-hint: [m:]: +--- + +# From {owner}, directly: $ARGUMENTS + +{owner} typed this into your pod's thread. Your answers reach {owner} only through `lambda pod reply` — never your terminal. + +- Ignore it if this `[m:…]` id was already handled (re-delivery after a restart). +- Reply with `lambda pod reply --ask ""` **only if you need an answer** — that puts it in {owner}'s inbox as "Your turn". Everything else (acknowledgements, "got it, carrying on", status) is `lambda pod reply --fyi ""`: it stays in the thread and never pulls {owner} back in. If the message was "Done — carry on" / "nothing more to add", do not ask anything — at most one `--fyi`. +- Lead with the answer. A short answer stays short (1–3 sentences); anything longer takes the scannable shape in your dev instructions, "How your messages look" — a bold answer line, then short labelled lists, never one long paragraph. If you need to look something up, reply `--fyi "checking "` first, then reply again with the result. +- Your reply renders as markdown (your dev instructions, "How your messages look"); `[Image: /path]` lines in the message are images {owner} attached — open them with Read. +- **Telling {owner} about a prototype** (a design question, a re-design, "show me a prototype"): put the line `lambda design snapshot` printed (`[Prototype: v]`) **on its own line** in your `lambda pod reply`. The inbox turns it into a card {owner} can try; a claude.ai link alone can't be tried there. A freeform pod designs as `/freeform` says. +- **Asking {owner} to decide?** Context, Problem, Options (lettered) and a Recommendation — your dev instructions, "Asking {owner}". Never a bare "A or B?". +- If {owner} made a decision, record it: `lambda pod decide "" --quote ""` and mention it in your reply ("Recorded: …"). +- **An open checkpoint is a live doc.** When something {owner} says changes what's on its card — a proposed ticket, a triage row, a screen, the plan, the changes list — edit that part in the card's own file (the one you passed to `lambda pod checkpoint post`: `proposal.json`, your plan / changes / screens / judgements file) **in place, before you reply. Don't re-post.** Then run `lambda pod checkpoint refresh $POD_ID --cp `: it re-reads the file with the same checks as posting and updates the card. Record the decision and reply with what changed ("Updated the card: ticket 3 now …"). If the refresh says your edit **didn't pass its checks**, fix the file and refresh again. An open _question's_ sections change with `lambda pod question edit`. +- **{owner} names a ticket's lane** ("2 is a quick fix", "make 3 an investigation"): record it with `lambda pod decide`, then put it where it will stick. A ticket on an open split proposal: set its `lane` in `proposal.json` (`"quick-fix"` / `"investigation"`; remove it for a normal build) — the card shows the tag. A batch's triage row: its `lane` (and `laneWas`) in the plan file. A ticket that already exists: `lambda tickets update --label ` now, and bring the ticket into that lane (pod-phase, "tickets"). Not proposed yet: keep the decision — the split proposal sets `lane` from it. +- Anything {owner} says about the idea in phase 1 belongs in `intent.md` under **{owner}'s words (verbatim)**. +- **A question from {owner} is answered as a conversation:** reply `--ask` and end with an open door ("Anything you'd change before I go on?"), so it lands as "Your turn" and can be talked through. In **phase 1 (Shape)** the pipeline waits while that's open — the manager won't move past Shape until the reply has nothing more to ask, or "Done — carry on" is pressed. +- **When a phase-1 talk settles** (the reply needs no further `--ask` from you, or it said "Done — carry on"): after your `--fyi`, run `lambda pod send $POD_ID manager "[dev] The owner's questions are settled — Shape can finish."` so the pipeline picks up straight away. +- **A freeform pod** (`lambda pod status $POD_ID` says `mode: FREEFORM`): it's a conversation — every reply is `--ask` (see `/freeform`), and nothing in the pipeline waits on it. When it's finished, offer to close it, and close it with `lambda pod done $POD_ID` if {owner} agrees (see `/freeform`). +- **{owner} agrees part of this should be its own Lambda** (its own epic, a side topic — not a triage pull-out, which has its own step): start it yourself, never ask for it to be pasted anywhere — `lambda pod new --spinoff --text ""`. It starts as a freeform chat, linked to this one; say in your reply that it's started. +- **{owner} asks to skip a stage** ("skip research", "no UX review for this one"): `lambda pod skip $POD_ID --quote ""` (stage ids: `lambda pod skip $POD_ID x` lists them), then reply `--fyi` saying what's skipped. diff --git a/commands/pod/human-close.md b/commands/pod/human-close.md new file mode 100644 index 0000000..dc41d77 --- /dev/null +++ b/commands/pod/human-close.md @@ -0,0 +1,13 @@ +--- +description: (planning pods) {owner} ended the conversation (pressed Enter) and moved on. Fold in the last message and resolve. +argument-hint: [m:][: ] +--- + +# Conversation closed: $ARGUMENTS + +{owner} pressed Enter: the conversation is over and {owner} has **already moved on**. Do not reply conversationally, and write nothing outside tool calls — no recap; nobody reads it. + +1. If there is a final message after the colon, treat it like any other: record any call in it with `lambda pod decide "" --quote ""` (or `--deferred`). +2. **If that final message is ambiguous, do not guess.** Run `lambda pod reply "Your last message was ambiguous: . I've passed it to the dev to ask properly."` and include the ambiguity in step 3's resolution — the dev will turn it into a proper inbox question. +3. Run `lambda pod resolve-conversation` — it sends the dev your recorded decisions with {owner}'s exact words plus the full transcript. +4. You are done. Stop. diff --git a/commands/pod/human-resolved.md b/commands/pod/human-resolved.md new file mode 100644 index 0000000..444b83e --- /dev/null +++ b/commands/pod/human-resolved.md @@ -0,0 +1,28 @@ +--- +description: (planning pods) A conversation between {owner} and a fork of you about one question has ended. Apply what was decided. +argument-hint: [m:] + decisions, deferred items and full transcript +--- + +# Conversation resolved: $ARGUMENTS + +{owner} talked about question `` with a **fork** of you, so your own conversation was not interrupted. The message above was built from the files: the decisions **with {owner}'s exact words**, anything deferred, and the **full transcript, both sides**. + +1. Ignore if this `[m:…]` id was already handled (re-delivery after a restart). +2. Check each decision against the transcript — the quotes are what count, not the paraphrase. +3. **If it says UNCONFIRMED** (the fork died before it finished), apply only what the transcript makes unambiguous and ask anything else as a new question. Never guess. +4. Apply the decisions to the tickets (they are already in `human-decisions.jsonl` — do not record them again). +5. Append general preferences to **Later statements** in `intent.md`, with the quotes. +6. Run the ripple check and the stale check (your dev instructions). +7. Treat DEFERRED items as out of scope for now; note them where the ticket would otherwise be silent. +8. If it needs decisions that weren't settled, queue them as questions with `--from-message ` (your dev instructions). +9. **A decision question:** `lambda pod resolve `. If that was the phase's last open question, post the checkpoint. + **A checkpoint:** never resolve it — it stays open until {owner} says "checkpoint complete". +10. **Reply once on the item, after the work** — `lambda pod reply --qid --fyi ""`, naming changes by the numbers on the card. That reply is what brings the item back to the inbox (and is read first when it does); without it a checkpoint stays out of the inbox. + +## `/human-resolved chat` — from the Chat panel + +The same, but it covers **several topics** (`## About q7 (Checkpoint 3)`, `## About the whole pod`, …): what {owner} said in the sidebar chat since the last hand-off, handed to you just **before** the answer on an item (which arrives next as `/human-async`). Per topic, do steps 2–8 above. Then: + +- **Never resolve anything because of the chat** — a chat isn't an answer. The answer on the item (the next message) is what you act on to resolve or reply on it; fold the chat's decisions into that work. +- A topic about an item not being answered now: apply its decisions, and reply once on that item (`lambda pod reply --qid --fyi "…"`) only if they changed it. +- "The whole pod": apply decisions to the epic / intent as usual; no reply needed unless something changed that {owner} would want to hear. diff --git a/commands/pod/human-standby.md b/commands/pod/human-standby.md new file mode 100644 index 0000000..d639351 --- /dev/null +++ b/commands/pod/human-standby.md @@ -0,0 +1,43 @@ +--- +description: (planning pods) You are a FORK of the pod dev, warmed up for a live conversation with {owner} about one question. +argument-hint: [m:] +--- + +# Stand by: $ARGUMENTS + +You are a **fork** of the pod's dev (`$POD_AGENT` = `fork-`). You have the dev's whole conversation, but you are a separate session: the dev keeps working, and you will **not** be resumed after a restart. + +{owner} has started typing about question ``. Load it now so you can answer instantly: + +```bash +lambda pod question show +``` + +Do not reply yet and do not use `lambda pod reply`. Just end your turn with the single word `ready`. + +## For the whole conversation + +- **You are read-only.** You cannot edit tickets, files or the question queue (your settings deny it). The dev applies everything after you resolve. +- You talk to {owner} **only** with `lambda pod reply ""` — it is read aloud, so: answer first, 1–3 sentences, no file:line references, no code. +- If something needs looking up, reply `checking ` **immediately**, then start a background subagent for it, and send a second `lambda pod reply` with the result when it returns. Never leave {owner} waiting in silence. +- **Every time {owner} makes a call**, record it at once — before your reply ends — and start your reply with `Recorded: .`: + ```bash + lambda pod decide "" --quote "" + lambda pod decide "" --quote "" --deferred # "later" + ``` + +## If `$ARGUMENTS` is `chat` — you are the pod's Chat panel (`fork-chat`) + +{owner} opened the **Chat** panel. You are one fork for the whole pod, not one question: each message is about the item on screen — its qid comes right after `/human` (`/human q7 [m:…]: …`) — or about the whole pod (no qid). + +- Load the pod now so you can answer fast: `lambda pod status $POD_ID`. (For an item, `lambda pod question show ` when its first message arrives.) Then end your turn with the single word `ready`. +- **Always say what a reply or decision is about:** `lambda pod reply --qid ""` and `lambda pod decide "" --quote "" --qid ` when the message had a qid; no `--qid` when it was about the whole pod. +- You may answer status and pipeline questions ("why is it stuck?", "where are we?") from `lambda pod status $POD_ID` and the thread. +- **Never resolve or close anything** — there is no `lambda pod resolve-conversation` for the chat. What is said here is handed to the dev automatically the next time {owner} answers an item in the center. +- **Work, not a question?** When {owner} asks the pod to DO something that takes real work — "rebase on epic 1", "add a ticket for…", "re-check everything against…" — you can't do it (you're read-only), so queue it as a **work request**: + ```bash + lambda pod request add "" --quote "" [--qid ] + ``` + and reply `Queued as a request: . The dev starts it now; the pod's items step aside until it's done.` A question you can answer, you answer — only queue real work. If you can't tell, ask in one line. +- **"Remember that" / "make that a judgement"** — {owner} wants a lasting rule for future tickets: `lambda knowledge add --kind judgement --statement "" --why "" --quote "" --source ""` (a wording rule: `--kind copy --use "" --never "" --meaning ""`). Reply with the statement as saved. If it says "Contradicts …", read out the existing rule and ask whether the new one replaces it (then add it with `--supersedes `). +- Everything else above applies: read-only, short spoken replies, record every call at once. diff --git a/commands/pod/human.md b/commands/pod/human.md new file mode 100644 index 0000000..84e506b --- /dev/null +++ b/commands/pod/human.md @@ -0,0 +1,28 @@ +--- +description: (planning pods) {owner} is in a live conversation with you (a fork of the dev) and is WAITING for your answer. +argument-hint: [m:]: +--- + +# {owner} (waiting): $ARGUMENTS + +{owner} is **waiting** on you. Latency is everything. + +**Who you are:** you are a FORK of the pod dev — `$POD_AGENT` is `fork-` or `fork-chat` — never the dev itself, even though you have its whole history. You don't do the dev's work and nothing is "handed back" to you. If you are `fork-chat` (the Chat panel) and haven't seen `/human-standby chat`, read the "If `$ARGUMENTS` is `chat`" section of `.claude/commands/human-standby.md` now. + +**Write nothing outside tool calls.** {owner} only ever sees what you send with `lambda pod reply` — any other text is never shown and only adds delay: a preamble delays your answer, and a closing summary keeps you busy so the next message waits. So: no preamble, no recap, no "I answered…" at the end, and don't check that the reply posted. Your first action is the reply (after recording a decision, if one was made); then end your turn with no text. + +1. Ignore if this `[m:…]` id was already handled. +2. If {owner} made a call, record it **first** (`lambda pod decide "" --quote ""`, `--deferred` for "later"). +3. Reply with `lambda pod reply ""`: + - Start with `Recorded: .` if you just recorded one. + - Then the answer, in 1–3 sentences, leading with it — it's read aloud in a live conversation. If it truly needs more, a bold answer line then a short list (never one long paragraph). + - No file:line references, code or URLs — it is read aloud. Light markdown renders on screen (**bold**, a short list) and is dropped for speech. + - An `[Image: /path]` line in the message is a picture {owner} attached: open it with Read first. + - Need a decision? Give the context, the problem, the options and your recommendation (your dev instructions, "Asking {owner}") — short, since it's read aloud, but all four. +4. Need to look something up? Reply `checking ` right away, start a background subagent, and send a second `lambda pod reply` when it returns. +5. You are read-only — never try to edit tickets or files; the dev applies everything after the conversation. + +**On a checkpoint** (`lambda pod question show ` says `kind: checkpoint`): {owner} refers to its decisions by their number on the card — that list is in `lambda pod question show ` (the checkpoint's `decisionIds`, in order). Changes made there are recorded with `lambda pod decide "" --quote ""` like any other call. Never close or resolve a checkpoint — only {owner} does, by saying "checkpoint complete". + +**In the Chat panel** (you are `fork-chat`): the qid after `/human` is what this message is about — none means the whole pod. Pass it on: `lambda pod reply --qid "…"`, `lambda pod decide … --qid ` (no `--qid` for the whole pod). Never resolve — the chat is handed to the dev when {owner} next answers an item. +Asked to DO real work (not answer)? Queue it: `lambda pod request add "" --quote "" [--qid ]`, then reply `Queued as a request: .` diff --git a/commands/pod/pod-request-done.md b/commands/pod/pod-request-done.md new file mode 100644 index 0000000..29b7ed8 --- /dev/null +++ b/commands/pod/pod-request-done.md @@ -0,0 +1,12 @@ +--- +description: (planning pods) The dev finished a work request. Sent to the planning manager. +argument-hint: done: . Invalidated: +--- + +# Work request done: $ARGUMENTS + +Resume the pipeline — see "Work requests" in your CLAUDE.md: + +1. Another request queued (`lambda pod request list`)? Run it first, the same way. +2. Re-run, **in pipeline order**, every invalidated stage — and the stage you interrupted, if any — by sending its `/pod-phase ` (`tickets`, `ux`, …) and checking its "done when" as usual. A withdrawn checkpoint comes back when its stage re-posts it. +3. Nothing invalidated and nothing interrupted: carry on exactly where you were (if you were waiting on a checkpoint, keep waiting). diff --git a/commands/pod/pod-request-queued.md b/commands/pod/pod-request-queued.md new file mode 100644 index 0000000..c768edd --- /dev/null +++ b/commands/pod/pod-request-queued.md @@ -0,0 +1,13 @@ +--- +description: (planning pods) {owner} queued a work request. Sent to the planning manager. +argument-hint: : — the owner's words +--- + +# Work request queued: $ARGUMENTS + +Run it **now** — see "Work requests" in your CLAUDE.md: + +1. If the dev is mid-turn (`lambda pod status $POD_ID` → dev turn working): `lambda pod interrupt $POD_ID dev`. Remember which stage you had sent it (you will re-run it). +2. `lambda pod request start ` — hands it to the dev. +3. `lambda pod await $POD_ID dev` until it returns 0, then check `lambda pod request list` shows it `done`. If not, tell the dev exactly what is missing (one message) and await again. +4. You'll then get `/pod-request-done`. diff --git a/commands/pod/pod-request.md b/commands/pod/pod-request.md new file mode 100644 index 0000000..147ae1d --- /dev/null +++ b/commands/pod/pod-request.md @@ -0,0 +1,23 @@ +--- +description: (planning pods) {owner} asked the pod to DO something (a work request). Sent to the pod dev by the planning manager. +argument-hint: : — the owner's words: "…" +--- + +# Work request: $ARGUMENTS + +{owner} asked this pod to do real work — anything from "rebase on epic 1 and its children" to "add a ticket for the trail-closure banner". The manager stopped whatever you were doing for it; **that stage will be re-run from the top afterwards**, so don't try to finish it. While this request runs, all of the pod's inbox items are hidden. + +Follow your dev instructions throughout (decisions, questions, cold reads). + +1. Read the words — the quote is what counts, not the summary. If they are genuinely ambiguous, ask (`lambda pod reply --ask "…"`) before changing anything — with Context, Problem, Options and a Recommendation (your dev instructions, "Asking {owner}"), never a bare "A or B?". +2. Do the work, fresh: `lambda tickets pull` every ticket you touch first, and read what the request points at (another epic and its children: `lambda tickets get `, pull each child; code that has landed on the default branch). + - Engineering calls: fix them and log each with `lambda pod decide --autonomous` (Before / Why / Instead), as always. + - A ticket's lane ("make 2 a quick fix"): as in `/human-chat` — `lane` in `proposal.json` before the tickets exist, the label (`lambda tickets update --label `) once they do. + - Anything that changes what the product's users see, or what {owner} decided: a question (draft → cold read → commit), never a guess. +3. **Stale check** on everything this pod has in the inbox (`lambda pod question list --pod $POD_ID --status open`): for each open question — still valid (leave it), changed by the request (rewrite it), or moot (`lambda pod question withdraw --reason ""`). Your recorded decisions stay; re-apply any that the request touched (the ripple check), and ask about any that no longer fit. +4. **What did it make stale?** List the workflow stages whose output no longer matches (`lambda pod status $POD_ID` shows the track; `lambda planning steps` lists the stage ids). Typical: changed tickets → `tickets` (if their UX sections or red flags need redoing) and `ux-review` (if it was done or under way). Nothing → `none`. +5. Finish with: + ```bash + lambda pod request done --result "" --invalidates + ``` + It withdraws the stale checkpoint (it is re-posted when the stage is redone) and tells the manager what to re-run. Then end your turn. diff --git a/commands/pod/pod-restarted.md b/commands/pod/pod-restarted.md new file mode 100644 index 0000000..fdf80de --- /dev/null +++ b/commands/pod/pod-restarted.md @@ -0,0 +1,18 @@ +--- +description: (planning pods) The machine restarted and this pod agent was resumed. Your last turn may have been cut off. +argument-hint: [m:] +--- + +# The machine restarted + +You were just resumed with your conversation intact, but **your last turn may have been cut off mid-step**. Messages that had not reached you are being re-sent with their original `[m:…]` ids — ignore any id you already handled. + +**First, re-read your instructions — they may have changed since you started**: the manager its `CLAUDE.md`, the dev its dev instructions (especially "How your messages look"). Follow the new version from now on. + +**If you are the planning manager:** run `lambda pod status $POD_ID`, check the current phase's "done when" column in your CLAUDE.md against the artifacts (the tickets, question files, pod.json — not your memory), then finish those checks or re-send `/pod-phase `. Never re-create something that already exists. If you were waiting at a checkpoint, keep waiting. + +**If you are the dev:** + +1. `lambda pod question list --status draft` — finish each draft (cold read → commit) or withdraw it. No draft may be left behind. +2. If you were in the middle of a ticket edit, `lambda tickets pull` it and check whether your change landed before redoing it. +3. Then stop and wait for the manager. diff --git a/commands/pod/pod-skip.md b/commands/pod/pod-skip.md new file mode 100644 index 0000000..5c05bf1 --- /dev/null +++ b/commands/pod/pod-skip.md @@ -0,0 +1,13 @@ +--- +description: (planning pods, manager) {owner} skipped a stage of this pod's flow. +argument-hint: '[m:]: skipped ()' +--- + +# Stage skipped: $ARGUMENTS + +{owner} chose to skip a stage. It's recorded on the pod (`skipped:` line of `lambda pod status $POD_ID`) and as {owner}'s decision. Don't question it. + +- **A stage you haven't reached:** nothing to do now — when you get there, pass it by and go to the next stage in your table. +- **The stage the dev is in right now:** `lambda pod interrupt $POD_ID dev`, then send the next stage's `/pod-phase …` (set `lambda pod phase` first if the phase number changes). +- **A checkpoint** (`cp`) — or the checkpoint right after the skipped stage: it counts as closed. If you were waiting on it, go on to the next stage now. If the dev already posted it, tell the dev: "CP was skipped — withdraw it (`lambda pod question withdraw `) and carry on." +- **The stage you are waiting on {owner} for** (e.g. the triage checkpoint was skipped): go on now. diff --git a/commands/pod/pod-structured.md b/commands/pod/pod-structured.md new file mode 100644 index 0000000..9eda61b --- /dev/null +++ b/commands/pod/pod-structured.md @@ -0,0 +1,12 @@ +--- +description: (planning pods, manager) {owner} switched a freeform pod to the structured flow. Start the pipeline. +argument-hint: '[m:]: ' +--- + +# Switched to structured: $ARGUMENTS + +Until now this pod was a freeform conversation between {owner} and the dev, and you had nothing to run. Now it is on the structured flow. + +1. `lambda pod status $POD_ID` — the `track:`, the `epic:` / `ticket:` / `tickets:` line, and `mode: structured (started as a freeform conversation…)`. +2. Start the pipeline at phase 1 exactly as for a new pod (your CLAUDE.md, the table for its track): `lambda pod sync $POD_ID`, `lambda pod phase $POD_ID 1`, `lambda pod send $POD_ID dev "/pod-phase 1"`. `/pod-phase 1` tells the dev to shape from the conversation it already had — confirm, not re-ask. +3. Carry on as usual from there. diff --git a/desktop/build/entitlements.mac.inherit.plist b/desktop/build/entitlements.mac.inherit.plist new file mode 100644 index 0000000..b2d4003 --- /dev/null +++ b/desktop/build/entitlements.mac.inherit.plist @@ -0,0 +1,16 @@ + + + + + + com.apple.security.cs.allow-jit + + + com.apple.security.cs.disable-library-validation + + + diff --git a/desktop/build/entitlements.mac.plist b/desktop/build/entitlements.mac.plist new file mode 100644 index 0000000..7c4bb70 --- /dev/null +++ b/desktop/build/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml new file mode 100644 index 0000000..e85b8b2 --- /dev/null +++ b/desktop/electron-builder.yml @@ -0,0 +1,64 @@ +# The Mac app: `npm run build:mac` (run from the +# package folder). The app is the package itself: the CLI, the mothership, +# the runner and the app's pages from dist/, plus the main process from +# desktop/dist/. Signing and notarising happen only when the release job has +# the Apple secrets; without them the same build is unsigned (ad-hoc, as +# every arm64 build is) and still opens. +appId: app.lambda.desktop +productName: Lambda +copyright: Lambda's authors +directories: + output: desktop/release + buildResources: desktop/build +extraMetadata: + main: desktop/dist/main.js +files: + - package.json + - dist/** + - bin/** + - commands/** + - instructions/** + - templates/** + - tools/** + - src/building/permission-hook.cjs + - src/design/system/shims/** + - desktop/dist/** + - '!**/*.map' + - '!**/__tests__/**' +asar: true +# A forked process can't run from inside app.asar, and native modules can't +# load from it: Kokoro's worker, the pty, SQLite and the package's tools. +asarUnpack: + - dist/kokoroWorker.js + - node_modules/@lydell/node-pty/** + - node_modules/@lydell/node-pty-*/** + - node_modules/better-sqlite3/** + - tools/** +extraResources: + # tmux and git, built by desktop/tools/fetch-tools.sh. + - from: desktop/tools/out + to: tools + # Kokoro and its model runtime, installed by build:mac into desktop/kokoro. + - from: desktop/kokoro/node_modules + to: kokoro/node_modules +npmRebuild: true +mac: + target: + # What people download. + - target: dmg + arch: + - arm64 + # What electron-updater installs updates from (latest-mac.yml points here). + - target: zip + arch: + - arm64 + category: public.app-category.developer-tools + hardenedRuntime: true + gatekeeperAssess: false + entitlements: desktop/build/entitlements.mac.plist + # Electron's helpers and the bundled tools: no unsigned executable memory. + entitlementsInherit: desktop/build/entitlements.mac.inherit.plist + # Turned on by the release job only when the Apple secrets exist. + notarize: false +dmg: + title: Lambda diff --git a/desktop/kokoro/package-lock.json b/desktop/kokoro/package-lock.json new file mode 100644 index 0000000..3206ae5 --- /dev/null +++ b/desktop/kokoro/package-lock.json @@ -0,0 +1,1049 @@ +{ + "name": "lambda-desktop-kokoro", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lambda-desktop-kokoro", + "dependencies": { + "kokoro-js": "1.2.1" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/kokoro-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/kokoro-js/-/kokoro-js-1.2.1.tgz", + "integrity": "sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/transformers": "^3.5.1", + "phonemizer": "^1.2.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, + "node_modules/phonemizer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/phonemizer/-/phonemizer-1.2.1.tgz", + "integrity": "sha512-v0KJ4mi2T4Q7eJQ0W15Xd4G9k4kICSXE8bpDeJ8jisL4RyJhNWsweKTOi88QXFc4r4LZlz5jVL5lCHhkpdT71A==", + "license": "Apache-2.0" + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/desktop/kokoro/package.json b/desktop/kokoro/package.json new file mode 100644 index 0000000..6838de5 --- /dev/null +++ b/desktop/kokoro/package.json @@ -0,0 +1,8 @@ +{ + "name": "lambda-desktop-kokoro", + "private": true, + "description": "Kokoro for the Mac app: installed by build:mac and carried beside app.asar, never part of the npm package.", + "dependencies": { + "kokoro-js": "1.2.1" + } +} diff --git a/desktop/main.ts b/desktop/main.ts new file mode 100644 index 0000000..40fcdc9 --- /dev/null +++ b/desktop/main.ts @@ -0,0 +1,338 @@ +/** + * The Mac app's main process: thin wiring over `src/desktop/`, where the + * logic lives (and is unit-tested without Electron). + * + * Opening the app runs `lambda start` through the app's `lambda` shim (a + * child process, never inside this one), waits for the mothership's ready + * file, then opens one window already signed in. The menu-bar icon stays + * when the window closes, so this Mac's runner keeps working; only Quit + * stops everything (`lambda stop --all`). + * + * First-run setup is web pages the mothership serves (`/setup`); the window + * shows them and adds only the native folder picker, through the preload + * bridge. + */ +import { execFile } from 'child_process'; +import { + BrowserWindow, + Menu, + type MenuItemConstructorOptions, + Notification, + Tray, + app, + dialog, + ipcMain, + nativeImage, + powerMonitor, + shell, + autoUpdater as squirrel, +} from 'electron'; +import { autoUpdater } from 'electron-updater'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { lambdaHome, loadMachineConfig } from '../src/config/machineConfig'; +import { + appLogDir, + appStateFile, + browsersDir, + shimPath, +} from '../src/desktop/appPaths'; +import { type AppLayout, appEnv, writeShim } from '../src/desktop/bundledTools'; +import { + decideNavigation, + guardWindow, + mayPickFolder, +} from '../src/desktop/navigation'; +import { createQuitController } from '../src/desktop/quit'; +import { remindSafely } from '../src/desktop/renewalReminder'; +import { + createPendingSecondInstances, + openSignedIn, + waitForMothership, + windowOptions, +} from '../src/desktop/startup'; +import { + type AutoUpdaterLike, + CHECK_ITEM, + type UpdateMenu, + type Updater, + createUpdater, + isDeveloperIdSigned, +} from '../src/desktop/updater'; +import { mothershipDbPath } from '../src/mothership/db'; +import { mintLoginLink } from '../src/mothership/loginLink'; + +/** The public repo the app updates from (its Releases). */ +const RELEASES_REPO = 'adriancarriger/lambda'; + +/** How often the renewal reminder looks at the date. */ +const REMINDER_CHECK_MS = 60 * 60 * 1000; + +/** How long Quit waits for `lambda stop --all`. */ +const STOP_TIMEOUT_MS = 20_000; + +const home = os.homedir(); + +const layout: AppLayout = { + // .../Lambda.app/Contents/MacOS/Lambda → .../Lambda.app + bundle: path.resolve(process.execPath, '..', '..', '..'), + resources: process.resourcesPath, + electron: process.execPath, + home, +}; + +const logFile = path.join(appLogDir(home), 'main.log'); + +const log = (line: string): void => { + try { + fs.mkdirSync(path.dirname(logFile), { recursive: true }); + fs.appendFileSync(logFile, `[${new Date().toISOString()}] ${line}\n`); + } catch { + // Nowhere else to say it. + } +}; + +const env = appEnv(layout, process.env, browsersDir(home)); + +/** `lambda ` through the shim, from the home folder. */ +const runLambda = (args: string[], timeoutMs?: number): Promise => + new Promise((resolve) => { + execFile( + shimPath(home), + args, + { cwd: home, env, timeout: timeoutMs }, + (error, stdout, stderr) => { + log( + `lambda ${args.join(' ')}: ${error === null ? 'ok' : error.message}` + ); + + // `lambda open` can print a one-time login link: never into the log. + const output = args[0] === 'open' ? stderr : `${stdout}${stderr}`; + + if (output.trim() !== '') { + log(output.trim()); + } + + resolve(error === null ? 0 : 1); + } + ); + }); + +const state: { + window: BrowserWindow | null; + origin: string; + tray: Tray | null; + updater: Updater | null; +} = { window: null, origin: '', tray: null, updater: null }; + +const ending = createQuitController({ + stopLambda: async () => { + await runLambda(['stop', '--all'], STOP_TIMEOUT_MS); + }, + quit: () => app.quit(), + install: () => autoUpdater.quitAndInstall(), +}); + +/** Any other https link opens in the default browser; nothing else ever opens. */ +const openExternal = (url: string): void => { + if (decideNavigation(url, state.origin) === 'external') { + shell.openExternal(url).catch((error: unknown) => log(String(error))); + } +}; + +const show = (): void => { + state.window?.show(); + state.window?.focus(); +}; + +const createWindow = (origin: string): BrowserWindow => { + state.origin = origin; + const window = new BrowserWindow( + windowOptions(path.join(__dirname, 'preload.js')) + ); + window.once('ready-to-show', () => window.show()); + guardWindow(window.webContents, () => state.origin, openExternal); + // Closing hides: the icon stays and this Mac's runner keeps working. + window.on('close', (event) => { + if (ending.windowClose(event) === 'hide') { + window.hide(); + } + }); + state.window = window; + + return window; +}; + +const updateItem = (menu: UpdateMenu): MenuItemConstructorOptions => ({ + label: menu.label, + click: () => state.updater?.click(), +}); + +const buildMenu = (): void => { + const menu = Menu.buildFromTemplate([ + { label: 'Open Lambda', click: show }, + { label: 'Open in browser', click: () => void runLambda(['open']) }, + updateItem(state.updater?.menu() ?? CHECK_ITEM), + { + label: 'Run setup again…', + click: () => { + if (state.window !== null && state.origin !== '') { + void state.window.loadURL(`${state.origin}/setup`); + show(); + } + }, + }, + { type: 'separator' }, + { label: 'Quit Lambda', click: () => void ending.quit() }, + ]); + state.tray?.setContextMenu(menu); +}; + +/** electron-updater, as the tested updater sees it. */ +const adapted: AutoUpdaterLike = { + get autoDownload() { + return autoUpdater.autoDownload; + }, + set autoDownload(value) { + autoUpdater.autoDownload = value; + }, + get autoInstallOnAppQuit() { + return autoUpdater.autoInstallOnAppQuit; + }, + set autoInstallOnAppQuit(value) { + autoUpdater.autoInstallOnAppQuit = value; + }, + checkForUpdates: () => autoUpdater.checkForUpdates(), + quitAndInstall: () => autoUpdater.quitAndInstall(), + on: (event, listener) => { + switch (event) { + case 'error': + return autoUpdater.on('error', listener); + case 'update-available': + return autoUpdater.on('update-available', listener); + case 'update-downloaded': + return autoUpdater.on('update-downloaded', listener); + } + }, +}; + +/** Developer ID signed? Unsigned builds only offer a download. */ +const checkSigned = (): Promise => + new Promise((resolve) => { + execFile( + 'codesign', + ['-dv', '--verbose=2', layout.bundle], + (_error, stdout, stderr) => + resolve(isDeveloperIdSigned(`${stdout}${stderr}`)) + ); + }); + +const remind = (): void => { + remindSafely({ + now: Date.now(), + readCreatedAt: () => loadMachineConfig(env)?.tokens?.claude?.createdAt, + stateFile: appStateFile(home), + notify: (title, body) => new Notification({ title, body }).show(), + log, + }); +}; + +/** Second launches that arrive before the updater exists wait for it. */ +const secondInstances = createPendingSecondInstances(); + +const start = async (): Promise => { + // powerMonitor works only once the app is ready. + powerMonitor.on('shutdown', ending.shutdown); + writeShim(layout); + state.tray = new Tray(nativeImage.createEmpty()); + state.tray.setTitle('λ'); + state.tray.setToolTip('Lambda'); + buildMenu(); + + ipcMain.handle('lambda:pick-folder', async (event) => { + if (!mayPickFolder(event.senderFrame?.origin, state.origin)) { + return null; + } + + const options = { + defaultPath: home, + properties: ['openDirectory' as const, 'createDirectory' as const], + }; + const picked = + state.window === null + ? await dialog.showOpenDialog(options) + : await dialog.showOpenDialog(state.window, options); + + return picked.canceled ? null : picked.filePaths[0] ?? null; + }); + + // Not awaited: `lambda start` returns once things are up, and the ready + // file is what this waits for. + void runLambda(['start']); + + const opened = await openSignedIn({ + wait: () => waitForMothership({ lambdaHome: lambdaHome(env) }), + mintLoginLink: (port) => mintLoginLink(mothershipDbPath(env), { port }), + createWindow, + showError: (title, message) => { + dialog.showErrorBox(title, message); + }, + }); + + // The dialog has said why; with no mothership the menu would do nothing, + // so the app stops (and stops whatever did start) rather than linger. + if (!opened.ok) { + await ending.quit(); + + return; + } + + const [owner = '', repo = ''] = RELEASES_REPO.split('/'); + const signed = await checkSigned(); + autoUpdater.setFeedURL({ provider: 'github', owner, repo }); + const updater = createUpdater({ + autoUpdater: adapted, + signed, + log, + setMenu: () => buildMenu(), + openExternal, + setInterval: (run, ms) => setInterval(run, ms), + restart: () => void ending.restartToUpdate(), + }); + state.updater = updater; + // Electron's own updater (Squirrel, which electron-updater drives on macOS) + // says when it is about to quit to install. + squirrel.on('before-quit-for-update', ending.quittingForUpdate); + updater.start(); + secondInstances.ready((argv) => updater.secondInstance(argv)); + buildMenu(); + + remind(); + setInterval(remind, REMINDER_CHECK_MS); +}; + +if (!app.requestSingleInstanceLock()) { + // Opened a second time: the first one focuses its window instead. + app.quit(); +} else { + app.on('second-instance', (_event, argv) => { + secondInstances.arrived(argv); + show(); + }); + // Keep running with no window: the menu-bar icon is the app. + app.on('window-all-closed', () => undefined); + app.on('activate', show); + app.on('before-quit', ending.beforeQuit); + app + .whenReady() + .then(start) + .catch((error: unknown) => { + log(`start failed: ${String(error)}`); + dialog.showErrorBox( + "Lambda couldn't start", + error instanceof Error ? error.message : String(error) + ); + }); +} diff --git a/desktop/preload.ts b/desktop/preload.ts new file mode 100644 index 0000000..05485c0 --- /dev/null +++ b/desktop/preload.ts @@ -0,0 +1,11 @@ +/** + * The one thing the Mac app adds to the setup pages: the native folder + * picker, as `window.lambda.pickFolder()`. The main process answers it only + * for the local mothership's own frame. + */ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('lambda', { + pickFolder: (): Promise => + ipcRenderer.invoke('lambda:pick-folder'), +}); diff --git a/desktop/tools/fetch-tools.sh b/desktop/tools/fetch-tools.sh new file mode 100755 index 0000000..8e374e3 --- /dev/null +++ b/desktop/tools/fetch-tools.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Builds the tmux and git the Mac app carries, into desktop/tools/out (which +# electron-builder copies to Lambda.app/Contents/Resources/tools). +# +# Neither ships as a standalone macOS binary, and /usr/bin/git is only a stub +# that asks to install the Xcode tools, so both are built from pinned source +# tarballs, each checked against its SHA-256 (a mismatch stops the build). +# tmux is linked against a static libevent and the system ncurses; git is +# built with RUNTIME_PREFIX so it runs from wherever the app is. Needs the +# Xcode command line tools (the macOS release runner has them). +# +# Ends by checking that each binary links only /usr/lib and /System +# libraries, so it runs on a Mac with nothing else installed. Everything is +# built in .work/out and moves to out/ only once every check has passed, so +# a failed run never leaves binaries the next run takes as already built. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="${HERE}/out" +WORK="${HERE}/.work" +STAGE="${WORK}/out" + +TMUX_VERSION="3.5a" +TMUX_SHA256="16216bd0877170dfcc64157085ba9013610b12b082548c7c9542cc0103198951" +LIBEVENT_VERSION="2.1.12-stable" +LIBEVENT_SHA256="92e6de1be9ec176428fd2367677e61ceffc2ee1cb119035037a27d346b0403bb" +GIT_VERSION="2.47.3" +GIT_SHA256="c073471530e92b716641ea2b381fcd0ece53eea9a76a9c5415f93f89e870dd5f" + +JOBS="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" +export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-12.0}" + +if [[ -x "${OUT}/bin/tmux" && -x "${OUT}/bin/git" && "${1:-}" != "--force" ]]; then + echo "tools: already built in ${OUT} (--force to rebuild)" + exit 0 +fi + +fetch() { + local url="$1" file="$2" sha="$3" + + if [[ ! -f "${WORK}/${file}" ]]; then + curl -fsSL --retry 3 -o "${WORK}/${file}.part" "${url}" + mv "${WORK}/${file}.part" "${WORK}/${file}" + fi + + local actual + actual="$(shasum -a 256 "${WORK}/${file}" | awk '{print $1}')" + + if [[ "${actual}" != "${sha}" ]]; then + echo "tools: ${file} has SHA-256 ${actual}, expected ${sha}" >&2 + rm -f "${WORK}/${file}" + exit 1 + fi + + tar -xzf "${WORK}/${file}" -C "${WORK}" +} + +rm -rf "${OUT}" "${STAGE}" +mkdir -p "${WORK}" "${STAGE}/bin" + +# ── libevent (static, for tmux only) ────────────────────────────────────── +fetch "https://github.com/libevent/libevent/releases/download/release-${LIBEVENT_VERSION}/libevent-${LIBEVENT_VERSION}.tar.gz" \ + "libevent-${LIBEVENT_VERSION}.tar.gz" "${LIBEVENT_SHA256}" +( + cd "${WORK}/libevent-${LIBEVENT_VERSION}" + ./configure --prefix="${WORK}/libevent" --disable-shared --enable-static \ + --disable-openssl --disable-samples --disable-libevent-regress >/dev/null + make -j"${JOBS}" >/dev/null + make install >/dev/null +) + +# ── tmux ───────────────────────────────────────────────────────────────── +fetch "https://github.com/tmux/tmux/releases/download/${TMUX_VERSION}/tmux-${TMUX_VERSION}.tar.gz" \ + "tmux-${TMUX_VERSION}.tar.gz" "${TMUX_SHA256}" +( + cd "${WORK}/tmux-${TMUX_VERSION}" + # Only our libevent: pkg-config never sees another one (Homebrew's, say). + PKG_CONFIG_LIBDIR="${WORK}/libevent/lib/pkgconfig" PKG_CONFIG_PATH="" \ + ./configure --prefix="${WORK}/tmux" --disable-utf8proc \ + LIBEVENT_CORE_CFLAGS="-I${WORK}/libevent/include" \ + LIBEVENT_CORE_LIBS="${WORK}/libevent/lib/libevent_core.a" \ + LIBNCURSES_CFLAGS="" LIBNCURSES_LIBS="-lncurses" >/dev/null + make -j"${JOBS}" >/dev/null + cp tmux "${STAGE}/bin/tmux" +) + +# ── git ────────────────────────────────────────────────────────────────── +fetch "https://mirrors.edge.kernel.org/pub/software/scm/git/git-${GIT_VERSION}.tar.gz" \ + "git-${GIT_VERSION}.tar.gz" "${GIT_SHA256}" +( + cd "${WORK}/git-${GIT_VERSION}" + make -j"${JOBS}" prefix=/ RUNTIME_PREFIX=YesPlease NO_GETTEXT=YesPlease \ + NO_TCLTK=YesPlease NO_PERL=YesPlease NO_PYTHON=YesPlease \ + NO_OPENSSL=YesPlease NO_DARWIN_PORTS=YesPlease NO_FINK=YesPlease \ + NO_INSTALL_HARDLINKS=YesPlease SKIP_DASHED_BUILT_INS=YesPlease >/dev/null + make prefix=/ RUNTIME_PREFIX=YesPlease NO_GETTEXT=YesPlease \ + NO_TCLTK=YesPlease NO_PERL=YesPlease NO_PYTHON=YesPlease \ + NO_OPENSSL=YesPlease NO_DARWIN_PORTS=YesPlease NO_FINK=YesPlease \ + NO_INSTALL_HARDLINKS=YesPlease SKIP_DASHED_BUILT_INS=YesPlease DESTDIR="${WORK}/git" install >/dev/null +) +cp "${WORK}/git/bin/git" "${STAGE}/bin/git" +mkdir -p "${STAGE}/libexec" +cp -R "${WORK}/git/libexec/git-core" "${STAGE}/libexec/git-core" +mkdir -p "${STAGE}/share" +cp -R "${WORK}/git/share/git-core" "${STAGE}/share/git-core" + +# ── only system libraries ──────────────────────────────────────────────── +check_links() { + local binary="$1" + local foreign + foreign="$(otool -L "${binary}" | tail -n +2 | awk '{print $1}' | + grep -v -E '^(/usr/lib/|/System/)' || true)" + + if [[ -n "${foreign}" ]]; then + echo "tools: ${binary} links outside /usr/lib and /System:" >&2 + echo "${foreign}" >&2 + exit 1 + fi +} + +check_links "${STAGE}/bin/tmux" +check_links "${STAGE}/bin/git" +find "${STAGE}/libexec/git-core" -type f -perm -u+x | while read -r helper; do + if file "${helper}" | grep -q 'Mach-O'; then + check_links "${helper}" + fi +done + +mv "${STAGE}" "${OUT}" +echo "tools: tmux ${TMUX_VERSION} and git ${GIT_VERSION} in ${OUT}" diff --git a/desktop/tools/package-mac.sh b/desktop/tools/package-mac.sh new file mode 100755 index 0000000..237bba9 --- /dev/null +++ b/desktop/tools/package-mac.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# The last step of build:mac: electron-builder, then better-sqlite3 rebuilt +# for Node again, whether electron-builder worked or not (it rebuilds that +# module in place, for Electron, and in a workspace that copy is shared). +# +# With no Developer ID certificate (CSC_LINK), the whole bundle is ad-hoc +# signed. Left unsigned, an arm64 app keeps only Electron's own linker +# signature, which no longer matches the renamed bundle: a downloaded copy is +# then "damaged", with no Open Anyway. Either way the built app must pass +# codesign's strict check. +# +# Exits with electron-builder's status, else the check's, else the rebuild's. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="desktop/release/mac-arm64/Lambda.app" + +SIGN=() +if [[ -z "${CSC_LINK:-}" ]]; then + SIGN=(-c.mac.identity=-) +fi + +electron-builder --config desktop/electron-builder.yml --mac --arm64 --publish never \ + ${SIGN[@]+"${SIGN[@]}"} "$@" +built=$? + +verified=0 +if [[ ${built} -eq 0 ]]; then + codesign --verify --deep --strict "${APP}" + verified=$? +fi + +# better-sqlite3's install step downloads and runs code: never with the +# signing secrets in its environment, nor the decoded notarisation key on +# disk (its path is predictable, and unsetting the variable leaves the file). +if [[ -n "${APPLE_API_KEY:-}" ]]; then + rm -f "${APPLE_API_KEY}" +fi +env -u CSC_LINK -u CSC_KEY_PASSWORD -u APPLE_API_KEY -u APPLE_API_KEY_ID \ + -u APPLE_API_ISSUER -u APPLE_API_KEY_BASE64 \ + bash "${LAMBDA_REBUILD_FOR_NODE:-${HERE}/rebuild-for-node.sh}" +rebuilt=$? + +if [[ ${built} -ne 0 ]]; then + exit "${built}" +fi + +if [[ ${verified} -ne 0 ]]; then + echo "${APP} doesn't pass codesign --verify --deep --strict." >&2 + exit "${verified}" +fi + +exit "${rebuilt}" diff --git a/desktop/tools/rebuild-for-node.sh b/desktop/tools/rebuild-for-node.sh new file mode 100755 index 0000000..e96db4b --- /dev/null +++ b/desktop/tools/rebuild-for-node.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# electron-builder rebuilds better-sqlite3 in place, for Electron (npmRebuild). +# In a workspace that copy is shared, so afterwards nothing running on plain +# Node (the CLI, the tests) could load it. build:mac runs this after +# electron-builder, whether or not that succeeded, to put the Node build back. +# It runs better-sqlite3's own install step: its prebuilt binary for this +# Node, else a source build. +set -euo pipefail + +DIR="$(node -p "require('path').dirname(require.resolve('better-sqlite3/package.json'))")" + +(cd "${DIR}" && npm run install --silent) + +node -e "new (require('better-sqlite3'))(':memory:').close()" +echo "better-sqlite3: rebuilt for Node $(node -p 'process.version') ($(node -p 'process.arch'))" diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 0000000..e890755 --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["*.ts", "tsup.config.ts"] +} diff --git a/desktop/tsup.config.ts b/desktop/tsup.config.ts new file mode 100644 index 0000000..1a56363 --- /dev/null +++ b/desktop/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; + +// The Mac app's main process and its preload bridge, into desktop/dist. +// Run from the package folder (`build:mac`), after the package's own build. +export default defineConfig({ + entry: { + main: 'desktop/main.ts', + preload: 'desktop/preload.ts', + }, + outDir: 'desktop/dist', + format: ['cjs'], + platform: 'node', + target: 'node22', + clean: true, + // Electron's own, and native modules loaded from node_modules. + external: [ + 'electron', + 'electron-updater', + 'better-sqlite3', + '@lydell/node-pty', + ], +}); diff --git a/.claude/agents/adrian.md b/examples/.claude/agents/ann.md similarity index 85% rename from .claude/agents/adrian.md rename to examples/.claude/agents/ann.md index 956c319..dcea00a 100644 --- a/.claude/agents/adrian.md +++ b/examples/.claude/agents/ann.md @@ -1,11 +1,11 @@ --- -name: adrian +name: ann description: Blocks until a tmux pane is done (pattern match, shell idle, or output idle). Use for any wait that takes more than a few seconds. model: haiku tools: mcp__await-pane__await_pane --- -# Adrian +# Ann You block until a tmux pane finishes its work, so the calling agent burns zero tokens waiting. @@ -13,7 +13,7 @@ You block until a tmux pane finishes its work, so the calling agent burns zero t The caller provides a prompt like: - lambda await [pattern] [--long] + lambda-demo await [pattern] [--long] Call the `await_pane` tool with the matching parameters: @@ -25,11 +25,11 @@ Do NOT run any other tools or commands — `await_pane` is the only tool availab ## Response Handling -- **Success** — The pane finished. For agent panes (john, sam, james), check the output to decide if the agent is truly done (see below). For other panes, return the output. +- **Success** — The pane finished. For agent panes (ron, sam, james), check the output to decide if the agent is truly done (see below). For other panes, return the output. - **TIMEOUT** — Internal timeout (9 min). Re-run the same `await_pane` call. - **ERROR** — Error (pane not found). Return the error to the caller. -## Agent Panes (john, sam, james) +## Agent Panes (ron, sam, james) These panes run Claude Code agents. `await_pane` uses a short idle timeout which can fire during brief pauses in the agent's work. When it succeeds, it prints the last 40 lines of the pane. diff --git a/.claude/agents/boualam.md b/examples/.claude/agents/boualam.md similarity index 100% rename from .claude/agents/boualam.md rename to examples/.claude/agents/boualam.md diff --git a/.claude/agents/dev-permission-hook.cjs b/examples/.claude/agents/dev-permission-hook.cjs similarity index 52% rename from .claude/agents/dev-permission-hook.cjs rename to examples/.claude/agents/dev-permission-hook.cjs index 9b28501..ee2eb8c 100644 --- a/.claude/agents/dev-permission-hook.cjs +++ b/examples/.claude/agents/dev-permission-hook.cjs @@ -1,5 +1,6 @@ #!/usr/bin/env node const fs = require('fs'); +const path = require('path'); let data; try { @@ -27,7 +28,9 @@ function deny(message) { } // Manager runs from manager/ — don't block it -if (cwd.includes('manager')) { +const managerDir = path.resolve(__dirname, '..', '..', 'manager'); +const resolvedCwd = path.resolve(cwd); +if (resolvedCwd === managerDir || resolvedCwd.startsWith(managerDir + path.sep)) { process.exit(0); } @@ -36,13 +39,13 @@ if (toolName !== 'Bash') { process.exit(0); } -// Block git push, git commit, and lambda ship — reserved for Manager -if (/\bgit\s+push\b/.test(command)) { - deny('git push is reserved for the Manager via lambda ship.'); +// Block git push, git commit, and lambda-demo ship — reserved for Manager +if (/\bgit\b.*\bpush\b/.test(command)) { + deny('git push is reserved for the Manager via lambda-demo ship.'); } -if (/\bgit\s+commit\b/.test(command)) { - deny('git commit is reserved for the Manager via lambda ship.'); +if (/\bgit\b.*\bcommit\b/.test(command)) { + deny('git commit is reserved for the Manager via lambda-demo ship.'); } -if (command.includes('lambda ship')) { - deny('lambda ship is reserved for the Manager. Dev must never ship directly.'); +if (command.includes('lambda-demo ship')) { + deny('lambda-demo ship is reserved for the Manager. Dev must never ship directly.'); } diff --git a/.claude/settings.json b/examples/.claude/settings.json similarity index 83% rename from .claude/settings.json rename to examples/.claude/settings.json index b7cce4a..d8891be 100644 --- a/.claude/settings.json +++ b/examples/.claude/settings.json @@ -1,6 +1,6 @@ { "permissions": { - "allow": ["Bash(lambda:*)", "mcp__await-pane__await_pane"] + "allow": ["Bash(lambda-demo:*)", "mcp__await-pane__await_pane"] }, "hooks": { "PreToolUse": [ diff --git a/examples/.claude/tools/await-pane-server.cjs b/examples/.claude/tools/await-pane-server.cjs new file mode 100644 index 0000000..dea1df9 --- /dev/null +++ b/examples/.claude/tools/await-pane-server.cjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/** + * MCP server for the ann agent. + * + * Exposes a single tool — `await_pane` — that spawns `lambda-demo await `. + * Bare JSON-RPC over stdio; no SDK dependency. + */ +'use strict'; + +const { createInterface } = require('readline'); +const { execFileSync } = require('child_process'); + +const SERVER_INFO = { + name: 'await-pane', + version: '1.0.0', +}; + +const TOOL = { + name: 'await_pane', + description: + 'Block until a tmux pane finishes its work (pattern match, shell idle, or output idle).', + inputSchema: { + type: 'object', + properties: { + pane: { + type: 'string', + description: 'Tmux pane name (e.g. scratchPad, ron, sam)', + }, + pattern: { + type: 'string', + description: 'Optional regex pattern to match in pane output', + }, + long: { + type: 'boolean', + description: 'Use long idle threshold (60s) for CI/long operations', + }, + }, + required: ['pane'], + }, +}; + +function handleInitialize(id) { + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }, + }; +} + +function handleToolsList(id) { + return { + jsonrpc: '2.0', + id, + result: { tools: [TOOL] }, + }; +} + +function handleToolsCall(id, params) { + const { name, arguments: args } = params; + + if (name !== 'await_pane') { + return { + jsonrpc: '2.0', + id, + result: { + isError: true, + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + }, + }; + } + + const { pane, pattern, long } = args || {}; + + if (!pane) { + return { + jsonrpc: '2.0', + id, + result: { + isError: true, + content: [{ type: 'text', text: 'Missing required parameter: pane' }], + }, + }; + } + + const cmdArgs = ['await', pane]; + if (pattern) cmdArgs.push(pattern); + if (long) cmdArgs.push('--long'); + + try { + const output = execFileSync('lambda-demo', cmdArgs, { + encoding: 'utf8', + timeout: 600_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: output || '(no output)' }], + }, + }; + } catch (err) { + const exitCode = err.status; + const output = (err.stdout || '') + (err.stderr || ''); + + if (exitCode === 2) { + return { + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `TIMEOUT: lambda-demo await timed out (9 min). Re-run to continue waiting.\n${output}`, + }, + ], + }, + }; + } + + return { + jsonrpc: '2.0', + id, + result: { + isError: true, + content: [ + { + type: 'text', + text: `ERROR (exit ${exitCode}): ${output || err.message}`, + }, + ], + }, + }; + } +} + +function dispatch(msg) { + const { method, id, params } = msg; + + switch (method) { + case 'initialize': + return handleInitialize(id); + case 'notifications/initialized': + return null; + case 'tools/list': + return handleToolsList(id); + case 'tools/call': + return handleToolsCall(id, params || {}); + default: + if (id !== undefined) { + return { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + } + return null; + } +} + +const rl = createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + + const response = dispatch(msg); + if (response) { + process.stdout.write(JSON.stringify(response) + '\n'); + } +}); diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..63bccd0 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules/ +dist/ +.next/ +test-results/ +playwright-report/ +.env* +*.local.json +.shutdown_confirmed +.ralph-stop-log diff --git a/.mcp.json b/examples/.mcp.json similarity index 100% rename from .mcp.json rename to examples/.mcp.json diff --git a/.nvmrc b/examples/.nvmrc similarity index 100% rename from .nvmrc rename to examples/.nvmrc diff --git a/CLAUDE.md b/examples/CLAUDE.md similarity index 69% rename from CLAUDE.md rename to examples/CLAUDE.md index 9238ae0..e2087cc 100644 --- a/CLAUDE.md +++ b/examples/CLAUDE.md @@ -7,14 +7,14 @@ Lambda uses three Claude instances (Manager, Dev, QA) collaborating via tmux to | Window | Purpose | | ------------ | -------------------------------- | | `james` | James - orchestration | -| `john` | John - implementation | +| `ron` | Ron - implementation | | `sam` | Sam - testing | | `scratchPad` | General commands (ls, git, etc.) | | `dev-server` | Next.js dev server on port 3000 | ## Personality -You're John. Confident, sharp, and never short on opinions. You know your stuff and you're not shy about it. +You're Ron. Confident, sharp, and never short on opinions. You know your stuff and you're not shy about it. Some things that are very you: @@ -30,41 +30,41 @@ Keep the personality in comments, status updates, and conversation. Code stays c ## Sample App -The `sample/` directory contains a Next.js todo app that agents work on. Check `lambda tmux dev-server` for compile errors when making changes to `sample/`. +The `sample/` directory contains a Next.js todo app that agents work on. Check `lambda-demo tmux dev-server` for compile errors when making changes to `sample/`. ## E2E Tests Tests are in `e2e/todos.spec.ts` and configured via `playwright.config.ts` at the package root. ```bash -lambda e2e todos.spec.ts # Run E2E tests +lambda-demo e2e todos.spec.ts # Run E2E tests ``` ## Lambda CLI -All commands use the `lambda` CLI: +All commands use the `lambda-demo` CLI: ```bash -lambda tmux [command] # Send command or capture output -lambda tmux wait # Wait for pane to finish -lambda tasks next # Get next task -lambda tasks show # Read ticket spec -lambda tasks done # Mark task complete -lambda ship # Branch, commit, push, PR -lambda e2e # Run E2E tests -lambda trace diagnose-all # Debug test failures -lambda start # Create session + launch agents -lambda stop # Kill session +lambda-demo tmux [command] # Send command or capture output +lambda-demo tmux wait # Wait for pane to finish +lambda-demo tasks next # Get next task +lambda-demo tasks show # Read ticket spec +lambda-demo tasks done # Mark task complete +lambda-demo ship # Branch, commit, push, PR +lambda-demo e2e # Run E2E tests +lambda-demo trace diagnose-all # Debug test failures +lambda-demo start # Create session + launch agents +lambda-demo stop # Kill session ``` ## Tmux Polling Patterns After sending a command, check output with backoff: 1s → 4s → 10s → 18s (max). -For long waits, delegate to the adrian agent (burns zero tokens): +For long waits, delegate to the ann agent (burns zero tokens): ``` -Task(subagent_type="adrian", prompt="lambda await john") +Task(subagent_type="ann", prompt="lambda-demo await ron") ``` ## Test-Driven Development @@ -92,5 +92,5 @@ Run these in order. Fix every warning and error. Iterate until all three pass wi The dev server runs automatically in the `dev-server` window on port 3000. Check it for compile errors: ```bash -lambda tmux dev-server +lambda-demo tmux dev-server ``` diff --git a/CONTRIBUTING.md b/examples/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to examples/CONTRIBUTING.md diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..e5a7dd4 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,303 @@ +# λ + +> This is Lambda's original v0, kept under `examples/` for reference. The package itself lives one directory up; this demo's CLI installs as `lambda-demo` so it never clashes with the package's `lambda`. + +Three AI agents collaborate via tmux to autonomously build full-stack features — optimizing for how long they can work without you, not how fast they work while you watch. + +**38+ hours unattended. 15 full-stack tickets. One session.** + +https://github.com/user-attachments/assets/3181c3c6-8f70-4e4b-bb92-e467e0ab174f + +Lambda is not a framework. It's an architecture — **division of labor across agents** and **vision-driven validation**. You don't need to clone this repo. Read the philosophy, take what's useful, build your own. + +Most multi-agent setups optimize for parallelism — more agents working simultaneously. The bottleneck they don't address is babysitting. If you're monitoring agents, restarting stuck sessions, and course-correcting drift, you haven't automated the work — you've just given yourself a different job. Lambda prioritizes unattended operation: a system you can walk away from and come back to a PR. + +An orchestrator that can't code. A developer that can't ship. A tester that can't write production code. E2E screenshots and traces as the feedback loop. These ideas are portable to any stack, any agent framework, any LLM. The implementation here is what worked for me — a reference, not a prescription. + +### Proven at scale + +In a single unattended session, Lambda completed 15 full-stack tickets over 38+ hours. One ticket was blocked due to an infrastructure issue — not an agent failure. The session was supposed to end at bedtime — I thought I'd stopped it. Woke up, found it still running and still completing tickets, and decided to just let it go. + +That number didn't come for free. Early runs maxed out at 4 hours before agents drifted, got stuck, or compounded errors into unrecoverable states. It took weeks of tuning — refining quality gates, tightening agent instructions, improving trace analysis, pushing failure-prone workflows into the CLI — before the system crossed a reliability threshold where sessions stopped dying and just kept going. + +**Expect the same ramp-up.** Every codebase has different failure modes. The patterns in this repo (agent separation, quality gates, vision-based validation) are portable, but dialing them in for your project takes iteration. You'll likely start closer to 4 hours than 38. That's normal — the architecture is designed so that each fix compounds, and once you cross the threshold, the runtime extends dramatically. + +## Contents + +- [How it works](#how-it-works) +- [Quick start](#quick-start) +- [Design philosophy](#design-philosophy) +- [Scaling](#scaling) +- [Architecture](#architecture) +- [CLI](#cli) +- [Transitional architecture](#transitional-architecture) +- [Make it yours](#make-it-yours) +- [Project structure](#project-structure) +- [Security considerations](#security-considerations) +- [Requirements](#requirements) + +## How it works + +``` + ┌──────────────┐ + │ James │ + │ orchestrator │ + └──────┬───────┘ + picks task, delegates, + enforces quality gates + ┌───────┴───────┐ + │ │ + ┌────▼────┐ ┌─────▼────┐ + │ Ron │ │ Sam │ + │ builds │ │ tests │ + └──┬───┬──┘ └─────┬────┘ + │ │ │ + ┌──────────▼┐ └─ fix loop ─┘ + │ Boualam │ + │ cleanup │ ┌──────────┐ + └───────────┘ │ Ann │ + │ waits │ + └──────────┘ +``` + +James delegates to Ron and Sam, gating on each step. Ron builds, self-reviews, and verifies against the ticket. Sam runs E2E tests and analyzes traces. If tests fail, Sam sends diagnostics back and Ron fixes — the loop repeats until tests pass. James ships the PR. Ann handles blocking waits on Haiku so the expensive model isn't burning tokens on idle time. + +The quality gates are customizable — they live in `manager/CLAUDE.md`. Make them stricter, add domain-specific checks, whatever your project needs. The point is having gates at all, not these specific ones. + +## Quick start + +> **You don't need any of this to use Lambda's ideas.** The [design philosophy](#design-philosophy) section is the actual value. If you want to build multi-agent workflows in your own stack, start there. The quick start below is for running the reference implementation. + +Prerequisites: + +- [tmux](https://github.com/tmux/tmux) +- Node.js 20+ +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) +- [iTerm2](https://iterm2.com/) recommended — Lambda auto-detects it and opens tmux windows as native tabs + +```bash +git clone https://github.com/adriancarriger/lambda.git +cd lambda/examples +./init.sh # one-time: install deps, build CLI, install Playwright (runs npm link — may need sudo) +lambda-demo start # launch agents and connect +# switch to James's tab and tell him: "Take the next ticket" +``` + +`lambda-demo start` creates a tmux session with three Claude agents and a Next.js dev server, then connects you to it. + +> **Note:** `lambda-demo ship` defaults to dry-run mode (`DEMO_MODE` in `src/config.ts`). Pass `--no-dry-run` to actually push and create PRs, or set `DEMO_MODE = false` in the config. + +In iTerm2, each agent window (James, Ron, Sam, scratchPad, dev-server) becomes a native tab — no tmux key bindings to learn. In other terminals, use `Ctrl+B w` to switch between tmux windows. + +Disconnected? Run `lambda-demo connect` to reattach. + +### The demo + +The repo ships with a sample Next.js todo app and three demo tasks. For each one, the agents write a failing E2E test, then make it pass. + +| Task | Feature | Spec | +| ------ | ------------------------------------- | ------------------- | +| DEMO-1 | Delete button on each todo | `tickets/DEMO-1.md` | +| DEMO-2 | Completed checkbox with strikethrough | `tickets/DEMO-2.md` | +| DEMO-3 | Filter bar (All / Active / Completed) | `tickets/DEMO-3.md` | + +Each task has a spec in `tickets/`, and `e2e/todos.spec.ts` starts with one test that the sample app loads. The agents read the spec, add a failing test for it to `e2e/todos.spec.ts`, implement the feature, and iterate until the test passes. The demo tasks are intentionally simple — the architecture shines more on complex, multi-file features where the separation of concerns and quality gates actually matter. + +## Design philosophy + +### Do what the human would do + +Most tickets go through the full cycle autonomously. Lambda systematizes the loop a human developer already runs — read the ticket, tell Claude what to build, watch the output, run tests, read the failures, send Claude back to fix — then replaces the human with James. + +The weakest link is flaky E2E tests. When a test passes sometimes and fails others, agents burn cycles chasing phantom failures. Pushing more of that complexity into the CLI (better trace analysis, smarter retry logic) keeps shrinking that gap. + +### Separation of concerns + +Ron writes the code. Sam evaluates it. Separate agents avoid the trap where the agent that wrote the code convinces itself the code is correct. Ron also runs his own code review and ticket verification — James doesn't read code, he just gates on Ron's self-reported grades before handing off to Sam. + +Give a single Claude Code instance a long workflow and it will drift — skip steps, stop following the process, or halt early. James solves this by doing less, not more. He can't write code, can't run shell commands, can't even read the codebase. All he does is delegate, enforce quality gates, and ship when the work passes. He enforces the sequence every time because that's all he does. + +### Reliability over parallelism + +If one agent needs intervention every 20 minutes, running eight of them means you're interrupted every 2.5 minutes. That's not parallelism — it's a full-time monitoring job. Lambda optimizes for unattended runtime: one team that can complete tickets end-to-end without human input. + +The agents are separated for **context isolation**, and context isolation is what makes reliability possible. Sam focuses on testing without Ron's implementation noise in his context. Ron focuses on building without Sam's trace analysis cluttering things up. James just enforces the process. Each agent is better at his job because he only sees what's relevant to his job. Focused agents are more reliable agents, and reliability is what makes unattended operation work. + +This also makes Lambda token-efficient. You're not paying for three agents thinking simultaneously — you're paying for one agent working while two wait. Ann handles the blocking waits on Haiku, so the expensive model isn't burning tokens on repeated `sleep` calls. + +### Tmux as context engineering + +Tmux isn't just a communication channel — it's how each agent gets access to the right context at the right time. Ron can read any tmux window in the session. If the dev server is throwing compile errors, he checks `lambda-demo tmux dev-server`. If the backend is misbehaving, he reads the logs from whatever window they're in. A React compile error, a backend crash, and a failing test are often the same bug seen from three different angles — Ron can triangulate, the same way a human developer would glance at different terminal tabs. + +James, by contrast, can only see Ron's and Sam's windows. He doesn't need dev-server logs or shell access — he just needs to know whether his agents are done and whether they succeeded. The permission hooks enforce this. Different agents, different context, different capabilities. + +### Vision-driven validation + +If you want autonomous front-end development, you need visual feedback. There's no way around it. A text-only agent can't tell you the modal is rendering behind the overlay. + +E2E tests are how Lambda structures that feedback. The agent writes a test, runs it, reads the trace (screenshots, DOM, console), and iterates. This is significantly faster than exploratory QA — the test gives the agent a repeatable, targeted feedback loop. Tests can be throwaway — written fresh for each feature, used purely as a verification mechanism, then discarded. + +### Code-first, vision-second + +Browser MCP tools put the agent behind the steering wheel — look at a screenshot, decide what to click, look at the next screenshot, decide what to type. Every interaction burns vision tokens. A ten-step form fill costs ten rounds of screenshot reasoning. + +Lambda inverts this. The agent writes code that drives the browser. Running that code costs nothing — Playwright executes it deterministically, as many times as needed, for negligible tokens. Vision only enters when the code doesn't work. A failing test produces a trace with screenshots, DOM state, and console logs. The agent reads the trace, fixes the code, and runs again. + +The expensive part (vision-based reasoning) only happens on failure. The cheap part (executing code) handles all the repetition. Write once, run free. + +### Deterministic CLI + +The `lambda-demo` CLI pushes complexity out of the agent's context and into deterministic code. Agents are good at reasoning and writing code. They're less good at reliably executing multi-step shell workflows or remembering to follow a twelve-step process every time. Shipping a PR is `lambda-demo ship`, not "create branch, stage files, commit, push, open PR, and don't forget the ticket ID." + +Whatever repetitive workflow your agents keep getting wrong, push it into a CLI command. The agent calls one command; the CLI handles the rest. + +## Scaling + +Once one team works reliably, you scale by spinning up a second team on a different feature set. Two teams, two backlogs, two machines. Each team pulls from different task filters in your ticketing system. This is how human teams scale — independent pods working on separate features, not more people on one task. + +The todo app in `sample/` is a demo target. Point Lambda at any codebase — replace the sample app, write different tickets, configure different tests. The architecture is the same; only the inputs change. + +This scaling model works in principle but has rough edges in practice. Context windows fill up on long sessions. Flaky tests burn cycles. The architecture is sound; the implementation is still maturing. + +## Architecture + +### Agent roles + +| Agent | Role | Can do | Cannot do | +| ------- | -------------- | ------------------------------------------------------------------- | ----------------------------------------------------------- | +| James | Orchestration | Manage tasks, delegate to Ron/Sam, enforce quality gates, ship PRs | Write code, run shell commands, read the codebase | +| Ron | Implementation | Write code, build, self-review, verify ticket completeness | Push, commit, ship, talk to Sam directly | +| Sam | Validation | Run E2E tests, analyze Playwright traces | Write application code | +| Boualam | Cleanup | Format, lint, type check — iterates until all pass | Architectural decisions, adding eslint-disable, refactoring | +| Ann | Waiting | Block until a pane finishes, burn zero tokens on expensive models | Anything else — only has the `await_pane` tool | + +### Communication + +Agents coordinate through tmux, not Claude Code native Teams. James sends commands and messages via the `lambda-demo tmux` CLI: + +```bash +lambda-demo tmux ron 'Implement DEMO-1. See tickets/DEMO-1.md for the spec.' +lambda-demo tmux sam 'Run E2E tests: lambda-demo e2e todos.spec.ts' +lambda-demo tmux ron # Read Ron's output +``` + +The `lambda-demo tmux` command auto-cycles the target pane to "accept edits" mode before sending, so agents never get stuck on permission prompts. + +### Key patterns + +- **Trace analysis**: Sam extracts screenshots, DOM state, and console logs from Playwright traces to produce structured failure diagnostics. +- **Ann subagent**: Long blocking waits (waiting for Ron to finish, waiting for E2E to complete) are delegated to a Haiku-powered Ann subagent. Zero tokens burned on the expensive model while waiting. +- **Boualam subagent**: Ron delegates format/lint/typecheck busywork to the Boualam subagent that iterates until all checks pass. Keeps Ron's context clean and focused on implementation. Won't make architectural decisions — escalates back to Ron after 3 failed attempts. +- **Permission lockdown**: James can only run `lambda-demo` CLI commands — enforced by a permission hook (`manager/scripts/permission-hook.cjs`). Ron and Sam are blocked from committing or pushing by their own hook (`dev-permission-hook.cjs`). +- **Ralph stop-hook**: Prevents James from exiting while tasks remain in the queue (`manager/scripts/ralph-stop-hook.sh`). + +## CLI + +```bash +# Session +lambda-demo start # Create tmux session, launch agents, connect +lambda-demo start --no-agents # Create session without launching agents +lambda-demo stop # Kill the tmux session +lambda-demo connect # Reattach to an existing session + +# Tmux +lambda-demo tmux [command] # Send command or read output from a pane +lambda-demo await [--long] # Block until pane is idle + +# Tasks +lambda-demo tasks list # Show all tasks from TASKS.md +lambda-demo tasks next # Get next available task +lambda-demo tasks show # Read ticket spec from tickets/.md +lambda-demo tasks done # Mark task as completed +lambda-demo tasks block # Mark task as blocked (--reason) + +# Testing +lambda-demo e2e [args...] # Run Playwright E2E tests +lambda-demo trace diagnose-all # Debug all Playwright traces +lambda-demo trace summary # Summarize trace results +lambda-demo trace errors # Extract errors from a trace + +# Shipping +lambda-demo ship # Branch, commit, push, open PR + +# Agent management +lambda-demo agent compact -w # Compact agent context +lambda-demo agent clear --self # Clear context with handoff message +lambda-demo agent whoami # Print current tmux window name +``` + +## Transitional architecture + +Lambda uses tmux because Claude Code Teams was too experimental when this was built (Feb 2026). Tmux is reliable, simple, and proven over many autonomous sessions. + +Lambda isn't building a moat. If Claude Code Teams fully replicates the needed feature set — reliable multi-agent coordination, permission isolation, context separation — the tmux layer gets replaced. That's a good outcome. The ideas (agent separation, quality gates, vision-based validation) are independent of the transport layer. + +As Claude Code Teams matures and ships pieces of this functionality, Lambda will adopt them incrementally. The architecture is designed so that swap would be a localized change — agent definitions, CLI commands, and permission model stay the same. + +## Make it yours + +This repo is a reference implementation, not the point. Lambda is the architecture — agent separation, quality gates, vision-based validation. If cloning and modifying works for you, great. If you'd rather build your own version from scratch using these ideas, that's probably even better. + +- **Use the workflow, not the repo.** Lambda mimics how human teams already work — tickets, PRs, code review, QA handoff, fix loops. If your team follows this flow, Lambda is a drop-in. If not, take the ideas that fit and leave the rest. +- **Point your own app at it.** Replace `sample/` with your project, update `playwright.config.ts` to point at your dev server, and write your tasks in `TASKS.md`. +- **Add your own tasks.** Write specs in `tickets/`, and add entries to `TASKS.md`. The agents will pick them up, writing the failing E2E test first. +- **Plug in your ticket system.** The `lambda-demo tasks` commands are a thin interface over `TASKS.md` and `tickets/`. Swap them out to pull from Linear, Jira, GitHub Issues, or whatever you use. There's a commented-out Linear integration stub in `src/commands/tasks/index.ts` as a starting point. +- **Customize agent behavior.** The root `CLAUDE.md` contains instructions for Ron and Sam. `manager/CLAUDE.md` controls James's orchestration loop. +- **Extend the CLI.** TypeScript + Commander.js in `src/commands/`. Add new commands by creating a directory with an `index.ts` that exports a registration function. +- **Adjust permissions.** James's permissions: `manager/.claude/settings.json` + `manager/scripts/permission-hook.cjs`. Ron/Sam: `.claude/settings.json` + `.claude/agents/dev-permission-hook.cjs`. + +## Project structure + +``` +examples/ +├── src/commands/ # CLI command implementations +├── manager/ +│ ├── CLAUDE.md # James orchestration instructions +│ └── scripts/ # Permission hooks, stop-hook +├── sample/ # Next.js todo app (the demo target) +├── e2e/ # Playwright E2E tests +├── tickets/ # Task specs (DEMO-1.md, DEMO-2.md, DEMO-3.md) +├── CLAUDE.md # Ron/Sam agent instructions +├── TASKS.md # Task queue +├── init.sh # One-time setup (install deps, build CLI) +└── playwright.config.ts # E2E test configuration +``` + +## Security considerations + +Lambda's permission model is enforced by prompt instructions and hooks, not by hard sandboxing. You should understand the limitations before running it unattended. + +**The core issue: tmux is a shared surface.** James can send arbitrary text to Ron and Sam via `lambda-demo tmux`, including granting permission prompts (`lambda-demo tmux ron --raw 'y'`). This is by design — James needs to unblock Ron when legitimate permission requests come up. But it means Ron can request escalations and James will typically approve them. + +**What the hooks enforce:** + +- Ron and Sam are blocked from `git push`, `git commit`, and `lambda-demo ship` by a `PreToolUse` hook (`dev-permission-hook.cjs`). This is a real gate — the hook runs before the command and denies it programmatically. +- James is restricted to `lambda-demo` CLI commands by his own hook (`manager/scripts/permission-hook.cjs`). He can't run arbitrary shell commands. + +**What the hooks don't enforce:** + +- James is explicitly set up to grant Ron's permission requests (`lambda-demo tmux ron --raw 'y'` is in James's command reference). You could instruct James to always deny, but there's no guarantee a prompt-level constraint holds under all conditions. +- Ron has full filesystem access within his Claude Code session. The hooks block specific commands, not filesystem operations. +- Nothing prevents prompt injection via task specs or code content that agents read during work. + +**My approach:** I run Lambda on a dedicated machine with limited access to sensitive systems. The permission hooks catch accidental mistakes (Ron trying to push, James running shell commands), and the prompt instructions handle the rest. This is an acceptable tradeoff for my goals. + +**If you need stronger isolation**, wait for Claude Code native Teams, which will provide process-level separation between agents. The README's [transitional architecture section](#transitional-architecture) explains why I haven't migrated yet. The architecture is designed so that migration would be a localized change. + +**Practical recommendations:** + +- Run on a dedicated machine or VM, not your primary development machine +- Don't store secrets (API keys, credentials) in the project directory +- Review PRs before merging +- Audit the permission hooks if you modify them — they're the only hard gates + +## Requirements + +- macOS or Linux +- Node.js 20+ +- tmux +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) + +## License + +MIT diff --git a/TASKS.md b/examples/TASKS.md similarity index 100% rename from TASKS.md rename to examples/TASKS.md diff --git a/examples/bin/lambda-demo b/examples/bin/lambda-demo new file mode 100755 index 0000000..b8d87c4 --- /dev/null +++ b/examples/bin/lambda-demo @@ -0,0 +1,11 @@ +#!/usr/bin/env node +const path = require('path'); +const fs = require('fs'); + +const cliPath = path.join(__dirname, '..', 'dist', 'cli.js'); +if (!fs.existsSync(cliPath)) { + console.error('Error: CLI not built yet. Run ./init.sh first.'); + process.exit(1); +} + +require(cliPath); diff --git a/docs/blog/38-hours.md b/examples/docs/blog/38-hours.md similarity index 96% rename from docs/blog/38-hours.md rename to examples/docs/blog/38-hours.md index d03798a..18b34df 100644 --- a/docs/blog/38-hours.md +++ b/examples/docs/blog/38-hours.md @@ -4,7 +4,7 @@ I left a multi-agent coding system running by accident. I thought I'd shut it down before bed. I hadn't. -I woke up to find it still running. It had been working through the night — picking up tickets, implementing features, writing tests, pushing commits — on a side project. Tickets ranged from small fixes to full features with UI, API calls, third-party integrations, and complex business logic. They were sequentially dependent, each building on data models and API flows from earlier ones. +I woke up to find it still running. It had been working through the night — picking up tickets, implementing features, writing tests, pushing commits — on a side project. Tickets ranged from small fixes to full features touching the UI, the API and the data model. They were sequentially dependent, each building on what earlier ones had added. Earlier versions were genuinely bad, so I expected the worst. But the features were implemented correctly. Tests were real — not gamed, not shallow, with full E2E coverage on each ticket. CI green across the board. So I let it keep going. 38 hours total. 15 tickets completed before it finally got stuck — not on a coding failure, but an infrastructure issue I hadn't given it the tools to handle. @@ -27,7 +27,7 @@ The answer wasn't more agents — it was better structure around fewer. I built The architecture is three agents communicating via tmux: - **James (Manager):** Delegates tasks, enforces quality gates, ships PRs. Cannot write code or read the codebase. -- **John (Developer):** Implements features, runs self-review. Cannot push, commit, or ship. +- **Ron (Developer):** Implements features, runs self-review. Cannot push, commit, or ship. - **Sam (Tester):** Runs Playwright E2E tests, analyzes traces (screenshots, DOM, console logs). Cannot modify application code. The agent that wrote the code isn't the one evaluating it — single-agent setups routinely fall into the trap where the agent convinces itself its own code is correct. And the permission boundaries aren't suggestions — they're enforced by hooks. The developer literally can't ship code. The manager literally can't write it. diff --git a/e2e/todos.spec.ts b/examples/e2e/todos.spec.ts similarity index 100% rename from e2e/todos.spec.ts rename to examples/e2e/todos.spec.ts diff --git a/eslint.config.mjs b/examples/eslint.config.mjs similarity index 92% rename from eslint.config.mjs rename to examples/eslint.config.mjs index 98f55cf..7cff90b 100644 --- a/eslint.config.mjs +++ b/examples/eslint.config.mjs @@ -20,7 +20,7 @@ export default tseslint.config( { selector: "VariableDeclaration[kind='let']", message: - "Use const. If mutation is needed, add an eslint-disable comment.", + "Use const. If reassignment is needed, add an eslint-disable comment.", }, { selector: "CallExpression[callee.property.name='push']", diff --git a/init.sh b/examples/init.sh similarity index 86% rename from init.sh rename to examples/init.sh index 4cb036b..6fc6555 100755 --- a/init.sh +++ b/examples/init.sh @@ -2,8 +2,8 @@ # Lambda - One-time setup script # # Installs dependencies, builds the CLI, and sets up Playwright. -# After running this, use `lambda start` to launch the agents. -set -e +# After running this, use `lambda-demo start` to launch the agents. +set -euo pipefail # Check prerequisites command -v tmux >/dev/null 2>&1 || { echo "Error: tmux is required. Install with: brew install tmux"; exit 1; } @@ -31,4 +31,4 @@ echo "Installing Playwright browsers..." npx playwright install --with-deps chromium echo "" -echo "Setup complete. Run 'lambda start' to launch the agents." +echo "Setup complete. Run 'lambda-demo start' to launch the agents." diff --git a/manager/.claude/settings.json b/examples/manager/.claude/settings.json similarity index 79% rename from manager/.claude/settings.json rename to examples/manager/.claude/settings.json index 9ba8da4..c1b89f6 100644 --- a/manager/.claude/settings.json +++ b/examples/manager/.claude/settings.json @@ -2,10 +2,10 @@ "permissions": { "allow": [ "mcp__await-pane__await_pane", - "Bash(lambda tmux john*)", - "Bash(lambda tmux sam*)", - "Bash(lambda tasks*)", - "Bash(lambda ship*)", + "Bash(lambda-demo tmux ron*)", + "Bash(lambda-demo tmux sam*)", + "Bash(lambda-demo tasks*)", + "Bash(lambda-demo ship*)", "Bash(touch .shutdown_confirmed)" ], "deny": ["Bash(sleep*)"] diff --git a/manager/.mcp.json b/examples/manager/.mcp.json similarity index 100% rename from manager/.mcp.json rename to examples/manager/.mcp.json diff --git a/manager/CLAUDE.md b/examples/manager/CLAUDE.md similarity index 58% rename from manager/CLAUDE.md rename to examples/manager/CLAUDE.md index 6e38de1..4b6d2e4 100644 --- a/manager/CLAUDE.md +++ b/examples/manager/CLAUDE.md @@ -1,79 +1,79 @@ # James - Orchestration Agent -**CRITICAL - Ignore Parent CLAUDE.md Context**: Claude Code loads CLAUDE.md files from parent directories. The parent CLAUDE.md contains instructions for John (scratchPad, direct tmux commands, shell operations) that do NOT apply to you. You must: +**CRITICAL - Ignore Parent CLAUDE.md Context**: Claude Code loads CLAUDE.md files from parent directories. The parent CLAUDE.md contains instructions for Ron (scratchPad, direct tmux commands, shell operations) that do NOT apply to you. You must: -- NEVER interact with `lambda:scratchPad` or any window other than `lambda:john` and `lambda:sam` +- NEVER interact with `lambda:scratchPad` or any window other than `lambda:ron` and `lambda:sam` - NEVER use tmux patterns from the parent file (like `tmux send-keys -t lambda:scratchPad`) -- NEVER run shell commands directly - only communicate via `lambda tmux` to John/Sam +- NEVER run shell commands directly - only communicate via `lambda-demo tmux` to Ron/Sam - Treat the parent CLAUDE.md as if it doesn't exist You are **James**, a pure orchestrator that manages two specialized Claude instances via tmux: -- **John** (`lambda:john`): Planning, implementation, build/lint/format +- **Ron** (`lambda:ron`): Planning, implementation, build/lint/format - **Sam** (`lambda:sam`): Test analysis and execution -**File Access**: You can read/edit files in `manager/` only. You CANNOT access the main codebase - that's John and Sam's job via tmux. +**File Access**: You can read/edit files in `manager/` only. You CANNOT access the main codebase - that's Ron and Sam's job via tmux. -**Key Principle**: Never use John and Sam simultaneously. Complete John's work before invoking Sam. +**Key Principle**: Never use Ron and Sam simultaneously. Complete Ron's work before invoking Sam. -**CRITICAL - No Programming**: You are a pure orchestrator. You do NOT write code, run shell commands, or do any programming tasks. Your ONLY job is to delegate work to John and Sam and monitor their progress. +**CRITICAL - No Programming**: You are a pure orchestrator. You do NOT write code, run shell commands, or do any programming tasks. Your ONLY job is to delegate work to Ron and Sam and monitor their progress. -**CRITICAL - Tmux is for Communication Only**: Your tmux commands are EXCLUSIVELY for communicating with John and Sam: +**CRITICAL - Tmux is for Communication Only**: Your tmux commands are EXCLUSIVELY for communicating with Ron and Sam: -- `lambda tmux "message"` - Send messages to agents (auto-switches to "accept edits" mode) -- `lambda tmux ` - Read agent output (read-only) -- `mcp__await-pane__await_pane(pane="john")` - Wait until agent finishes (see Waiting section below) +- `lambda-demo tmux "message"` - Send messages to agents (auto-switches to "accept edits" mode) +- `lambda-demo tmux ` - Read agent output (read-only) +- `mcp__await-pane__await_pane(pane="ron")` - Wait until agent finishes (see Waiting section below) **NEVER use**: -- Raw `tmux send-keys` commands (use `lambda tmux` instead) -- Any interaction with `lambda:scratchPad` (that's John's workspace) +- Raw `tmux send-keys` commands (use `lambda-demo tmux` instead) +- Any interaction with `lambda:scratchPad` (that's Ron's workspace) - Commands like `ls`, `git status`, etc. - even via tmux -**Exception**: For interrupt signals (`Escape`, `C-c`), use `lambda tmux john Escape` or `lambda tmux john C-c`. +**Exception**: For interrupt signals (`Escape`, `C-c`), use `lambda-demo tmux ron Escape` or `lambda-demo tmux ron C-c`. -If you need information, ask John or Sam to get it. +If you need information, ask Ron or Sam to get it. -**CRITICAL - Push Rules**: John and Sam must NEVER push. Always deny `git push`. Only James handles git operations via `lambda ship`. +**CRITICAL - Push Rules**: Ron and Sam must NEVER push. Always deny `git push`. Only James handles git operations via `lambda-demo ship`. -**CRITICAL - Read Output Carefully / When In Doubt, Redo**: When reading John or Sam output, be careful and precise. If the output is ambiguous, unclear, or you're not 100% certain the step succeeded, have John redo the step. There is no cost to asking John to redo something, but there is a huge cost to incorrectly advancing. +**CRITICAL - Read Output Carefully / When In Doubt, Redo**: When reading Ron or Sam output, be careful and precise. If the output is ambiguous, unclear, or you're not 100% certain the step succeeded, have Ron redo the step. There is no cost to asking Ron to redo something, but there is a huge cost to incorrectly advancing. -**CRITICAL - Never use `cd`**: Your working directory is `manager/`. Never prefix commands with `cd`. Run `lambda ship`, etc. directly. +**CRITICAL - Never use `cd`**: Your working directory is `manager/`. Never prefix commands with `cd`. Run `lambda-demo ship`, etc. directly. **CRITICAL - Always Fix Everything**: If you encounter ANY issue during work - fix it. This includes broken tests, lint warnings, E2E failures, type errors, formatting issues. --- -## Waiting for John/Sam +## Waiting for Ron/Sam -Use the `mcp__await-pane__await_pane` MCP tool to block until John or Sam finishes work. This blocks until pane output stops changing. +Use the `mcp__await-pane__await_pane` MCP tool to block until Ron or Sam finishes work. This blocks until pane output stops changing. -**IMPORTANT — Do NOT use pattern matching** for John/Sam panes. Patterns will match against command text visible on screen, causing false matches. +**IMPORTANT — Do NOT use pattern matching** for Ron/Sam panes. Patterns will match against command text visible on screen, causing false matches. ### Typical workflow ``` -# 1. Send work to John -lambda tmux john 'Please implement DEMO-1...' +# 1. Send work to Ron +lambda-demo tmux ron 'Please implement DEMO-1...' -# 2. Wait until John finishes (use the MCP tool directly): -mcp__await-pane__await_pane(pane="john") +# 2. Wait until Ron finishes (use the MCP tool directly): +mcp__await-pane__await_pane(pane="ron") # 3. Read the full output to evaluate -lambda tmux john +lambda-demo tmux ron ``` ### Waiting for long-running operations ``` -mcp__await-pane__await_pane(pane="john", long=true) +mcp__await-pane__await_pane(pane="ron", long=true) ``` ### No wait needed after simple sends -`lambda tmux john 'message'` already does a 500ms auto-read and returns the pane output. Only use `mcp__await-pane__await_pane` when you need to block until John/Sam **finishes working**. +`lambda-demo tmux ron 'message'` already does a 500ms auto-read and returns the pane output. Only use `mcp__await-pane__await_pane` when you need to block until Ron/Sam **finishes working**. -**CRITICAL**: Do NOT use the `adrian` subagent or `Task(subagent_type="adrian")`. Use `mcp__await-pane__await_pane` directly. +**CRITICAL**: Do NOT use the `ann` subagent or `Task(subagent_type="ann")`. Use `mcp__await-pane__await_pane` directly. --- @@ -87,8 +87,8 @@ mcp__await-pane__await_pane(pane="john", long=true) │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ - │ JOHN │ │ SAM │ - │ (lambda:john) │ │ (lambda:sam) │ + │ RON │ │ SAM │ + │ (lambda:ron) │ │ (lambda:sam) │ │ │ │ │ │ • Planning │ │ • Test analysis │ │ • Implementation│ │ • Unit tests │ @@ -104,8 +104,8 @@ mcp__await-pane__await_pane(pane="john", long=true) ### Step 1: Pick Up Task -1. **Get next task**: Run `lambda tasks next` to get the next task ID -2. **Get ticket details**: Run `lambda tasks show ` to read the spec +1. **Get next task**: Run `lambda-demo tasks next` to get the next task ID +2. **Get ticket details**: Run `lambda-demo tasks show ` to read the spec 3. **Save the requirements** — you'll need them for verification later **If no tasks**: Report "No tasks. Awaiting instructions." and stop. @@ -114,21 +114,21 @@ mcp__await-pane__await_pane(pane="john", long=true) --- -### Step 2: Assign to John +### Step 2: Assign to Ron -1. **Send task to John**: `lambda tmux john 'Please implement . Write a failing test first, then implement until it passes. Here are the requirements: ...'` -2. **Wait until John finishes** using `mcp__await-pane__await_pane(pane="john")` -3. **Read output**: `lambda tmux john` -4. **If John asks questions** → Answer them, wait again +1. **Send task to Ron**: `lambda-demo tmux ron 'Please implement . Write a failing test first, then implement until it passes. Here are the requirements: ...'` +2. **Wait until Ron finishes** using `mcp__await-pane__await_pane(pane="ron")` +3. **Read output**: `lambda-demo tmux ron` +4. **If Ron asks questions** → Answer them, wait again **Completion indicators**: - `>` prompt at bottom = waiting for input (done) - Output unchanged across 2 polls = likely done -**If John seems stuck** (going in circles): +**If Ron seems stuck** (going in circles): -1. Clear: `lambda tmux john '/clear'` +1. Clear: `lambda-demo tmux ron '/clear'` 2. Re-issue task with clearer instructions 3. If stuck after 2 restarts → Go to Step 8 (Blocked) @@ -138,10 +138,10 @@ mcp__await-pane__await_pane(pane="john", long=true) ### Step 3: Code Cleanup -Tell John to delegate cleanup to the boualam subagent, then handle anything boualam couldn't fix. +Tell Ron to delegate cleanup to the boualam subagent, then handle anything boualam couldn't fix. ``` -lambda tmux john 'Delegate cleanup to the boualam subagent: +lambda-demo tmux ron 'Delegate cleanup to the boualam subagent: 1. Format, lint, and type check (boualam handles the iterative loop) 2. After boualam finishes, manually remove any debugging artifacts: console.log, .only() in tests, commented-out code, screenshots 3. Remove TODO/FIXME comments that are resolved by this change @@ -150,7 +150,7 @@ lambda tmux john 'Delegate cleanup to the boualam subagent: Confirm all checks pass with zero warnings.' ``` -**Wait → read output.** ALL checks must pass. If any fail → John fixes and repeats (unlimited retries). +**Wait → read output.** ALL checks must pass. If any fail → Ron fixes and repeats (unlimited retries). → Go to Step 4 @@ -158,10 +158,10 @@ Confirm all checks pass with zero warnings.' ### Step 4: Code Review -Tell John to review his own changes and report a grade. +Tell Ron to review his own changes and report a grade. ``` -lambda tmux john 'Review all uncommitted changes and grade them. Check for: +lambda-demo tmux ron 'Review all uncommitted changes and grade them. Check for: Hard blockers (any = grade D or F): - eslint-disable / @ts-ignore without justification @@ -181,12 +181,12 @@ Quality: Security: - No exposed secrets - Input validation at system boundaries -- Permission checks on mutations if applicable +- Permission checks on write operations if applicable Report a letter grade (A+ to F) and list any issues found. Fix all issues before reporting.' ``` -**Wait → read output.** Grade must be **B or higher** to proceed. If below B → John fixes and re-reviews. +**Wait → read output.** Grade must be **B or higher** to proceed. If below B → Ron fixes and re-reviews. → Go to Step 5 @@ -194,10 +194,10 @@ Report a letter grade (A+ to F) and list any issues found. Fix all issues before ### Step 5: Ticket Verification -Tell John to verify completeness against the original ticket spec. +Tell Ron to verify completeness against the original ticket spec. ``` -lambda tmux john 'Verify your implementation is complete against the ticket requirements: +lambda-demo tmux ron 'Verify your implementation is complete against the ticket requirements: @@ -210,7 +210,7 @@ Check each requirement: Report COMPLETE or INCOMPLETE. If incomplete, list exactly what is missing.' ``` -**Wait → read output.** Must be **COMPLETE** to proceed. If INCOMPLETE → John implements missing items, then re-run from Step 3. +**Wait → read output.** Must be **COMPLETE** to proceed. If INCOMPLETE → Ron implements missing items, then re-run from Step 3. → Go to Step 6 @@ -221,7 +221,7 @@ Report COMPLETE or INCOMPLETE. If incomplete, list exactly what is missing.' 1. **Send to Sam** with specific test instructions: ``` -lambda tmux sam 'Please test the changes for . +lambda-demo tmux sam 'Please test the changes for . Requirements that were implemented: @@ -235,7 +235,7 @@ Report pass/fail for each check with details.' ``` 2. **Wait until Sam finishes**: `mcp__await-pane__await_pane(pane="sam")` -3. **Read output**: `lambda tmux sam` +3. **Read output**: `lambda-demo tmux sam` → Go to Step 7 @@ -243,12 +243,12 @@ Report pass/fail for each check with details.' ### Step 7: Evaluate Sam's Result -**If Sam PASSES**: Ship changes with `lambda ship `, then → Go to Step 1 +**If Sam PASSES**: Ship changes with `lambda-demo ship `, then → Go to Step 1 **If Sam FAILS** (max 10 attempts per criterion): -1. Hand back to John with specific failure details from Sam -2. Wait for John to fix +1. Hand back to Ron with specific failure details from Sam +2. Wait for Ron to fix 3. Re-run Code Cleanup (Step 3) 4. Re-run Code Review (Step 4) 5. Re-run Ticket Verification (Step 5) @@ -261,7 +261,7 @@ Report pass/fail for each check with details.' **Only after exhausting recovery options:** -1. Run `lambda tasks block --reason "Description of what failed and why"` +1. Run `lambda-demo tasks block --reason "Description of what failed and why"` 2. Report status and wait for guidance --- @@ -272,24 +272,24 @@ Report pass/fail for each check with details.' | Action | Command | | ---------------- | ------------------------------------------ | -| Send to John | `lambda tmux john 'msg'` | -| Send to Sam | `lambda tmux sam 'msg'` | -| Read John output | `lambda tmux john` | -| Read Sam output | `lambda tmux sam` | -| Wait for John | `mcp__await-pane__await_pane(pane="john")` | +| Send to Ron | `lambda-demo tmux ron 'msg'` | +| Send to Sam | `lambda-demo tmux sam 'msg'` | +| Read Ron output | `lambda-demo tmux ron` | +| Read Sam output | `lambda-demo tmux sam` | +| Wait for Ron | `mcp__await-pane__await_pane(pane="ron")` | | Wait for Sam | `mcp__await-pane__await_pane(pane="sam")` | -| Grant permission | `lambda tmux john --raw 'y'` | -| Clear context | `lambda tmux john '/clear'` | +| Grant permission | `lambda-demo tmux ron --raw 'y'` | +| Clear context | `lambda-demo tmux ron '/clear'` | ### Lambda CLI | Action | Command | | ------------------ | ------------------------------------------ | -| Get next task | `lambda tasks next` | -| Show ticket | `lambda tasks show ` | -| Mark task done | `lambda tasks done ` | -| Ship changes | `lambda ship ` | -| Wait for pane idle | `mcp__await-pane__await_pane(pane="john")` | +| Get next task | `lambda-demo tasks next` | +| Show ticket | `lambda-demo tasks show ` | +| Mark task done | `lambda-demo tasks done ` | +| Ship changes | `lambda-demo ship ` | +| Wait for pane idle | `mcp__await-pane__await_pane(pane="ron")` | ### Retry Limits @@ -314,15 +314,15 @@ Report pass/fail for each check with details.' Send `/compact` at good stopping points: -- Before assigning a new task to John -- After John completes a major piece of work +- Before assigning a new task to Ron +- After Ron completes a major piece of work - When transitioning between phases Example: ```bash -lambda tmux john '/compact' -lambda tmux john 'Now please work on X...' +lambda-demo tmux ron '/compact' +lambda-demo tmux ron 'Now please work on X...' ``` ### Follow-up instructions (separate message) @@ -330,8 +330,8 @@ lambda tmux john 'Now please work on X...' **IMPORTANT**: Follow-up instructions MUST be a separate message: ```bash -lambda tmux john '/compact' -lambda tmux john 'Please work on X next' +lambda-demo tmux ron '/compact' +lambda-demo tmux ron 'Please work on X next' ``` --- @@ -340,10 +340,10 @@ lambda tmux john 'Please work on X next' | Actor | Commit | Push | Branch Create | | ----- | ------ | ---- | ------------- | -| John | No | No | No | +| Ron | No | No | No | | Sam | No | No | No | | James | Yes | Yes | Yes | -James uses `lambda ship ` which handles commit, push, branch creation, and PR creation in one command. +James uses `lambda-demo ship ` which handles commit, push, branch creation, and PR creation in one command. -**CRITICAL**: John and Sam must NEVER commit or push. Always deny `git commit` and `git push`. +**CRITICAL**: Ron and Sam must NEVER commit or push. Always deny `git commit` and `git push`. diff --git a/manager/scripts/permission-hook.cjs b/examples/manager/scripts/permission-hook.cjs similarity index 54% rename from manager/scripts/permission-hook.cjs rename to examples/manager/scripts/permission-hook.cjs index b4f0a4f..945c6e4 100644 --- a/manager/scripts/permission-hook.cjs +++ b/examples/manager/scripts/permission-hook.cjs @@ -59,12 +59,43 @@ if (command.includes('.shutdown_confirmed')) { deny('Use relative path: touch .shutdown_confirmed (not absolute path).'); } +// A prefix only vouches for one command: anything the shell would run as a +// second one (`;`, `&&`, `|`, a redirect, `$(…)`, backticks, a new line) is +// refused, unless it sits inside single quotes, where the shell reads it as +// plain text (the messages sent to Ron and Sam; the CLI hands its arguments +// to tmux, git and gh as argument arrays, never through a shell). A backslash +// outside single quotes is refused too: `\'` is a literal quote to the shell, +// so skipping it would misread where the quoted text ends. +function chainsCommands(text) { + let quote = null; + for (const char of text) { + if (quote === "'") { + if (char === "'") quote = null; + continue; + } + if (char === '\n' || char === '`' || char === '$' || char === '\\') return true; + if (quote === '"') { + if (char === '"') quote = null; + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (';&|<>'.includes(char)) return true; + } + return false; +} +if (chainsCommands(command)) { + deny('One command at a time: no ; && | redirects $( ) backticks or backslashes outside single quotes.'); +} + // Prefix match allow list const prefixes = [ - 'lambda tmux john', - 'lambda tmux sam', - 'lambda tasks', - 'lambda ship', + 'lambda-demo tmux ron', + 'lambda-demo tmux sam', + 'lambda-demo tasks', + 'lambda-demo ship', ]; if (prefixes.some((p) => command.startsWith(p))) { allow(); diff --git a/manager/scripts/ralph-stop-hook.sh b/examples/manager/scripts/ralph-stop-hook.sh similarity index 79% rename from manager/scripts/ralph-stop-hook.sh rename to examples/manager/scripts/ralph-stop-hook.sh index 8557fa9..da475d0 100755 --- a/manager/scripts/ralph-stop-hook.sh +++ b/examples/manager/scripts/ralph-stop-hook.sh @@ -35,12 +35,12 @@ if [ "$COUNT" -ge "$THRESHOLD" ]; then exit 0 fi -# Capture current state of John and Sam panes -DEV_STATE=$(tmux capture-pane -t lambda:john -p 2>/dev/null | tail -30) +# Capture current state of Ron and Sam panes +DEV_STATE=$(tmux capture-pane -t lambda:ron -p 2>/dev/null | tail -30) QA_STATE=$(tmux capture-pane -t lambda:sam -p 2>/dev/null | tail -30) # Check for next available task -NEXT_TASK=$(lambda tasks next 2>/dev/null || echo "") +NEXT_TASK=$(lambda-demo tasks next 2>/dev/null || echo "") # Output the context to stderr (this becomes the message Claude sees) # Then exit 2 to block the stop and re-prompt @@ -50,7 +50,7 @@ cat >&2 << EOF ## Next Task: ${NEXT_TASK:-No tasks available} -## John Pane (lambda:john) - last 30 lines: +## Ron Pane (lambda:ron) - last 30 lines: \`\`\` $DEV_STATE \`\`\` @@ -61,11 +61,11 @@ $QA_STATE \`\`\` ## Required Checks: -1. Is there a next task available from \`lambda tasks next\`? -2. Is John working (shows "Thinking/Wizarding") or waiting for input? +1. Is there a next task available from \`lambda-demo tasks next\`? +2. Is Ron working (shows "Thinking/Wizarding") or waiting for input? 3. Is Sam running tests or needs invocation? -**If ANY work remains**: Continue working - poll John/Sam, grant permissions, etc. +**If ANY work remains**: Continue working - poll Ron/Sam, grant permissions, etc. **If truly idle**: Run \`touch .shutdown_confirmed\` then try to exit again. EOF diff --git a/examples/package-lock.json b/examples/package-lock.json new file mode 100644 index 0000000..0c3cb7e --- /dev/null +++ b/examples/package-lock.json @@ -0,0 +1,2101 @@ +{ + "name": "lambda-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lambda-cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "commander": "^11.1.0" + }, + "bin": { + "lambda-demo": "bin/lambda-demo" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.48.0", + "@types/node": "^20.10.0", + "eslint": "^9.39.2", + "prettier": "^3.4.0", + "tsx": "^4.6.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.55.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.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/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..c7644e1 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,54 @@ +{ + "name": "lambda-cli", + "version": "0.1.0", + "description": "Lambda CLI - Multi-agent development orchestration", + "main": "dist/cli.js", + "bin": { + "lambda-demo": "./bin/lambda-demo" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "clean": "rm -rf dist", + "test": "npx playwright test", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write '**/*.{ts,tsx,js,jsx,json,css,md}'", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "lambda", + "cli", + "multi-agent", + "claude", + "ai", + "tmux", + "orchestration" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/adriancarriger/lambda.git" + }, + "homepage": "https://github.com/adriancarriger/lambda", + "bugs": { + "url": "https://github.com/adriancarriger/lambda/issues" + }, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^11.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.48.0", + "@types/node": "^20.10.0", + "eslint": "^9.39.2", + "prettier": "^3.4.0", + "tsx": "^4.6.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.55.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/playwright.config.ts b/examples/playwright.config.ts similarity index 100% rename from playwright.config.ts rename to examples/playwright.config.ts diff --git a/sample/app/globals.css b/examples/sample/app/globals.css similarity index 100% rename from sample/app/globals.css rename to examples/sample/app/globals.css diff --git a/sample/app/layout.tsx b/examples/sample/app/layout.tsx similarity index 100% rename from sample/app/layout.tsx rename to examples/sample/app/layout.tsx diff --git a/sample/app/page.tsx b/examples/sample/app/page.tsx similarity index 100% rename from sample/app/page.tsx rename to examples/sample/app/page.tsx diff --git a/sample/app/todos/page.tsx b/examples/sample/app/todos/page.tsx similarity index 100% rename from sample/app/todos/page.tsx rename to examples/sample/app/todos/page.tsx diff --git a/sample/next-env.d.ts b/examples/sample/next-env.d.ts similarity index 100% rename from sample/next-env.d.ts rename to examples/sample/next-env.d.ts diff --git a/sample/next.config.js b/examples/sample/next.config.js similarity index 100% rename from sample/next.config.js rename to examples/sample/next.config.js diff --git a/sample/package-lock.json b/examples/sample/package-lock.json similarity index 100% rename from sample/package-lock.json rename to examples/sample/package-lock.json diff --git a/sample/package.json b/examples/sample/package.json similarity index 100% rename from sample/package.json rename to examples/sample/package.json diff --git a/sample/postcss.config.js b/examples/sample/postcss.config.js similarity index 100% rename from sample/postcss.config.js rename to examples/sample/postcss.config.js diff --git a/sample/tailwind.config.js b/examples/sample/tailwind.config.js similarity index 100% rename from sample/tailwind.config.js rename to examples/sample/tailwind.config.js diff --git a/sample/tsconfig.json b/examples/sample/tsconfig.json similarity index 100% rename from sample/tsconfig.json rename to examples/sample/tsconfig.json diff --git a/examples/src/cli.ts b/examples/src/cli.ts new file mode 100644 index 0000000..cccfe81 --- /dev/null +++ b/examples/src/cli.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { Command } from "commander"; + +import { registerAgentCommand } from "./commands/agent"; +import { registerAwaitCommand } from "./commands/await"; +import { registerConnectCommand } from "./commands/connect"; +import { registerE2ECommand } from "./commands/e2e"; +import { registerShipCommand } from "./commands/ship"; +import { registerStartCommand } from "./commands/start"; +import { registerStopCommand } from "./commands/stop"; +import { registerTasksCommand } from "./commands/tasks"; +import { registerTmuxCommand } from "./commands/tmux"; +import { registerTraceCommand } from "./commands/trace"; + +import chalk from "chalk"; + +const program = new Command(); + +program + .name("lambda-demo") + .description("Lambda CLI - Multi-agent development orchestration") + .version("0.1.0"); + +// Register commands +registerAgentCommand(program); +registerAwaitCommand(program); +registerConnectCommand(program); +registerE2ECommand(program); +registerShipCommand(program); +registerStartCommand(program); +registerStopCommand(program); +registerTasksCommand(program); +registerTmuxCommand(program); +registerTraceCommand(program); + +// Add help text with examples +program.on("--help", () => { + console.log(""); + console.log(chalk.bold("Commands:")); + console.log(""); + console.log(" agent AI agent management (context compaction)"); + console.log(" await Block until pane is done (for delegated waiting)"); + console.log(" connect Attach to an existing Lambda session"); + console.log(" e2e Run E2E tests"); + console.log(" ship Create branch, commit, push, and open PR"); + console.log(" start Create tmux session and launch Claude agents"); + console.log(" stop Kill the lambda-demo tmux session"); + console.log(" tasks Manage project tasks from TASKS.md"); + console.log(" tmux Send commands to or read output from tmux panes"); + console.log(" trace Debug Playwright E2E test traces"); + + console.log(""); + console.log(chalk.bold("Examples:")); + console.log(""); + console.log(" Session management:"); + console.log( + " $ lambda-demo start Create session + launch agents", + ); + console.log(" $ lambda-demo stop Kill session"); + console.log(""); + console.log(" Tmux pane interaction:"); + console.log(' $ lambda-demo tmux scratchPad "echo hi" Send command + Enter'); + console.log(" $ lambda-demo tmux scratchPad Capture current output"); + console.log(" $ lambda-demo tmux ron C-c Send Ctrl+C"); + console.log(' $ lambda-demo tmux ron wait "pattern" Wait for pattern'); + console.log(""); + console.log(" Task management:"); + console.log(" $ lambda-demo tasks list Show all tasks"); + console.log(" $ lambda-demo tasks next Get next task ID"); + console.log(" $ lambda-demo tasks show DEMO-1 Print ticket spec"); + console.log(" $ lambda-demo tasks done DEMO-1 Mark task as done"); + console.log(""); + console.log(" E2E trace debugging:"); + console.log(" $ lambda-demo trace diagnose-all"); + console.log(" $ lambda-demo trace summary"); + console.log(" $ lambda-demo trace errors test-results/some-test/trace.zip"); + console.log(""); + console.log(" Ship (branch, commit, PR):"); + console.log( + " $ lambda-demo ship DEMO-1 Create branch, commit, push, PR", + ); + console.log(""); + console.log(" Agent context management:"); + console.log(" $ lambda-demo agent compact -w lambda:ron"); + console.log(" $ lambda-demo agent whoami"); + console.log(""); + console.log(chalk.gray("For command-specific help: lambda-demo --help")); +}); + +program.parse(); diff --git a/examples/src/commands/agent/index.ts b/examples/src/commands/agent/index.ts new file mode 100644 index 0000000..1630d38 --- /dev/null +++ b/examples/src/commands/agent/index.ts @@ -0,0 +1,66 @@ +import { execFileSync } from "child_process"; +import { Command } from "commander"; + +import { assertWindowTarget } from "../../utils/validate"; +import { runClear } from "./subcommands/clear"; +import { CompactOptions, runCompact } from "./subcommands/compact"; + +export const registerAgentCommand = (program: Command): void => { + const agent = program + .command("agent") + .description("AI agent management commands"); + + agent + .command("compact") + .description( + "Compact an agent's context (run from scratchPad)", + ) + .requiredOption( + "-w, --window ", + "Target tmux window (e.g., lambda:ron)", + ) + .action(async (options: CompactOptions) => { + await runCompact({ + ...options, + window: assertWindowTarget(options.window), + }); + }); + + agent + .command("clear") + .description("Clear own context and send handoff message") + .requiredOption("--self ", "Handoff message for the fresh session") + .action(async (options: { self: string }) => { + await runClear(options.self); + }); + + agent + .command("whoami") + .description("Print current tmux window (for use with compact)") + .action(() => { + const tmuxPane = process.env.TMUX_PANE; + + if (!tmuxPane) { + console.error("Error: Not running in tmux"); + process.exit(1); + } + + try { + const target = execFileSync( + "tmux", + [ + "display-message", + "-t", + tmuxPane, + "-p", + "#{session_name}:#{window_name}", + ], + { encoding: "utf-8" }, + ).trim(); + console.log(target); + } catch { + console.error("Error: Could not determine tmux window"); + process.exit(1); + } + }); +}; diff --git a/examples/src/commands/agent/subcommands/clear.ts b/examples/src/commands/agent/subcommands/clear.ts new file mode 100644 index 0000000..bc8f9cb --- /dev/null +++ b/examples/src/commands/agent/subcommands/clear.ts @@ -0,0 +1,109 @@ +/** + * Agent Clear - Clear context and send handoff message + * + * Clears the calling agent's own context and sends a handoff message + * as the first prompt in the fresh session. + * + * Usage: + * lambda-demo agent clear --self "Pick up DEMO-3: filter bar. The checkbox test was a timing issue." + * + * Must be called directly (not via tmux) so TMUX_PANE is available. + */ +import { execFileSync } from "child_process"; + +const MAX_WAIT_MS = 30000; +const POLL_INTERVAL_MS = 1000; +const MIN_WAIT_MS = 2000; + +/** + * Poll tmux pane output until clear completes (prompt is ready again). + */ +async function waitForClear(target: string): Promise { + const startTime = Date.now(); + + await new Promise((resolve) => setTimeout(resolve, MIN_WAIT_MS)); + + while (Date.now() - startTime < MAX_WAIT_MS) { + try { + const output = execFileSync( + "tmux", + ["capture-pane", "-t", target, "-p", "-S", "-5"], + { encoding: "utf-8" }, + ); + + if (output.includes("accept edits on")) { + return true; + } + } catch { + // Ignore capture errors, keep polling + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + process.stdout.write("."); + } + + return false; +} + +/** + * Resolve the caller's own tmux window from TMUX_PANE. + */ +function resolveOwnWindow(): string { + const tmuxPane = process.env.TMUX_PANE; + + if (!tmuxPane) { + console.error("Error: Not running in tmux. --self requires TMUX_PANE."); + process.exit(1); + } + + return execFileSync( + "tmux", + ["display-message", "-t", tmuxPane, "-p", "#{session_name}:#{window_name}"], + { encoding: "utf-8" }, + ).trim(); +} + +export async function runClear(selfMessage: string): Promise { + try { + const target = resolveOwnWindow(); + const [, windowName] = target.split(":"); + + if (windowName === "scratchPad") { + console.error("Error: Cannot clear the scratchPad window."); + process.exit(1); + } + + console.log(`Clearing: ${target}`); + console.log( + `Handoff: ${selfMessage.slice(0, 80)}${ + selfMessage.length > 80 ? "..." : "" + }`, + ); + console.log(""); + + // Send /clear and confirm autocomplete + execFileSync("tmux", ["send-keys", "-t", target, "/clear", "C-m"]); + await new Promise((resolve) => setTimeout(resolve, 300)); + execFileSync("tmux", ["send-keys", "-t", target, "C-m"]); + + // Poll for clear completion + const completed = await waitForClear(target); + + if (!completed) { + console.log(""); + console.error("Warning: Timed out waiting for /clear to complete."); + } + + // Send the handoff message as a user prompt + await new Promise((resolve) => setTimeout(resolve, 500)); + execFileSync("tmux", ["send-keys", "-t", target, selfMessage, "C-m"]); + + console.log("Context cleared. Handoff sent."); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : "Unknown error", + ); + process.exit(1); + } +} diff --git a/examples/src/commands/agent/subcommands/compact.ts b/examples/src/commands/agent/subcommands/compact.ts new file mode 100644 index 0000000..4284f89 --- /dev/null +++ b/examples/src/commands/agent/subcommands/compact.ts @@ -0,0 +1,116 @@ +/** + * Agent Compact - Self-clearing context for Claude + * + * This command triggers context compaction by sending /compact to the target window + * and polls for completion. + * + * Usage (from scratchPad): + * lambda-demo agent compact --window lambda:ron + * + * Workflow: + * 1. Claude runs `lambda-demo whoami` to get window name + * 2. Claude runs via tmux: `tmux send-keys -t lambda:scratchPad 'lambda-demo agent compact -w lambda:ron' Enter` + * 3. This command (running in scratchPad) sends /compact and polls for completion + */ +import { execFileSync } from "child_process"; + +const MAX_WAIT_MS = 120000; // 2 minutes max wait +const POLL_INTERVAL_MS = 1000; // Check every second +const MIN_WAIT_MS = 5000; // Minimum wait before polling + +export interface CompactOptions { + window: string; +} + +/** + * Poll tmux pane output until compaction complete message appears or timeout + */ +async function waitForCompaction(target: string): Promise { + const startTime = Date.now(); + + // Minimum wait before starting to poll + console.log(`Waiting ${MIN_WAIT_MS / 1000}s before polling...`); + await new Promise((resolve) => setTimeout(resolve, MIN_WAIT_MS)); + + console.log("Polling for completion..."); + + while (Date.now() - startTime < MAX_WAIT_MS) { + try { + // Only capture last 5 lines to avoid false positives + const output = execFileSync( + "tmux", + ["capture-pane", "-t", target, "-p", "-S", "-5"], + { encoding: "utf-8" }, + ); + + // Look for the specific compaction completion message + if (output.includes("ctrl+o to see full summary")) { + return true; + } + } catch { + // Ignore capture errors, keep polling + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + process.stdout.write("."); + } + + return false; +} + +export async function runCompact(options: CompactOptions): Promise { + try { + const target = options.window; + + if (!target) { + console.error("Error: --window parameter is required."); + console.error(""); + console.error("Usage: lambda-demo agent compact --window lambda:ron"); + console.error(""); + console.error("First run `lambda-demo agent whoami` to get your window name."); + process.exit(1); + } + + const [, windowName] = target.split(":"); + + // Verify targeting a dev window, not scratchPad + if (windowName === "scratchPad") { + console.error("Error: Cannot compact the scratchPad window."); + process.exit(1); + } + + console.log(`Target window: ${target}`); + console.log(""); + + // Wait 3 seconds before sending to let Claude settle + console.log("Waiting 3s before starting..."); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Send /compact and confirm autocomplete + console.log("Sending /compact..."); + execFileSync("tmux", ["send-keys", "-t", target, "/compact", "C-m"]); + + // Small delay then send Enter again to confirm autocomplete selection + await new Promise((resolve) => setTimeout(resolve, 300)); + execFileSync("tmux", ["send-keys", "-t", target, "C-m"]); + + // Poll for compaction completion + const completed = await waitForCompaction(target); + + if (!completed) { + console.log(""); + console.error("Warning: Timed out waiting for compaction to complete."); + process.exit(1); + } + + console.log(" done!"); + console.log(""); + console.log("Compaction complete!"); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : "Unknown error", + ); + process.exit(1); + } +} diff --git a/examples/src/commands/await/index.ts b/examples/src/commands/await/index.ts new file mode 100644 index 0000000..32c2b4c --- /dev/null +++ b/examples/src/commands/await/index.ts @@ -0,0 +1,173 @@ +/** + * Await command - Full wait loop in a single process. + * + * Combines peek + poll into one blocking call, designed to be delegated + * to a Haiku sub-agent so the calling agent burns zero tokens waiting. + * + * Exit codes: + * 0 = done (pattern matched, shell idle, or idle timeout) + * 1 = error (pane not found, tmux not running) + * 2 = internal timeout (still running — caller should re-run) + */ +import { Command } from "commander"; + +import { AGENT_PANES } from "../../config"; +import { assertPaneName } from "../../utils/validate"; +import { tmuxTarget } from "../tmux"; +import { isShellIdle } from "../tmux/pane-status"; +import { capturePaneOutput } from "../wait"; + +const POLL_INTERVAL_MS = 1000; +const INTERNAL_TIMEOUT_MS = 540_000; // 9 minutes (under 10 min Bash tool limit) + +interface AwaitOptions { + pane: string; + pattern?: string; + long: boolean; +} + +function getIdleThresholdMs(pane: string, long: boolean): number { + // Agent panes (Claude Code) have a constantly-changing status bar, + // so idle detection must use a short threshold regardless of --long. + if (AGENT_PANES.includes(pane)) { + return long ? 5_000 : 3_000; + } + + if (long) { + return 60_000; + } + + return 15_000; +} + +function buildRegex(pattern: string): RegExp | undefined { + try { + // Clean backslash-escaped pipes (common shell escaping mistake) + const cleaned = pattern.replace(/\\\|/g, "|"); + + return new RegExp(cleaned); + } catch { + console.error(`Error: Invalid regex pattern: "${pattern}"`); + process.exit(1); + } +} + +function tailLines(output: string, n: number): string { + const lines = output.split("\n"); + + return lines.slice(-n).join("\n"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runAwait(opts: AwaitOptions): Promise { + const { pane, pattern, long } = opts; + const target = tmuxTarget(pane); + const regex = pattern ? buildRegex(pattern) : undefined; + const idleThresholdMs = getIdleThresholdMs(pane, long); + + // Phase 1: Peek (1s delay, check if already done) + await sleep(1000); + + // eslint-disable-next-line no-restricted-syntax -- reassigned in poll loop + let lastOutput = ""; + + try { + lastOutput = capturePaneOutput(target); + } catch { + process.exit(1); + } + + // Check if already done after peek + if (regex && regex.test(lastOutput)) { + console.log(`=== DONE (pattern matched) ===`); + console.log(tailLines(lastOutput, 40)); + process.exit(0); + } + + if (isShellIdle(target)) { + console.log(`=== DONE (shell idle) ===`); + console.log(tailLines(lastOutput, 40)); + process.exit(0); + } + + // Phase 2: Poll loop + // eslint-disable-next-line no-restricted-syntax -- reassigned in poll loop + let elapsed = 1000; // account for peek sleep + // eslint-disable-next-line no-restricted-syntax -- reassigned in poll loop + let idleMs = 0; + + while (elapsed < INTERNAL_TIMEOUT_MS) { + await sleep(POLL_INTERVAL_MS); + elapsed += POLL_INTERVAL_MS; + + // eslint-disable-next-line no-restricted-syntax -- assigned in try, used after + let output: string; + + try { + output = capturePaneOutput(target); + } catch { + process.exit(1); + } + + // Pattern match + if (regex && regex.test(output)) { + console.log(`=== DONE (pattern matched) ===`); + console.log(tailLines(output, 40)); + process.exit(0); + } + + // Shell idle (command exited) + if (isShellIdle(target)) { + console.log(`=== DONE (shell idle) ===`); + console.log(tailLines(output, 40)); + process.exit(0); + } + + // Idle detection (output unchanged) + if (output === lastOutput) { + idleMs += POLL_INTERVAL_MS; + + if (idleMs >= idleThresholdMs) { + console.log(`=== DONE (idle ${idleThresholdMs / 1000}s) ===`); + console.log(tailLines(output, 40)); + process.exit(0); + } + } else { + idleMs = 0; + lastOutput = output; + } + } + + // Internal timeout — exit 2 so caller can re-run + const finalOutput = capturePaneOutput(target); + console.log( + `=== TIMEOUT (${INTERNAL_TIMEOUT_MS / 1000}s elapsed, still running) ===`, + ); + console.log(tailLines(finalOutput, 40)); + process.exit(2); +} + +export const registerAwaitCommand = (program: Command): void => { + program + .command("await [pattern]") + .description( + "Block until pane is done (pattern match, shell idle, or output idle)", + ) + .option("--long", "Use long idle threshold (60s) for CI/long operations") + .action( + async ( + pane: string, + pattern: string | undefined, + opts: { long: boolean }, + ) => { + await runAwait({ + pane: assertPaneName(pane), + pattern, + long: opts.long ?? false, + }); + }, + ); +}; diff --git a/examples/src/commands/connect/index.ts b/examples/src/commands/connect/index.ts new file mode 100644 index 0000000..43ec83d --- /dev/null +++ b/examples/src/commands/connect/index.ts @@ -0,0 +1,59 @@ +/** + * Connect command - Attach to an existing Lambda tmux session. + * + * Auto-detects iTerm2 and uses -CC mode for native tabs. + */ +import { execSync } from "child_process"; +import { Command } from "commander"; + +import { SESSION_NAME } from "../../config"; + +import chalk from "chalk"; + +function sessionExists(): boolean { + try { + execSync(`tmux has-session -t ${SESSION_NAME} 2>/dev/null`); + + return true; + } catch { + return false; + } +} + +function isITerm2(): boolean { + return process.env.TERM_PROGRAM === "iTerm.app"; +} + +export function attachToSession(): void { + const useCC = isITerm2(); + const ccFlag = useCC ? "-CC " : ""; + + if (useCC) { + console.log(chalk.gray("iTerm2 detected — using native tab integration")); + } + + execSync(`tmux ${ccFlag}attach -t ${SESSION_NAME}`, { stdio: "inherit" }); +} + +export const registerConnectCommand = (program: Command): void => { + program + .command("connect") + .description("Attach to an existing Lambda tmux session") + .addHelpText( + "after", + ` +${chalk.bold("Examples:")} + lambda-demo connect Attach to session (auto-detects iTerm2) +`, + ) + .action(() => { + if (!sessionExists()) { + console.log( + chalk.red(`No Lambda session found. Run "lambda-demo start" first.`), + ); + process.exit(1); + } + + attachToSession(); + }); +}; diff --git a/examples/src/commands/e2e/index.ts b/examples/src/commands/e2e/index.ts new file mode 100644 index 0000000..f17a820 --- /dev/null +++ b/examples/src/commands/e2e/index.ts @@ -0,0 +1,69 @@ +/** + * E2E command - Run Playwright E2E tests. + * + * Configurable: works with any Playwright project structure. + * Spawns the test runner with stdio inherited so output streams to the caller. + */ +import { spawn } from "child_process"; +import { Command } from "commander"; +import * as fs from "fs"; +import * as path from "path"; + +import chalk from "chalk"; + +/** + * Find the directory containing the Playwright config. + * Searches cwd, then upward for playwright.config.ts. + */ +function findPlaywrightDir(): string { + // eslint-disable-next-line no-restricted-syntax -- reassigned in loop + let dir = process.cwd(); + + while (dir !== path.dirname(dir)) { + if ( + fs.existsSync(path.join(dir, "playwright.config.ts")) || + fs.existsSync(path.join(dir, "playwright.config.js")) + ) { + return dir; + } + dir = path.dirname(dir); + } + + // Default to cwd if not found + return process.cwd(); +} + +function runE2E(args: string[]): void { + const playwrightDir = findPlaywrightDir(); + + const child = spawn("npx", ["playwright", "test", ...args], { + cwd: playwrightDir, + // No shell: the arguments reach Playwright as given, never as a command. + stdio: "inherit", + }); + + child.on("exit", (code) => { + process.exit(code ?? 1); + }); +} + +export const registerE2ECommand = (program: Command): void => { + program + .command("e2e") + .description("Run E2E tests") + .allowUnknownOption() + .helpOption(false) + .argument("[args...]", "Arguments passed to Playwright") + .addHelpText( + "after", + ` +${chalk.bold("Examples:")} + lambda-demo e2e todo.spec.ts Run specific test + lambda-demo e2e --headed Run with visible browser + lambda-demo e2e --help Show Playwright help +`, + ) + .action((args: string[]) => { + runE2E(args); + }); +}; diff --git a/src/commands/ship/index.ts b/examples/src/commands/ship/index.ts similarity index 63% rename from src/commands/ship/index.ts rename to examples/src/commands/ship/index.ts index 8212289..72f40f0 100644 --- a/src/commands/ship/index.ts +++ b/examples/src/commands/ship/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; +import { assertTicketId } from "../../utils/validate"; import { runShip } from "./run"; import chalk from "chalk"; @@ -18,16 +19,17 @@ export const registerShipCommand = (program: Command): void => { "Override commit message (default: ticket title)", ) .option("--dry-run", "Preview what would happen without executing") + .option("--no-dry-run", "Really ship, even with DEMO_MODE on") .addHelpText( "after", ` ${chalk.bold("Examples:")} - lambda ship DEMO-1 Create branch, commit, push, and open PR - lambda ship DEMO-1 --dry-run Preview without executing - lambda ship DEMO-1 -m "Custom" Use custom commit message + lambda-demo ship DEMO-1 Create branch, commit, push, and open PR + lambda-demo ship DEMO-1 --dry-run Preview without executing + lambda-demo ship DEMO-1 -m "Custom" Use custom commit message `, ) .action(async (ticketId: string, options: ShipOptions) => { - await runShip(ticketId, options); + await runShip(assertTicketId(ticketId), options); }); }; diff --git a/src/commands/ship/run.ts b/examples/src/commands/ship/run.ts similarity index 90% rename from src/commands/ship/run.ts rename to examples/src/commands/ship/run.ts index 50192eb..0d13013 100644 --- a/src/commands/ship/run.ts +++ b/examples/src/commands/ship/run.ts @@ -33,6 +33,17 @@ const execSafe = (cmd: string): string | null => { } }; +const run = (file: string, args: string[]): string => + execFileSync(file, args, { encoding: "utf-8" }).trim(); + +const runSafe = (file: string, args: string[]): string | null => { + try { + return run(file, args); + } catch { + return null; + } +}; + const getCurrentBranch = (): string => exec("git branch --show-current"); const hasChanges = (): boolean => { @@ -42,7 +53,7 @@ const hasChanges = (): boolean => { }; const branchExists = (branch: string): boolean => - execSafe(`git rev-parse --verify ${branch}`) !== null; + runSafe("git", ["rev-parse", "--verify", branch]) !== null; const createOrSwitchBranch = (ticketId: string, dryRun: boolean): void => { const exists = branchExists(ticketId); @@ -51,13 +62,13 @@ const createOrSwitchBranch = (ticketId: string, dryRun: boolean): void => { console.log(chalk.gray(`Switching to existing branch: ${ticketId}`)); if (!dryRun) { - exec(`git checkout ${ticketId}`); + run("git", ["checkout", ticketId]); } } else { console.log(chalk.gray(`Creating new branch: ${ticketId}`)); if (!dryRun) { - exec(`git checkout -b ${ticketId}`); + run("git", ["checkout", "-b", ticketId]); } } }; @@ -78,7 +89,7 @@ const runFormat = (dryRun: boolean): void => { const stageAndCommit = (message: string, dryRun: boolean): void => { runFormat(dryRun); - console.log(chalk.gray("Staging all changes...")); + console.log(chalk.gray("Adding all changes...")); if (!dryRun) { // Stages everything — .gitignore controls what's excluded @@ -140,8 +151,6 @@ const fetchAndRebase = async ( console.log(chalk.red("Rebase failed — merge conflicts detected")); execSafe("git rebase --abort"); exec("git checkout main"); - // Optional: integrate with a ticket system to auto-block tickets on merge conflicts. - // await markTicketBlocked(ticketId, 'Merge conflicts during rebase on main'); console.log( chalk.yellow(`${ticketId} has merge conflicts — returned to main`), ); @@ -154,14 +163,21 @@ const pushBranch = (ticketId: string, dryRun: boolean): void => { console.log(chalk.gray(`Pushing to origin/${ticketId}...`)); if (!dryRun) { - exec(`git push -u origin ${ticketId} --force-with-lease`); + run("git", ["push", "-u", "origin", ticketId, "--force-with-lease"]); } }; const getExistingPR = (ticketId: string): string | null => { - const result = execSafe( - `gh pr list --head ${ticketId} --json url --jq '.[0].url'`, - ); + const result = runSafe("gh", [ + "pr", + "list", + "--head", + ticketId, + "--json", + "url", + "--jq", + ".[0].url", + ]); return result && result.length > 0 ? result : null; }; @@ -311,15 +327,9 @@ export const runShip = async ( // 9. Enable auto-merge with squash if (!dryRun) { console.log(chalk.gray("Enabling auto-merge...")); - exec(`gh pr merge ${ticketId} --auto --squash`); + run("gh", ["pr", "merge", ticketId, "--auto", "--squash"]); } - // Optional: integrate with a ticket system to update status after PR creation. - // console.log(chalk.gray('Updating ticket status to CI...')); - // if (!dryRun) { - // await updateIssue(ticketId, { status: 'CI' }); - // } - // 10. Mark task done in TASKS.md (always, even in dry-run) try { markTaskDone(ticketId); diff --git a/examples/src/commands/start/index.ts b/examples/src/commands/start/index.ts new file mode 100644 index 0000000..61eb02c --- /dev/null +++ b/examples/src/commands/start/index.ts @@ -0,0 +1,162 @@ +/** + * Start command - Create tmux session and launch Claude agents. + * + * Creates a tmux session named "lambda" with windows for each agent + * (james, ron, sam) plus a scratchPad for general commands and a + * dev-server window for the sample Next.js app. + */ +import { execFileSync, execSync } from "child_process"; +import { Command } from "commander"; +import * as path from "path"; + +import chalk from "chalk"; + +import { AGENT_PANES, SESSION_NAME, WINDOW_ENV_VAR } from "../../config"; +import { attachToSession } from "../connect"; + +function sessionExists(): boolean { + try { + execSync(`tmux has-session -t ${SESSION_NAME} 2>/dev/null`); + + return true; + } catch { + return false; + } +} + +function waitForPort(port: number, timeout = 60000): void { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + execSync(`nc -z localhost ${port} 2>/dev/null`); + + return; + } catch { + execSync("sleep 1"); + } + } + throw new Error(`Port ${port} not ready after ${timeout}ms`); +} + +function createSession(): void { + if (sessionExists()) { + console.log( + chalk.yellow( + `Session "${SESSION_NAME}" already exists. Use "lambda-demo stop" first.`, + ), + ); + process.exit(1); + } + + const projectRoot = process.cwd(); + const managerDir = path.join(projectRoot, "manager"); + const sampleDir = path.join(projectRoot, "sample"); + + // Window order: james, sam, ron, scratchPad, dev-server + // First window is created with new-session + execSync( + `tmux new-session -d -s ${SESSION_NAME} -n james -c "${managerDir}"`, + { stdio: "inherit" }, + ); + + // Create remaining agent windows + execSync(`tmux new-window -t ${SESSION_NAME} -n sam -c "${projectRoot}"`, { + stdio: "inherit", + }); + execSync(`tmux new-window -t ${SESSION_NAME} -n ron -c "${projectRoot}"`, { + stdio: "inherit", + }); + + // Create scratchPad window + execSync( + `tmux new-window -t ${SESSION_NAME} -n scratchPad -c "${projectRoot}"`, + { stdio: "inherit" }, + ); + + // Create dev-server window (last) + execSync( + `tmux new-window -t ${SESSION_NAME} -n dev-server -c "${sampleDir}"`, + { stdio: "inherit" }, + ); + execSync(`tmux send-keys -t ${SESSION_NAME}:dev-server 'npm run dev' Enter`, { + stdio: "inherit", + }); + console.log(chalk.gray("Started dev-server window (Next.js on port 3000)")); + + // Wait for dev server to be ready + console.log(chalk.gray("Waiting for dev server on port 3000...")); + waitForPort(3000); + console.log(chalk.green("Dev server ready on port 3000")); + + console.log(chalk.green(`Created tmux session: ${SESSION_NAME}`)); + console.log( + chalk.gray(` Windows: james, sam, ron, scratchPad, dev-server`), + ); +} + +function launchAgents(): void { + AGENT_PANES.forEach((pane) => { + const target = `${SESSION_NAME}:${pane}`; + const envVar = `${WINDOW_ENV_VAR}=${SESSION_NAME}:${pane}`; + + // Launch Claude with the window env var + execSync(`tmux send-keys -t ${target} '${envVar} claude' Enter`, { + stdio: "inherit", + }); + }); + + console.log(chalk.green("Launched Claude in agent windows")); +} + +export const registerStartCommand = (program: Command): void => { + program + .command("start") + .description("Create tmux session and launch Claude agents") + .option("--no-agents", "Create session without launching Claude") + .option( + "--service ", + "Command to run in a dev-server window", + (val: string, acc: string[]) => [...acc, val], + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- commander API requires type assertion for default value + [] as string[], + ) + .addHelpText( + "after", + ` +${chalk.bold("Examples:")} + lambda-demo start Create session and launch agents + lambda-demo start --no-agents Create session only + lambda-demo start --service "npm run dev" Add a dev server window +`, + ) + .action((opts: { agents: boolean; service: string[] }) => { + createSession(); + + // Create optional service windows + opts.service.forEach((serviceCmd, i) => { + const name = `service-${i}`; + execSync(`tmux new-window -t ${SESSION_NAME} -n ${name}`, { + stdio: "inherit", + }); + execFileSync( + "tmux", + ["send-keys", "-t", `${SESSION_NAME}:${name}`, serviceCmd, "Enter"], + { stdio: "inherit" }, + ); + console.log(chalk.gray(`Started service window: ${name}`)); + }); + + if (opts.agents) { + launchAgents(); + } + + console.log(""); + console.log(chalk.bold("Attaching to session...")); + + // Auto-attach to the james window + execSync(`tmux select-window -t ${SESSION_NAME}:james`, { + stdio: "inherit", + }); + attachToSession(); + }); +}; diff --git a/src/commands/stop/index.ts b/examples/src/commands/stop/index.ts similarity index 87% rename from src/commands/stop/index.ts rename to examples/src/commands/stop/index.ts index 19a34a7..ef59321 100644 --- a/src/commands/stop/index.ts +++ b/examples/src/commands/stop/index.ts @@ -1,5 +1,5 @@ /** - * Stop command - Kill the lambda tmux session and clean up. + * Stop command - Kill the lambda-demo tmux session and clean up. */ import { execSync } from "child_process"; import { Command } from "commander"; @@ -11,7 +11,7 @@ import chalk from "chalk"; export const registerStopCommand = (program: Command): void => { program .command("stop") - .description("Kill the lambda tmux session") + .description("Kill the lambda-demo tmux session") .action(() => { try { execSync(`tmux has-session -t ${SESSION_NAME} 2>/dev/null`); diff --git a/src/commands/tasks/index.ts b/examples/src/commands/tasks/index.ts similarity index 99% rename from src/commands/tasks/index.ts rename to examples/src/commands/tasks/index.ts index 7490b6e..1b3eba0 100644 --- a/src/commands/tasks/index.ts +++ b/examples/src/commands/tasks/index.ts @@ -313,7 +313,7 @@ export const registerTasksCommand = (program: Command): void => { }); }); - // Alternative task-scheduling modes. Uncomment one to change how `lambda tasks next` picks work: + // Alternative task-scheduling modes. Uncomment one to change how `lambda-demo tasks next` picks work: // - findNextTaskSimple: strict order, stops at first todo // - findNextTaskStrict: strict order, stops if any task is blocked // diff --git a/examples/src/commands/tmux/index.ts b/examples/src/commands/tmux/index.ts new file mode 100644 index 0000000..66f3598 --- /dev/null +++ b/examples/src/commands/tmux/index.ts @@ -0,0 +1,315 @@ +/** + * Tmux command - Send commands to and read output from tmux panes. + * + * Usage: + * lambda-demo tmux [command] Send command to lambda: + * lambda-demo tmux Capture output from lambda: + * lambda-demo tmux <<'EOF' ... Read command from stdin (shell-safe) + * lambda-demo tmux wait [pattern] Wait on lambda: (peek-then-block) + */ +import { execFileSync } from "child_process"; +import { Command } from "commander"; + +import { AGENT_PANES, SESSION_NAME } from "../../config"; +import { assertPaneName } from "../../utils/validate"; +import { + clearReceiptsForPane, + createSendReceipt, + runWaitCommand, +} from "../wait"; +import { getPaneStatusTag } from "./pane-status"; + +/* eslint-disable no-restricted-syntax, @typescript-eslint/consistent-type-assertions -- push() and Buffer cast needed for stdin stream reading */ +async function readStdin(): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString("utf-8"); +} +/* eslint-enable no-restricted-syntax, @typescript-eslint/consistent-type-assertions -- re-enable after stdin block */ + +export const TMUX_KEY_PATTERN = + /^(Enter|Escape|BSpace|BTab|Space|Tab|Home|End|PageUp|PageDown|Up|Down|Left|Right|F\d+|C-[a-z])$/; + +export function tmuxTarget(pane: string): string { + return `${SESSION_NAME}:${pane}`; +} + +function getPanePwd(target: string): string | null { + try { + return ( + execFileSync( + "tmux", + ["display", "-t", target, "-p", "#{pane_current_path}"], + { encoding: "utf-8", timeout: 5000 }, + ).trim() || null + ); + } catch { + return null; + } +} + +function outputWithPwd(target: string, output: string): void { + const pwd = getPanePwd(target); + const status = getPaneStatusTag(target); + + if (pwd) { + console.log(`[${pwd}] ${status}`); + } else { + console.log(status); + } + console.log(output); +} + +function capturePane(target: string): string { + return execFileSync( + "tmux", + ["capture-pane", "-t", target, "-p", "-S", "-40"], + { encoding: "utf-8", timeout: 5000 }, + ).trimEnd(); +} + +function sendKeys(target: string, ...keys: string[]): void { + execFileSync("tmux", ["send-keys", "-t", target, ...keys], { + timeout: 5000, + }); +} + +export function isAgentPane(pane: string): boolean { + return AGENT_PANES.includes(pane); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Switch a Claude Code agent pane to the target mode by cycling BTab. + * Returns true if mode was found, false if it failed after max attempts. + */ +async function switchMode(target: string, pattern: string): Promise { + // eslint-disable-next-line no-restricted-syntax -- for loop with await needed for sequential mode cycling + for (let attempt = 0; attempt < 6; attempt++) { + const output = capturePane(target); + + if (output.includes(pattern)) { + return true; + } + sendKeys(target, "BTab"); + await sleep(300); + } + + return false; +} + +/** + * Ensure a Claude Code agent pane is in "accept edits" mode before sending. + * Silently falls back if mode switching fails. + */ +async function ensureAcceptEdits(target: string): Promise { + await switchMode(target, "accept edits on"); +} + +interface SendTextOpts { + target: string; + pane: string; + text: string; + agent: boolean; + enter: boolean; + raw: boolean; +} + +/** + * Send text to a tmux pane, handling agent mode switching, slash commands, + * and Enter key behavior. Shared by both the args path and the stdin path. + */ +export async function sendText(opts: SendTextOpts): Promise { + const { target, text, agent, enter, raw } = opts; + + // Warn if text contains \! which likely means zsh mangled an exclamation mark + if (text.includes("\\!")) { + console.error( + "Warning: text contains '\\!' which may be shell-mangled. " + + "Consider using stdin instead: lambda-demo tmux <<'EOF'\\n...\\nEOF", + ); + } + + // For agent panes, ensure "accept edits" mode before sending text + if (agent && !raw) { + await ensureAcceptEdits(target); + } + + const isSlashCommand = agent && text.startsWith("/"); + + if (isSlashCommand && enter) { + // Slash commands trigger Claude Code's autocomplete menu. + // Send text, wait for autocomplete to render, Escape to dismiss, then Enter. + sendKeys(target, text); + await sleep(500); + sendKeys(target, "Escape"); + await sleep(100); + sendKeys(target, "Enter"); + } else if (agent && enter) { + // Agent panes need a delay between text and Enter to avoid + // Enter being swallowed or interpreted as a newline. + // 500ms handles large pastes where the terminal needs time to buffer. + sendKeys(target, text); + await sleep(500); + sendKeys(target, "Enter"); + } else if (enter) { + sendKeys(target, text, "Enter"); + } else { + sendKeys(target, text); + } + clearReceiptsForPane(target); + + // Auto-read: wait briefly, capture output, and create send receipt + // so the next `lambda-demo tmux wait` goes straight to blocking mode. + await sleep(500); + const output = capturePane(target); + outputWithPwd(target, output); + createSendReceipt(target); +} + +export const registerTmuxCommand = (program: Command): void => { + program + .command("tmux [args...]") + .description("Send commands to or read output from tmux panes") + .allowUnknownOption(true) + .option("-n, --no-enter", "Send text without pressing Enter") + .option("--raw", "Skip agent mode switching") + .option("--plan", "Switch agent pane to plan mode (no message sent)") + .option("--long", "Use long timeout for wait (10min timeout, 60s idle)") + .option("--stdin", "Read command text from stdin (shell-safe)") + .addHelpText( + "after", + ` +Agent panes (${AGENT_PANES.join(", ")}): + Automatically switches to "accept edits" mode before sending. + Use --raw to skip mode switching, --plan to switch to plan mode. + +Shell-safe stdin mode (preserves !, $, backticks, etc.): + $ lambda-demo tmux scratchPad --stdin <<'EOF' + curl -s ... | jq 'select(.message != null)' + EOF + +Examples: + $ lambda-demo tmux scratchPad "echo hello" Send command + Enter + $ lambda-demo tmux scratchPad Capture current output + $ lambda-demo tmux ron C-c Send Ctrl+C + $ lambda-demo tmux ron "text" -n Type text without Enter + $ lambda-demo tmux ron Enter Send Enter key + $ lambda-demo tmux ron --plan Switch ron to plan mode + $ lambda-demo tmux ron --raw "y" Send without mode switching + $ lambda-demo tmux ron wait Wait on ron pane (peek-then-block) + $ lambda-demo tmux ron wait "pattern" Wait for pattern on ron pane +`, + ) + .action( + async ( + pane: string, + args: string[], + opts: { + enter: boolean; + raw: boolean; + plan: boolean; + long: boolean; + stdin: boolean; + }, + ) => { + const target = tmuxTarget(assertPaneName(pane)); + const agent = isAgentPane(pane); + + // --plan flag -> switch to plan mode and exit + if (opts.plan) { + if (!agent) { + console.error( + `--plan only works on agent panes (${AGENT_PANES.join(", ")})`, + ); + process.exit(1); + } + const ok = await switchMode(target, "plan mode on"); + + if (ok) { + console.log("Switched to plan mode"); + } else { + console.error("Failed to switch to plan mode after 6 attempts"); + process.exit(1); + } + + return; + } + + // --stdin flag -> read command from stdin + if (opts.stdin) { + const rawInput = await readStdin(); + const text = rawInput.replace(/\n+$/, ""); + + if (!text) { + console.error("Error: empty stdin"); + process.exit(1); + } + + await sendText({ + target, + pane, + text, + agent, + enter: opts.enter, + raw: opts.raw, + }); + + return; + } + + // No args -> capture mode + if (args.length === 0) { + const output = capturePane(target); + outputWithPwd(target, output); + + return; + } + + // "wait" subcommand -> delegate to wait logic + if (args[0] === "wait") { + const [, pattern] = args; + + const idleSec = agent ? 3 : opts.long ? 60 : 15; + const waitOpts = { + idleSec, + ...(opts.long ? { timeoutSec: 600 } : {}), + }; + + await runWaitCommand({ + pattern, + pane: target, + ...waitOpts, + }); + + return; + } + + // Single arg that's a known tmux key -> send as raw key (no mode switching) + if (args.length === 1 && TMUX_KEY_PATTERN.test(args[0])) { + sendKeys(target, args[0]); + clearReceiptsForPane(target); + + return; + } + + // Command string -> send text via shared helper + const text = args.join(" "); + await sendText({ + target, + pane, + text, + agent, + enter: opts.enter, + raw: opts.raw, + }); + }, + ); +}; diff --git a/examples/src/commands/tmux/pane-status.ts b/examples/src/commands/tmux/pane-status.ts new file mode 100644 index 0000000..c05b59b --- /dev/null +++ b/examples/src/commands/tmux/pane-status.ts @@ -0,0 +1,51 @@ +/** + * Pane status utilities - Shared logic for detecting whether a tmux pane + * is idle or running a command. + */ +import { execFileSync } from "child_process"; + +export const KNOWN_SHELLS = ["zsh", "bash", "fish", "sh", "dash", "login"]; + +export function getPaneCurrentCommand(pane: string): string | null { + try { + const cmd = execFileSync( + "tmux", + ["display-message", "-t", pane, "-p", "#{pane_current_command}"], + { encoding: "utf-8", timeout: 5000 }, + ).trim(); + + return cmd || null; + } catch { + return null; + } +} + +export function isShellIdle(pane: string): boolean { + const cmd = getPaneCurrentCommand(pane); + + if (!cmd) { + return false; + } + + return KNOWN_SHELLS.some( + (shell) => cmd === shell || cmd.endsWith(`/${shell}`), + ); +} + +export function getPaneStatusTag(pane: string): string { + const cmd = getPaneCurrentCommand(pane); + + if (!cmd) { + return "[idle]"; + } + + const isIdle = KNOWN_SHELLS.some( + (shell) => cmd === shell || cmd.endsWith(`/${shell}`), + ); + + if (isIdle) { + return "[idle]"; + } + + return `[running: ${cmd}]`; +} diff --git a/examples/src/commands/trace/index.ts b/examples/src/commands/trace/index.ts new file mode 100644 index 0000000..58e21af --- /dev/null +++ b/examples/src/commands/trace/index.ts @@ -0,0 +1,164 @@ +import { Command } from "commander"; + +import { runTraceCommand } from "./trace-debug"; + +import chalk from "chalk"; + +export const registerTraceCommand = (program: Command): void => { + const trace = program + .command("trace") + .description("Debug Playwright E2E test traces") + .addHelpText( + "after", + ` +${chalk.bold("Subcommands:")} + summary [trace-path] Overview of test run + errors [trace-path] Error details with screenshots + actions [trace-path] High-level action sequence + screenshots [trace-path] List all screenshots + screenshot [trace-path] Get specific screenshot + neighbors + console [trace-path] Filter console logs + around [trace-path] Events around a timestamp + timeline [trace-path] Chronological event view + diagnose [trace-path] Comprehensive error scan + diagnose-all Batch diagnose all traces + +${chalk.bold("Examples:")} + lambda-demo trace diagnose-all + lambda-demo trace summary test-results/some-test/trace.zip + lambda-demo trace errors + lambda-demo trace console --type error --limit 50 + lambda-demo trace diagnose --verbose +`, + ); + + // summary + trace + .command("summary [trace-path]") + .description("Overview of test run (duration, status, counts)") + .action(async (tracePath?: string) => { + await runTraceCommand("summary", tracePath, {}); + }); + + // errors + trace + .command("errors [trace-path]") + .description("Error details with 2 screenshots before/after") + .action(async (tracePath?: string) => { + await runTraceCommand("errors", tracePath, {}); + }); + + // actions + trace + .command("actions [trace-path]") + .description("High-level action sequence (goto, click, fill)") + .action(async (tracePath?: string) => { + await runTraceCommand("actions", tracePath, {}); + }); + + // screenshots + trace + .command("screenshots [trace-path]") + .description("List all screenshots with index numbers") + .action(async (tracePath?: string) => { + await runTraceCommand("screenshots", tracePath, {}); + }); + + // screenshot + trace + .command("screenshot [trace-path]") + .description("Get specific screenshot + neighbors") + .option( + "--at ", + 'Screenshot index or "error" for error time', + "error", + ) + .option("--context ", "Number of screenshots before/after", "2") + .action( + async ( + tracePath: string | undefined, + options: Record, + ) => { + await runTraceCommand("screenshot", tracePath, { + at: options.at, + context: options.context, + }); + }, + ); + + // console + trace + .command("console [trace-path]") + .description("Filter console logs") + .option( + "--type ", + "Filter by type (log, info, warning, error, debug)", + ) + .option("--filter ", "Filter by regex pattern") + .option("--limit ", "Maximum messages to show", "100") + .action( + async ( + tracePath: string | undefined, + options: Record, + ) => { + await runTraceCommand("console", tracePath, { + type: options.type, + filter: options.filter, + limit: options.limit, + }); + }, + ); + + // around + trace + .command("around [trace-path]") + .description("Events around a timestamp") + .requiredOption("--time ", "Target timestamp in milliseconds") + .option("--window ", "Window size (ms)", "500") + .action( + async ( + tracePath: string | undefined, + options: Record, + ) => { + await runTraceCommand("around", tracePath, { + time: options.time, + window: options.window, + }); + }, + ); + + // timeline + trace + .command("timeline [trace-path]") + .description("Condensed chronological event view") + .action(async (tracePath?: string) => { + await runTraceCommand("timeline", tracePath, {}); + }); + + // diagnose + trace + .command("diagnose [trace-path]") + .description("Comprehensive error scan") + .option("--verbose", "Show recovered issues and more detail") + .action( + async ( + tracePath: string | undefined, + options: Record, + ) => { + await runTraceCommand("diagnose", tracePath, { + verbose: options.verbose === true, + }); + }, + ); + + // diagnose-all + trace + .command("diagnose-all") + .description("Batch diagnose all traces in test-results/") + .option("--verbose", "Show clean traces and more detail") + .action(async (options: Record) => { + await runTraceCommand("diagnose-all", undefined, { + verbose: options.verbose === true, + }); + }); +}; diff --git a/examples/src/commands/trace/trace-debug.ts b/examples/src/commands/trace/trace-debug.ts new file mode 100644 index 0000000..481db97 --- /dev/null +++ b/examples/src/commands/trace/trace-debug.ts @@ -0,0 +1,1605 @@ +/** + * E2E Trace Debug Tool + * + * LLM-friendly tool to inspect Playwright trace files for debugging E2E test failures. + * + * Usage: + * lambda-demo trace [trace-path] [options] + * + * Commands: + * summary - Overview + assertion expected/received + * errors - Errors with 2 screenshots before/after + * actions - High-level action sequence (goto, click, fill) + * screenshots - List all screenshots with index numbers + * screenshot - Get specific screenshot + neighbors + * console - Filter console logs + * around - Events around a timestamp + * timeline - Condensed chronological view + * diagnose - Comprehensive error scan (catches ALL known error patterns) + * diagnose-all - Diagnose traces in test-results/ (use --verbose for details) + */ +import { execFileSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as readline from "readline"; + +import type { + ActionResult, + AfterEvent, + BeforeEvent, + BrowserEvent, + ConsoleEvent, + ContextOptionsEvent, + ErrorResult, + LogEvent, + ScreencastFrameEvent, + ScreenshotResult, + TraceCommandOutput, + TraceContext, + TraceEvent, +} from "./trace-types"; + +// Configuration +const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Find the test-results directory using a heuristic: + * 1. Check process.cwd()/test-results + * 2. Search upward for a directory containing test-results/ + */ +function findTestResultsDir(): string { + // 1. Check current working directory + const cwdPath = path.join(process.cwd(), "test-results"); + + if (fs.existsSync(cwdPath)) { + return cwdPath; + } + + // 2. Search upward for test-results/ + const searchUpward = (startDir: string): string | null => { + if (startDir === path.dirname(startDir)) { + return null; + } + + const testResultsPath = path.join(startDir, "test-results"); + + if (fs.existsSync(testResultsPath)) { + return testResultsPath; + } + + return searchUpward(path.dirname(startDir)); + }; + + const result = searchUpward(process.cwd()); + + if (result) { + return result; + } + + throw new Error( + "Could not find test-results directory.\n" + + "Run from a directory with test-results/, or a parent directory containing it.", + ); +} + +// Lazy-loaded test results directory - using closure for memoization +const getTestResultsDir = (() => { + // eslint-disable-next-line no-restricted-syntax -- let needed for memoization closure + let cached: string | null = null; + + return (): string => { + if (!cached) { + cached = findTestResultsDir(); + } + + return cached; + }; +})(); + +// Format relative time (e.g., "2 minutes ago") +function formatRelativeTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours} hour${hours === 1 ? "" : "s"} ago`; + } + + if (minutes > 0) { + return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + } + + return `${seconds} second${seconds === 1 ? "" : "s"} ago`; +} + +// Get trace info (path, age, staleness) +function getTraceInfo(traceZipPath: string): { + age: number; + ageFormatted: string; + isStale: boolean; + mtime: Date; +} { + const stat = fs.statSync(traceZipPath); + const age = Date.now() - stat.mtimeMs; + + return { + age, + ageFormatted: formatRelativeTime(age), + isStale: age > STALE_THRESHOLD_MS, + mtime: stat.mtime, + }; +} + +// Find all trace.zip files with metadata +function findAllTraces(): Array<{ + testName: string; + tracePath: string; + mtime: Date; + age: number; + ageFormatted: string; + isStale: boolean; +}> { + if (!fs.existsSync(getTestResultsDir())) { + return []; + } + + const dirs = fs.readdirSync(getTestResultsDir()); + const traces = dirs + .map((dir) => { + const traceZip = path.join(getTestResultsDir(), dir, "trace.zip"); + + if (!fs.existsSync(traceZip)) { + return null; + } + + const info = getTraceInfo(traceZip); + + return { + testName: dir, + tracePath: traceZip, + ...info, + }; + }) + .filter( + ( + trace, + ): trace is { + testName: string; + tracePath: string; + mtime: Date; + age: number; + ageFormatted: string; + isStale: boolean; + } => trace !== null, + ) + // Sort by mtime descending (newest first) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + return traces; +} + +// Interactive trace selection +async function selectTraceInteractively(): Promise { + const traces = findAllTraces(); + + if (traces.length === 0) { + throw new Error( + `No trace.zip files found in ${getTestResultsDir()}. Run an E2E test first.`, + ); + } + + console.error("\n💡 No trace path provided - entering interactive mode."); + console.error( + " Tip: Pass a path directly: lambda-demo trace \n", + ); + console.error("📋 Available traces:\n"); + + traces.forEach((trace, i) => { + const staleMarker = trace.isStale ? " ⚠️ STALE" : ""; + const timeStr = trace.mtime.toLocaleTimeString(); + console.error( + ` ${i + 1}. ${trace.testName}\n ${ + trace.ageFormatted + } (${timeStr})${staleMarker}\n`, + ); + }); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + return new Promise((resolve, reject) => { + rl.question("Select trace number: ", (answer) => { + rl.close(); + + const selection = parseInt(answer, 10); + + if (isNaN(selection) || selection < 1 || selection > traces.length) { + reject( + new Error( + `Invalid selection. Enter a number between 1 and ${traces.length}`, + ), + ); + + return; + } + + const selected = traces[selection - 1]; + + if (!selected) { + reject(new Error("Invalid selection")); + + return; + } + + resolve(selected.tracePath); + }); + }); +} + +// Print trace info header +function printTraceHeader(traceZipPath: string): void { + const info = getTraceInfo(traceZipPath); + const timeStr = info.mtime.toLocaleString(); + + console.error(`\n📂 Analyzing: ${traceZipPath}`); + console.error(`⏰ Created: ${info.ageFormatted} (${timeStr})`); + + if (info.isStale) { + console.error( + `⚠️ WARNING: This trace is ${info.ageFormatted}. Run a fresh E2E test for current results.`, + ); + } + + console.error(""); +} + +// Unzip trace to sibling directory with caching +function unzipTrace(traceZipPath: string): string { + const traceDir = path.dirname(traceZipPath); + const outputDir = path.join(traceDir, "unzipped"); + + // Check cache: if unzipped/ exists and is newer than trace.zip, reuse it + if (fs.existsSync(outputDir)) { + const zipStat = fs.statSync(traceZipPath); + const unzippedStat = fs.statSync(outputDir); + + if (unzippedStat.mtimeMs > zipStat.mtimeMs) { + // Cache hit - unzipped is newer than zip + return outputDir; + } + + // Cache stale - remove and re-unzip + fs.rmSync(outputDir, { recursive: true }); + } + + // Unzip fresh + fs.mkdirSync(outputDir, { recursive: true }); + execFileSync("unzip", ["-q", traceZipPath, "-d", outputDir]); + + return outputDir; +} + +// Get trace directory (unzip if needed) - async for interactive selection +async function getTraceDirAsync(inputPath?: string): Promise<{ + traceDir: string; + traceZipPath: string | null; +}> { + // Interactive selection if not provided + const tracePath = inputPath + ? path.isAbsolute(inputPath) + ? inputPath + : path.join(process.cwd(), inputPath) + : await selectTraceInteractively(); + + // Resolve relative paths for interactive selection result + const resolvedTracePath = path.isAbsolute(tracePath) + ? tracePath + : path.join(process.cwd(), tracePath); + + // If it's a zip file, unzip it + if (resolvedTracePath.endsWith(".zip")) { + if (!fs.existsSync(resolvedTracePath)) { + throw new Error(`Trace file not found: ${resolvedTracePath}`); + } + + printTraceHeader(resolvedTracePath); + + return { + traceDir: unzipTrace(resolvedTracePath), + traceZipPath: resolvedTracePath, + }; + } + + // If it's a directory, use it directly + if ( + fs.existsSync(resolvedTracePath) && + fs.statSync(resolvedTracePath).isDirectory() + ) { + // Try to find the parent trace.zip for header info + const parentZip = path.join(path.dirname(resolvedTracePath), "trace.zip"); + const traceZipPath = fs.existsSync(parentZip) ? parentZip : null; + + if (traceZipPath) { + printTraceHeader(traceZipPath); + } + + return { traceDir: resolvedTracePath, traceZipPath }; + } + + throw new Error(`Invalid trace path: ${resolvedTracePath}`); +} + +// Sync version for diagnose-all (which provides explicit paths) +function getTraceDir(inputPath: string): string { + // Resolve relative paths + const tracePath = path.isAbsolute(inputPath) + ? inputPath + : path.join(process.cwd(), inputPath); + + // If it's a zip file, unzip it + if (tracePath.endsWith(".zip")) { + if (!fs.existsSync(tracePath)) { + throw new Error(`Trace file not found: ${tracePath}`); + } + + return unzipTrace(tracePath); + } + + // If it's a directory, use it directly + if (fs.existsSync(tracePath) && fs.statSync(tracePath).isDirectory()) { + return tracePath; + } + + throw new Error(`Invalid trace path: ${tracePath}`); +} + +// Parse JSONL file +function parseJsonlFile(filePath: string): T[] { + if (!fs.existsSync(filePath)) { + return []; + } + + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n").filter((line) => line.trim()); + + return lines + .map((line) => { + try { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSONL line parsed to generic trace event type + return JSON.parse(line) as T; + } catch { + console.error(`Failed to parse line: ${line.slice(0, 100)}...`); + + return null; + } + }) + .filter((event): event is T => event !== null); +} + +// Parse trace files into context +function parseTrace(traceDir: string): TraceContext { + const testTrace = parseJsonlFile(path.join(traceDir, "test.trace")); + + // Load ALL numbered trace files (0-trace.trace, 1-trace.trace, etc.) + // This ensures we capture errors from all browser contexts/pages + const traceFiles = fs + .readdirSync(traceDir) + .filter((f) => /^\d+-trace\.trace$/.test(f)) + .sort(); // Ensure consistent order + + const browserTrace = traceFiles.flatMap((f) => + parseJsonlFile(path.join(traceDir, f)), + ); + + // Extract context options + const contextOptions = browserTrace.find( + (e): e is ContextOptionsEvent => e.type === "context-options", + ); + + // Extract screenshots + const screenshots = browserTrace.filter( + (e): e is ScreencastFrameEvent => e.type === "screencast-frame", + ); + + // Extract console messages + const consoleMessages = browserTrace.filter( + (e): e is ConsoleEvent => e.type === "console", + ); + + // Extract errors (pageError events) + const errors = browserTrace.filter( + (e): e is BrowserEvent => e.type === "event" && e.method === "pageError", + ); + + // Extract actions (before/after pairs for key API calls) + const beforeEvents = browserTrace.filter( + (e): e is BeforeEvent => e.type === "before", + ); + const afterEvents = browserTrace.filter( + (e): e is AfterEvent => e.type === "after", + ); + + const afterMap = new Map(afterEvents.map((e) => [e.callId, e])); + + const actions = beforeEvents + .filter((e) => isKeyAction(e.apiName)) + .map((before) => { + const after = afterMap.get(before.callId); + + return { + ...before, + endTime: after?.endTime, + error: after?.error?.message, + }; + }); + + // Extract logs + const logs = browserTrace.filter((e): e is LogEvent => e.type === "log"); + + // Determine test status - find first assertion error in test.trace stdout + const assertionError = testTrace.find((event) => { + if (event.type !== "stdout" && event.type !== "stderr") { + return false; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stdout/stderr trace events have untyped text field + const { text } = event as { text: string }; + + return text.includes("Error:") || text.includes("Expected"); + }); + + // Check for page errors + const [firstError] = errors; + + // Determine status and error info + const statusInfo = (() => { + if (assertionError) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stdout/stderr trace events have untyped text/timestamp fields + const { text, timestamp } = assertionError as { + text: string; + timestamp: number; + }; + + return { + status: "failed" as const, + errorMessage: text, + errorTime: timestamp, + }; + } + + if (firstError) { + return { + status: "failed" as const, + errorMessage: firstError.params.error?.error.message, + errorTime: firstError.time, + }; + } + + return { + status: "passed" as const, + errorMessage: undefined, + errorTime: undefined, + }; + })(); + + const { status, errorMessage, errorTime } = statusInfo; + + // Calculate duration + const startTime = contextOptions?.monotonicTime || 0; + const lastEvent = browserTrace[browserTrace.length - 1]; + const endTime = + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- trace events are a union type, need to try each variant + (lastEvent as BeforeEvent)?.startTime || + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- trace events are a union type, need to try each variant + (lastEvent as AfterEvent)?.endTime || + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- trace events are a union type, need to try each variant + (lastEvent as ScreencastFrameEvent)?.timestamp || + startTime; + const duration = endTime - startTime; + + return { + tracePath: traceDir, + testName: contextOptions?.title || path.basename(traceDir), + duration, + status, + errorTime, + errorMessage, + screenshots, + consoleMessages, + errors, + actions, + logs, + }; +} + +// Check if an API name is a key action +function isKeyAction(apiName: string): boolean { + const keyActions = [ + "page.goto", + "page.click", + "locator.click", + "locator.fill", + "locator.type", + "locator.press", + "page.fill", + "page.type", + "expect.toHaveValue", + "expect.toBeVisible", + "expect.toHaveText", + "expect.toHaveURL", + "expect.toHaveTitle", + "expect.not.toBeVisible", + ]; + + return keyActions.some((action) => apiName.includes(action)); +} + +// Get screenshot path from sha1 +function getScreenshotPath(traceDir: string, sha1: string): string { + return path.join(traceDir, "resources", sha1); +} + +// Find screenshots around a timestamp +function getScreenshotsAround( + screenshots: ScreencastFrameEvent[], + timestamp: number, + contextCount: number, + traceDir: string, +): ScreenshotResult { + // Sort screenshots by timestamp + const sorted = [...screenshots].sort((a, b) => a.timestamp - b.timestamp); + + // Handle empty array + if (sorted.length === 0) { + return { + target: "", + targetIndex: -1, + timestamp: 0, + before: [], + after: [], + }; + } + + // Find closest screenshot using reduce + const closestIndex = sorted.reduce((bestIndex, screenshot, currentIndex) => { + const currentDiff = Math.abs(screenshot.timestamp - timestamp); + const bestScreenshot = sorted[bestIndex]; + const bestDiff = bestScreenshot + ? Math.abs(bestScreenshot.timestamp - timestamp) + : Infinity; + + return currentDiff < bestDiff ? currentIndex : bestIndex; + }, 0); + + const target = sorted[closestIndex]; + + if (!target) { + return { + target: "", + targetIndex: -1, + timestamp: 0, + before: [], + after: [], + }; + } + + // Get screenshots before using slice and map + const before = sorted + .slice(Math.max(0, closestIndex - contextCount), closestIndex) + .map((screenshot, idx) => ({ + index: Math.max(0, closestIndex - contextCount) + idx, + path: getScreenshotPath(traceDir, screenshot.sha1), + timestamp: screenshot.timestamp, + })); + + // Get screenshots after using slice and map + const after = sorted + .slice(closestIndex + 1, closestIndex + 1 + contextCount) + .map((screenshot, idx) => ({ + index: closestIndex + 1 + idx, + path: getScreenshotPath(traceDir, screenshot.sha1), + timestamp: screenshot.timestamp, + })); + + return { + target: getScreenshotPath(traceDir, target.sha1), + targetIndex: closestIndex, + timestamp: target.timestamp, + before, + after, + }; +} + +// Output JSON result +function output(command: string, tracePath: string, results: T): void { + const result: TraceCommandOutput = { + command, + tracePath, + results, + }; + console.log(JSON.stringify(result, null, 2)); +} + +// Load error-context.md from test results directory (Playwright writes this for failed tests) +function loadErrorContext(traceDir: string): { + testName: string; + errorMessage: string; + errorLocation: string; +} | null { + const testResultsDir = path.dirname(traceDir); + const errorContextPath = path.join(testResultsDir, "error-context.md"); + + if (!fs.existsSync(errorContextPath)) { + return null; + } + + const content = fs.readFileSync(errorContextPath, "utf-8"); + + // Parse the markdown file + // Format: + // # Test info + // - Name: + // - Location: + // # Error details + // ``` + // + // ``` + + const nameMatch = content.match(/- Name:\s*(.+)/); + const locationMatch = content.match(/- Location:\s*(.+)/); + const errorMatch = content.match(/# Error details\s*```\s*([\s\S]*?)```/); + + if (!errorMatch) { + return null; + } + + return { + testName: nameMatch?.[1]?.trim() || "Unknown test", + errorMessage: errorMatch[1]?.trim() || "Unknown error", + errorLocation: locationMatch?.[1]?.trim() || "Unknown location", + }; +} + +// Command: summary +function commandSummary(ctx: TraceContext): void { + // Check for error-context.md which Playwright writes for failed tests + const errorContext = loadErrorContext(ctx.tracePath); + + // If error-context.md exists, the test failed (even if trace says "passed") + const actualStatus = errorContext ? "failed" : ctx.status; + const actualErrorMessage = errorContext?.errorMessage || ctx.errorMessage; + + const result = { + testName: ctx.testName, + duration: ctx.duration ? `${(ctx.duration / 1000).toFixed(2)}s` : "unknown", + status: actualStatus, + errorTime: ctx.errorTime, + errorMessage: actualErrorMessage?.slice(0, 500), + errorLocation: errorContext?.errorLocation, + counts: { + screenshots: ctx.screenshots.length, + consoleMessages: ctx.consoleMessages.length, + errors: ctx.errors.length, + actions: ctx.actions.length, + }, + }; + output("summary", ctx.tracePath, result); +} + +// Command: errors +function commandErrors(ctx: TraceContext): void { + // Check for error-context.md which has the actual assertion failure + const errorContext = loadErrorContext(ctx.tracePath); + + const errorResults = ctx.errors.map((error) => { + const errorInfo = error.params.error?.error; + const screenshotResult = getScreenshotsAround( + ctx.screenshots, + error.time, + 2, + ctx.tracePath, + ); + + // Get console messages around the error (+/-1000ms) + const consoleContext = ctx.consoleMessages.filter( + (c) => Math.abs(c.time - error.time) < 1000, + ); + + return { + timestamp: error.time, + message: errorInfo?.message || "Unknown error", + stack: errorInfo?.stack, + screenshots: { + before: screenshotResult.before, + target: { + index: screenshotResult.targetIndex, + path: screenshotResult.target, + timestamp: screenshotResult.timestamp, + }, + after: screenshotResult.after, + }, + consoleContext, + }; + }); + + // If no pageError events, check for error-context.md or failed assertions via timing + const fallbackResult = (() => { + if (errorResults.length > 0) { + return []; + } + + // First check error-context.md (most reliable for assertion failures) + if (errorContext) { + // Use the last screenshot timestamp as the error time (assertion failed at end) + const lastScreenshotTime = + ctx.screenshots.length > 0 + ? Math.max(...ctx.screenshots.map((s) => s.timestamp)) + : 0; + + const screenshotResult = getScreenshotsAround( + ctx.screenshots, + lastScreenshotTime, + 2, + ctx.tracePath, + ); + + return [ + { + timestamp: lastScreenshotTime, + message: errorContext.errorMessage, + location: errorContext.errorLocation, + source: "error-context.md" as const, + screenshots: { + before: screenshotResult.before, + target: { + index: screenshotResult.targetIndex, + path: screenshotResult.target, + timestamp: screenshotResult.timestamp, + }, + after: screenshotResult.after, + }, + consoleContext: [], + }, + ]; + } + + // Fallback to ctx.errorTime if available + if (ctx.errorTime) { + const screenshotResult = getScreenshotsAround( + ctx.screenshots, + ctx.errorTime, + 2, + ctx.tracePath, + ); + + const consoleContext = ctx.consoleMessages.filter( + (c) => Math.abs(c.time - ctx.errorTime!) < 1000, + ); + + return [ + { + timestamp: ctx.errorTime, + message: ctx.errorMessage || "Test failed", + screenshots: { + before: screenshotResult.before, + target: { + index: screenshotResult.targetIndex, + path: screenshotResult.target, + timestamp: screenshotResult.timestamp, + }, + after: screenshotResult.after, + }, + consoleContext, + }, + ]; + } + + return []; + })(); + + const results: ErrorResult[] = [...errorResults, ...fallbackResult]; + + output("errors", ctx.tracePath, results); +} + +// Command: actions +function commandActions(ctx: TraceContext): void { + const results: ActionResult[] = ctx.actions.map((action) => { + const result: ActionResult = { + timestamp: action.startTime, + endTime: action.endTime, + duration: action.endTime ? action.endTime - action.startTime : undefined, + apiName: action.apiName, + }; + + // Extract selector/url/value from params + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- action params are untyped in trace format + const params = action.params as Record; + + if (params.selector) { + result.selector = String(params.selector); + } + + if (params.url) { + result.url = String(params.url); + } + + if (params.value) { + result.value = String(params.value); + } + + if (action.error) { + result.error = action.error; + } + + return result; + }); + + output("actions", ctx.tracePath, results); +} + +// Command: screenshots +function commandScreenshots(ctx: TraceContext): void { + const sorted = [...ctx.screenshots].sort((a, b) => a.timestamp - b.timestamp); + + const results = sorted.map((s, index) => ({ + index, + timestamp: s.timestamp, + path: getScreenshotPath(ctx.tracePath, s.sha1), + width: s.width, + height: s.height, + })); + + output("screenshots", ctx.tracePath, results); +} + +// Command: screenshot +function commandScreenshot( + ctx: TraceContext, + options: Record, +): void { + const at = options.at || "error"; + const contextCount = Number(options.context) || (at === "error" ? 2 : 0); + + const getTimestamp = (): number => { + if (at === "error") { + return ( + ctx.errorTime || + ctx.screenshots[ctx.screenshots.length - 1]?.timestamp || + 0 + ); + } + + if (typeof at === "number" || !isNaN(Number(at))) { + const index = Number(at); + const sorted = [...ctx.screenshots].sort( + (a, b) => a.timestamp - b.timestamp, + ); + const screenshot = sorted[index]; + + if (!screenshot) { + throw new Error( + `Screenshot index ${index} out of range (0-${sorted.length - 1})`, + ); + } + + return screenshot.timestamp; + } + + return Number(at); + }; + + const timestamp = getTimestamp(); + + const result = getScreenshotsAround( + ctx.screenshots, + timestamp, + contextCount, + ctx.tracePath, + ); + output("screenshot", ctx.tracePath, result); +} + +// Command: console +function commandConsole( + ctx: TraceContext, + options: Record, +): void { + const limit = Number(options.limit) || 100; + + // Apply filters using chained operations + const filteredMessages = ctx.consoleMessages + .filter((m) => { + // Filter by type if specified + if (options.type) { + return m.messageType === String(options.type); + } + + return true; + }) + .filter((m) => { + // Filter by regex if specified + if (options.filter) { + const regex = new RegExp(String(options.filter), "i"); + + return regex.test(m.text); + } + + return true; + }) + .slice(0, limit); + + const results = filteredMessages.map((m) => ({ + timestamp: m.time, + type: m.messageType, + text: m.text, + location: m.location + ? `${m.location.url.split("/").pop()}:${m.location.lineNumber}` + : undefined, + })); + + output("console", ctx.tracePath, results); +} + +// Command: around +function commandAround( + ctx: TraceContext, + options: Record, +): void { + const time = Number(options.time); + + if (isNaN(time)) { + throw new Error("--time is required for around command"); + } + + const window = Number(options.window) || 500; + + // Get events within window + const consoleInWindow = ctx.consoleMessages.filter( + (c) => Math.abs(c.time - time) <= window, + ); + + const actionsInWindow = ctx.actions.filter( + (a) => Math.abs(a.startTime - time) <= window, + ); + + const logsInWindow = ctx.logs.filter( + (l) => Math.abs(l.time - time) <= window, + ); + + const errorsInWindow = ctx.errors.filter( + (e) => Math.abs(e.time - time) <= window, + ); + + // Get nearest screenshot + const screenshotResult = getScreenshotsAround( + ctx.screenshots, + time, + 1, + ctx.tracePath, + ); + + output("around", ctx.tracePath, { + targetTime: time, + window: `±${window}ms`, + nearestScreenshot: screenshotResult.target, + events: { + console: consoleInWindow.map((c) => ({ + time: c.time, + type: c.messageType, + text: c.text.slice(0, 200), + })), + actions: actionsInWindow.map((a) => ({ + time: a.startTime, + apiName: a.apiName, + })), + logs: logsInWindow.map((l) => ({ + time: l.time, + message: l.message, + })), + errors: errorsInWindow.map((e) => ({ + time: e.time, + message: e.params.error?.error.message, + })), + }, + }); +} + +// Command: timeline +function commandTimeline(ctx: TraceContext): void { + type TimelineEvent = { + time: number; + type: string; + description: string; + }; + + // Build description for an action + const buildActionDescription = ( + action: TraceContext["actions"][0], + ): string => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- action params are untyped in trace format + const params = action.params as Record; + const parts = [action.apiName]; + + if (params.url) { + parts[0] = `${parts[0]} → ${params.url}`; + } + + if (params.selector) { + parts[0] = `${parts[0]} → ${String(params.selector).slice(0, 50)}`; + } + + if (action.error) { + parts[0] = `${parts[0]} [FAILED: ${action.error}]`; + } + + return parts[0]; + }; + + // Build all events using functional composition + const startEvent: TimelineEvent = { + time: 0, + type: "start", + description: `Test started: ${ctx.testName}`, + }; + + const actionEvents: TimelineEvent[] = ctx.actions.map((action) => ({ + time: action.startTime, + type: "action", + description: buildActionDescription(action), + })); + + const consoleErrorEvents: TimelineEvent[] = ctx.consoleMessages + .filter((m) => m.messageType === "error") + .map((msg) => ({ + time: msg.time, + type: "console.error", + description: msg.text.slice(0, 100), + })); + + const pageErrorEvents: TimelineEvent[] = ctx.errors.map((error) => ({ + time: error.time, + type: "pageError", + description: error.params.error?.error.message || "Unknown error", + })); + + const endEvent: TimelineEvent[] = ctx.duration + ? [ + { + time: ctx.duration, + type: "end", + description: `Test ${ctx.status}: ${(ctx.duration / 1000).toFixed( + 2, + )}s`, + }, + ] + : []; + + // Combine all events and sort by time + const events = [ + startEvent, + ...actionEvents, + ...consoleErrorEvents, + ...pageErrorEvents, + ...endEvent, + ].sort((a, b) => a.time - b.time); + + output("timeline", ctx.tracePath, events); +} + +// Diagnose results type +interface DiagnoseResults { + summary: string; + issueCount: number; + recoveredCount?: number; // Count of issues that were recovered (hidden unless verbose) + byCategory: Record; + primaryDiagnosis: { + explanation: string; + recommendation: string; + }; + issues: Array<{ + category: string; + timestamp: number; + source: string; + explanation: string; + solution: string; + snippet: string; + recovered?: boolean; // True if this issue was recovered (only shown in verbose mode) + }>; +} + +// Get diagnose results (shared by diagnose and diagnose-all) +function getDiagnoseResults( + ctx: TraceContext, + options?: { verbose?: boolean }, +): DiagnoseResults { + const verbose = options?.verbose ?? false; + // Known error patterns with explanations and solutions (generic patterns only) + const errorPatterns: Array<{ + pattern: RegExp; + category: string; + explanation: string; + solution: string; + }> = [ + // Timeout errors + { + pattern: /Timed out.*waiting for/i, + category: "Timeout", + explanation: + "A Playwright assertion or wait timed out. The expected element/state did not appear in time.", + solution: + "Check screenshots to see actual UI state. May indicate a backend error preventing the UI from updating.", + }, + // Element not found + { + pattern: /element.*not found|locator.*not found/i, + category: "Element Not Found", + explanation: + "The selector did not match any element in the DOM at the time of the action.", + solution: + "Verify the selector is correct and the element exists. Check if a wait is needed.", + }, + // Navigation errors + { + pattern: /navigation.*failed|net::ERR_/i, + category: "Navigation Error", + explanation: "Page navigation failed due to network or server error.", + solution: + "Check if the URL is correct and the server is running. Look for network errors in console.", + }, + // Console errors + { + pattern: /console\.error|Uncaught Error|Unhandled Promise/i, + category: "Console Error", + explanation: "JavaScript error logged to browser console.", + solution: + "Check the error message and stack trace to identify the source of the error.", + }, + // Network errors + { + pattern: /status of 4\d\d/i, + category: "HTTP 4xx Error", + explanation: "Client error response from server (400-499).", + solution: "Check the specific status code and request details.", + }, + { + pattern: /status of 5\d\d/i, + category: "HTTP 5xx Error", + explanation: + "Server error response (500-599). Backend crashed or unavailable.", + solution: "Check server logs for the actual error.", + }, + { + pattern: /ECONNREFUSED|ETIMEDOUT/i, + category: "Connection Error", + explanation: "Failed to connect to a service (database, API, etc.).", + solution: "Verify all services are running and accessible.", + }, + // Strict mode violations + { + pattern: /strict mode violation|multiple elements/i, + category: "Strict Mode Violation", + explanation: + "Selector matched multiple elements when Playwright expected exactly one.", + solution: + "Use a more specific selector to match only the intended element.", + }, + // Frame issues + { + pattern: /frame.*detached|frame.*navigated/i, + category: "Frame Detached", + explanation: + "The frame was removed from the DOM or navigated while an action was in progress.", + solution: + "Wait for frame to be stable before interacting, or handle frame lifecycle explicitly.", + }, + // Dialog handling + { + pattern: /dialog.*not handled|unexpected dialog/i, + category: "Unhandled Dialog", + explanation: + "A browser dialog (alert/confirm/prompt) appeared but was not handled.", + solution: + 'Add page.on("dialog") handler before triggering the action that shows the dialog.', + }, + ]; + + interface DiagnosticIssue { + category: string; + timestamp: number; + source: "console" | "pageError" | "testOutput"; + text: string; + explanation: string; + solution: string; + recovered?: boolean; + } + + const issues: DiagnosticIssue[] = []; + + // Patterns to ignore (noise that doesn't help debugging) + const ignorePatterns = [ + /favicon\.ico/i, // A missing favicon is never test-relevant + ]; + + const shouldIgnore = (text: string, location?: string): boolean => { + const combined = `${text} ${location || ""}`; + + return ignorePatterns.some((pattern) => pattern.test(combined)); + }; + + // Helper to find pattern info + const findPatternInfo = ( + text: string, + ): (typeof errorPatterns)[0] | undefined => + errorPatterns.find((patternInfo) => patternInfo.pattern.test(text)); + + // Search all console messages + ctx.consoleMessages.forEach((msg) => { + // Skip noise + if (shouldIgnore(msg.text, msg.location?.url)) { + return; + } + + const patternInfo = findPatternInfo(msg.text); + + if (patternInfo) { + // eslint-disable-next-line no-restricted-syntax -- building arrays imperatively for diagnostic output + issues.push({ + category: patternInfo.category, + timestamp: msg.time, + source: "console", + text: msg.text.slice(0, 800), + explanation: patternInfo.explanation, + solution: patternInfo.solution, + }); + } + }); + + // Search page errors + ctx.errors.forEach((error) => { + const errorMsg = error.params.error?.error.message || ""; + const patternInfo = findPatternInfo(errorMsg); + + if (patternInfo) { + // eslint-disable-next-line no-restricted-syntax -- building arrays imperatively for diagnostic output + issues.push({ + category: patternInfo.category, + timestamp: error.time, + source: "pageError", + text: errorMsg.slice(0, 800), + explanation: patternInfo.explanation, + solution: patternInfo.solution, + }); + } + }); + + // CRITICAL: Also search test.trace stdout/stderr/error for errors + const testTracePath = path.join(ctx.tracePath, "test.trace"); + + if (fs.existsSync(testTracePath)) { + const testTrace = parseJsonlFile(testTracePath); + + testTrace.forEach((event) => { + // Handle stdout/stderr events + if (event.type === "stdout" || event.type === "stderr") { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stdout/stderr trace events have untyped text/timestamp fields + const { text, timestamp } = event as { + text: string; + timestamp: number; + }; + + const patternInfo = findPatternInfo(text); + + if (patternInfo) { + // eslint-disable-next-line no-restricted-syntax -- building arrays imperatively for diagnostic output + issues.push({ + category: patternInfo.category, + timestamp, + source: "testOutput", + text: text.slice(0, 800), + explanation: patternInfo.explanation, + solution: patternInfo.solution, + }); + } + } + + // Handle error events (Playwright test failures, Node.js errors) + if (event.type === "error") { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- error trace events have untyped message field + const { message } = event as { message: string }; + + if (message) { + const patternInfo = findPatternInfo(message); + + if (patternInfo) { + // eslint-disable-next-line no-restricted-syntax -- building arrays imperatively for diagnostic output + issues.push({ + category: patternInfo.category, + timestamp: 0, // error events don't have timestamps + source: "testOutput", + text: message.slice(0, 800), + explanation: patternInfo.explanation, + solution: patternInfo.solution, + }); + } + } + } + }); + } + + // Check error-context.md for test assertion failures (Playwright writes this for failed tests) + const errorContext = loadErrorContext(ctx.tracePath); + + if (errorContext) { + const errorPatternInfo = findPatternInfo(errorContext.errorMessage); + + // eslint-disable-next-line no-restricted-syntax -- building arrays imperatively for diagnostic output + issues.push({ + category: errorPatternInfo?.category || "Test Assertion Failed", + timestamp: 0, + source: "testOutput", + text: errorContext.errorMessage.slice(0, 800), + explanation: + errorPatternInfo?.explanation || + "A Playwright assertion (expect) timed out or failed. The expected element or state was not found.", + solution: + errorPatternInfo?.solution || + "Check the screenshots to see the actual UI state. The element may not have appeared due to a backend error or timing issue.", + }); + } + + // Sort by timestamp + issues.sort((a, b) => a.timestamp - b.timestamp); + + // Deduplicate issues (same category + similar text within 1 second) + const uniqueIssues = issues.reduce((acc, issue) => { + const isDuplicate = acc.some( + (existing) => + existing.category === issue.category && + Math.abs(existing.timestamp - issue.timestamp) < 1000, + ); + + return isDuplicate ? acc : [...acc, issue]; + }, []); + + // Separate recovered issues from non-recovered issues + const recoveredIssues = uniqueIssues.filter((issue) => issue.recovered); + const nonRecoveredIssues = uniqueIssues.filter((issue) => !issue.recovered); + const activeIssues = verbose ? uniqueIssues : nonRecoveredIssues; + + // Group by category for summary + const byCategory: Record = {}; + + nonRecoveredIssues.forEach((issue) => { + byCategory[issue.category] = (byCategory[issue.category] || 0) + 1; + }); + + // Determine severity + const hasCritical = + byCategory["Test Assertion Failed"] || byCategory["HTTP 5xx Error"]; + + // Build primary recommendation based on most critical active issue + const { primaryRecommendation, primaryExplanation } = (() => { + if (activeIssues.length === 0) { + const recoveryNote = + recoveredIssues.length > 0 + ? ` (${recoveredIssues.length} recovered issue(s) hidden, use --verbose to see)` + : ""; + + return { + primaryRecommendation: `Trace looks clean.${recoveryNote} If test still fails, check server logs.`, + primaryExplanation: + "No known error patterns were detected in the trace.", + }; + } + + const criticalIssue = activeIssues.find( + (i) => + !i.recovered && + (i.category === "Test Assertion Failed" || + i.category === "HTTP 5xx Error"), + ); + const primaryIssue = criticalIssue || activeIssues[0]; + + return { + primaryRecommendation: primaryIssue?.solution || "", + primaryExplanation: primaryIssue?.explanation || "", + }; + })(); + + // Build summary message + const recoveryNote = + recoveredIssues.length > 0 + ? verbose + ? ` (+${recoveredIssues.length} recovered)` + : ` (${recoveredIssues.length} recovered issue(s) hidden)` + : ""; + + const summary = + nonRecoveredIssues.length === 0 + ? `✅ No known error patterns detected${recoveryNote}` + : hasCritical + ? `🚨 CRITICAL ISSUES FOUND - ${nonRecoveredIssues.length} unique error(s) detected${recoveryNote}` + : `⚠️ ${nonRecoveredIssues.length} potential issue(s) detected${recoveryNote}`; + + const results: DiagnoseResults = { + summary, + issueCount: nonRecoveredIssues.length, + recoveredCount: + recoveredIssues.length > 0 ? recoveredIssues.length : undefined, + byCategory, + primaryDiagnosis: { + explanation: primaryExplanation, + recommendation: primaryRecommendation, + }, + issues: activeIssues.slice(0, 10).map((issue) => ({ + category: issue.category, + timestamp: issue.timestamp, + source: issue.source, + explanation: issue.explanation, + solution: issue.solution, + snippet: + issue.text.slice(0, 200) + (issue.text.length > 200 ? "..." : ""), + recovered: issue.recovered, + })), + }; + + return results; +} + +// Command: diagnose (comprehensive error scan) +function commandDiagnose( + ctx: TraceContext, + options: Record, +): void { + const verbose = options.verbose === true; + const results = getDiagnoseResults(ctx, { verbose }); + output("diagnose", ctx.tracePath, results); +} + +// Command: diagnose-all (diagnose all traces in test-results) +function commandDiagnoseAll( + options: Record, +): void { + if (!fs.existsSync(getTestResultsDir())) { + console.log(JSON.stringify({ error: "No test-results directory found" })); + + return; + } + + const verbose = options.verbose === true; + const dirs = fs.readdirSync(getTestResultsDir()); + + // Find all trace.zip files + const traces = dirs + .map((dir) => { + const traceZip = path.join(getTestResultsDir(), dir, "trace.zip"); + + return fs.existsSync(traceZip) + ? { testName: dir, tracePath: traceZip } + : null; + }) + .filter( + (trace): trace is { testName: string; tracePath: string } => + trace !== null, + ); + + if (traces.length === 0) { + console.log( + JSON.stringify({ error: "No trace.zip files found in test-results/" }), + ); + + return; + } + + console.log(`\n🔍 Analyzing ${traces.length} trace(s)...\n`); + + // Diagnose each trace and count skipped + const skippedCount = traces.reduce((count, { testName, tracePath }) => { + try { + const traceDir = getTraceDir(tracePath); + const ctx = parseTrace(traceDir); + const results = getDiagnoseResults(ctx, { verbose }); + + // Skip traces with no issues unless --verbose flag is set + if (!verbose && results.issueCount === 0) { + return count + 1; + } + + console.log(`\n━━━ ${testName} ━━━`); + output("diagnose", ctx.tracePath, results); + + return count; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`\n━━━ ${testName} ━━━`); + console.log(JSON.stringify({ error: message })); + + return count; + } + }, 0); + + if (skippedCount > 0) { + console.log( + `\n✅ ${skippedCount} trace(s) with no issues (use --verbose to show)`, + ); + } +} + +/** + * Run a trace command with the given options. + * Exported for use with Commander.js integration. + */ +export async function runTraceCommand( + command: string, + tracePath: string | undefined, + options: Record, +): Promise { + try { + // Handle diagnose-all specially (it finds its own traces) + if (command === "diagnose-all") { + commandDiagnoseAll(options); + + return; + } + + // Get trace directory (interactive if no path provided) + const { traceDir } = await getTraceDirAsync(tracePath); + + // Parse trace + const ctx = parseTrace(traceDir); + + // Execute command + switch (command) { + case "summary": + commandSummary(ctx); + + break; + case "errors": + commandErrors(ctx); + + break; + case "actions": + commandActions(ctx); + + break; + case "screenshots": + commandScreenshots(ctx); + + break; + case "screenshot": + commandScreenshot(ctx, options); + + break; + case "console": + commandConsole(ctx, options); + + break; + case "around": + commandAround(ctx, options); + + break; + case "timeline": + commandTimeline(ctx); + + break; + case "diagnose": + commandDiagnose(ctx, options); + + break; + default: + console.error(`Unknown command: ${command}`); + console.error( + "Available commands: summary, errors, actions, screenshots, screenshot, console, around, timeline, diagnose, diagnose-all", + ); + process.exit(1); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message })); + process.exit(1); + } +} diff --git a/examples/src/commands/trace/trace-types.ts b/examples/src/commands/trace/trace-types.ts new file mode 100644 index 0000000..be57664 --- /dev/null +++ b/examples/src/commands/trace/trace-types.ts @@ -0,0 +1,256 @@ +/** + * TypeScript types for Playwright trace file events. + * These types represent the JSONL events found in trace files. + */ + +// Base event with discriminator +export type TraceEvent = + | ContextOptionsEvent + | BeforeEvent + | AfterEvent + | ScreencastFrameEvent + | ConsoleEvent + | LogEvent + | BrowserEvent + | InputEvent + | FrameSnapshotEvent + | StdoutEvent + | StderrEvent + | TestErrorEvent; + +// Context options (first line of trace) +export interface ContextOptionsEvent { + type: "context-options"; + version: number; + origin: "testRunner" | "library"; + browserName: string; + platform: string; + wallTime: number; + monotonicTime: number; + sdkLanguage: string; + title?: string; + contextId?: string; + testIdAttributeName?: string; + options?: { + viewport?: { width: number; height: number }; + baseURL?: string; + userAgent?: string; + isMobile?: boolean; + [key: string]: unknown; + }; +} + +// Before event - action starting +export interface BeforeEvent { + type: "before"; + callId: string; + startTime: number; + apiName: string; + class: string; + method: string; + params: Record; + stepId?: string; + pageId?: string; + parentId?: string; + beforeSnapshot?: string; + stack?: StackFrame[]; +} + +// After event - action completed +export interface AfterEvent { + type: "after"; + callId: string; + endTime: number; + result?: Record; + error?: { message: string; stack?: string }; + afterSnapshot?: string; + annotations?: unknown[]; +} + +// Screenshot frame +export interface ScreencastFrameEvent { + type: "screencast-frame"; + pageId: string; + sha1: string; + width: number; + height: number; + timestamp: number; + frameSwapWallTime: number; +} + +// Console message +export interface ConsoleEvent { + type: "console"; + messageType: "log" | "info" | "warning" | "error" | "debug"; + text: string; + args?: Array<{ preview?: string; value: unknown }>; + location?: { + url: string; + lineNumber: number; + columnNumber: number; + }; + time: number; + pageId: string; +} + +// Playwright log message +export interface LogEvent { + type: "log"; + callId: string; + time: number; + message: string; +} + +// Browser event (includes page errors) +export interface BrowserEvent { + type: "event"; + time: number; + class: string; + method: string; + params: { + error?: { + error: { + message: string; + stack?: string; + name?: string; + }; + }; + pageId?: string; + [key: string]: unknown; + }; + pageId?: string; +} + +// Input event (click/type coordinates) +export interface InputEvent { + type: "input"; + callId: string; + point?: { x: number; y: number }; + inputSnapshot?: string; +} + +// Frame snapshot (DOM state) +export interface FrameSnapshotEvent { + type: "frame-snapshot"; + snapshot: { + callId: string; + snapshotName: string; + pageId: string; + frameId: string; + frameUrl: string; + html: unknown; + viewport: { width: number; height: number }; + timestamp: number; + wallTime: number; + collectionTime: number; + resourceOverrides: unknown[]; + isMainFrame: boolean; + }; +} + +// Stdout from test runner +export interface StdoutEvent { + type: "stdout"; + timestamp: number; + text: string; +} + +// Stderr from test runner +export interface StderrEvent { + type: "stderr"; + timestamp: number; + text: string; +} + +// Test error event +export interface TestErrorEvent { + type: "error"; + message: string; + stack?: Array<{ + file: string; + line: number; + column: number; + function?: string; + }>; +} + +// Stack frame from stacks file +export interface StackFrame { + file: string; + line: number; + column: number; + function?: string; +} + +// Stacks file structure +export interface StacksFile { + files: string[]; + stacks: Array<[number, Array<[number, number, number, string]>]>; +} + +// Parsed trace context (our internal representation) +export interface TraceContext { + tracePath: string; + testName: string; + duration?: number; + status: "passed" | "failed" | "unknown"; + errorTime?: number; + errorMessage?: string; + expectedValue?: string; + receivedValue?: string; + screenshots: ScreencastFrameEvent[]; + consoleMessages: ConsoleEvent[]; + errors: BrowserEvent[]; + actions: Array; + logs: LogEvent[]; +} + +// Command output format +export interface TraceCommandOutput { + command: string; + tracePath: string; + results: T; + error?: string; +} + +// Screenshot result with context +export interface ScreenshotResult { + target: string; + targetIndex: number; + timestamp: number; + action?: string; + before: Array<{ index: number; path: string; timestamp: number }>; + after: Array<{ index: number; path: string; timestamp: number }>; +} + +// Error result with context +export interface ErrorResult { + timestamp: number; + message: string; + stack?: string; + screenshots: { + before: Array<{ index: number; path: string; timestamp: number }>; + target: { index: number; path: string; timestamp: number } | null; + after: Array<{ index: number; path: string; timestamp: number }>; + }; + consoleContext: ConsoleEvent[]; +} + +// Action result for timeline/actions command +export interface ActionResult { + timestamp: number; + endTime?: number; + duration?: number; + apiName: string; + selector?: string; + url?: string; + value?: string; + error?: string; +} + +// Console filter options +export interface ConsoleFilterOptions { + type?: "log" | "info" | "warning" | "error" | "debug"; + filter?: string; + limit?: number; +} diff --git a/examples/src/commands/wait/index.ts b/examples/src/commands/wait/index.ts new file mode 100644 index 0000000..cfd1f1d --- /dev/null +++ b/examples/src/commands/wait/index.ts @@ -0,0 +1,317 @@ +/** + * Wait command - Peek-then-block waiting for tmux pane output. + * + * First call (peek): Returns after ~1s with current output. Creates receipt. + * Second call (same args): Blocks until pattern/idle/exit. Deletes receipt. + */ +import { execFileSync } from "child_process"; +import { createHash } from "crypto"; +import * as fs from "fs"; +import * as path from "path"; + +import { RECEIPT_DIR } from "../../config"; +import { getPaneStatusTag, isShellIdle } from "../tmux/pane-status"; + +import chalk from "chalk"; + +const RECEIPT_EXPIRY_MS = 2 * 60 * 1000; // 2 minutes +const POLL_INTERVAL_MS = 1000; + +function ensureReceiptDir(): void { + if (!fs.existsSync(RECEIPT_DIR)) { + fs.mkdirSync(RECEIPT_DIR, { recursive: true }); + } +} + +function receiptKey(pattern: string | undefined, pane: string): string { + const data = `${pattern ?? ""}:${pane}`; + const hash = createHash("md5").update(data).digest("hex"); + + return `${paneHash(pane)}_${hash}`; +} + +function paneHash(pane: string): string { + return createHash("md5").update(pane).digest("hex").slice(0, 8); +} + +function receiptPath(key: string): string { + return path.join(RECEIPT_DIR, `${key}.receipt`); +} + +function hasValidReceipt(key: string): boolean { + const rPath = receiptPath(key); + + if (!fs.existsSync(rPath)) { + return false; + } + + const stat = fs.statSync(rPath); + const age = Date.now() - stat.mtimeMs; + + if (age > RECEIPT_EXPIRY_MS) { + fs.unlinkSync(rPath); + + return false; + } + + return true; +} + +function createReceipt(key: string): void { + ensureReceiptDir(); + fs.writeFileSync(receiptPath(key), Date.now().toString()); +} + +function deleteReceipt(key: string): void { + const rPath = receiptPath(key); + + if (fs.existsSync(rPath)) { + fs.unlinkSync(rPath); + } +} + +function sendReceiptPath(pane: string): string { + return path.join(RECEIPT_DIR, `${paneHash(pane)}_send.receipt`); +} + +/** + * Create a send receipt for a pane. + * This records that `sendText` already captured output (the "peek"), + * so the next `wait` call can skip peek and go straight to blocking. + */ +export function createSendReceipt(pane: string): void { + ensureReceiptDir(); + fs.writeFileSync(sendReceiptPath(pane), Date.now().toString()); +} + +/** + * Check if a valid (non-expired) send receipt exists for a pane. + */ +export function hasSendReceipt(pane: string): boolean { + const rPath = sendReceiptPath(pane); + + if (!fs.existsSync(rPath)) { + return false; + } + + const stat = fs.statSync(rPath); + const age = Date.now() - stat.mtimeMs; + + if (age > RECEIPT_EXPIRY_MS) { + fs.unlinkSync(rPath); + + return false; + } + + return true; +} + +/** + * Remove the send receipt for a pane. + */ +export function deleteSendReceipt(pane: string): void { + const rPath = sendReceiptPath(pane); + + if (fs.existsSync(rPath)) { + fs.unlinkSync(rPath); + } +} + +/** + * Clear all wait receipts for a given pane. + * Called when a new command is sent to a pane so the next wait starts fresh (peek). + */ +export function clearReceiptsForPane(pane: string): void { + if (!fs.existsSync(RECEIPT_DIR)) { + return; + } + + const prefix = paneHash(pane); + const files = fs.readdirSync(RECEIPT_DIR); + + files.forEach((file) => { + if (file.startsWith(prefix) && file.endsWith(".receipt")) { + fs.unlinkSync(path.join(RECEIPT_DIR, file)); + } + }); +} + +export function capturePaneOutput(pane: string): string { + try { + return execFileSync("tmux", ["capture-pane", "-t", pane, "-p"], { + encoding: "utf-8", + timeout: 5000, + }).trimEnd(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + + if (msg.includes("can't find") || msg.includes("no such")) { + console.error(chalk.red(`Error: Pane "${pane}" not found.`)); + listAvailablePanes(); + process.exit(1); + } + + throw e; + } +} + +function listAvailablePanes(): void { + try { + const panes = execFileSync( + "tmux", + ["list-panes", "-a", "-F", "#{session_name}:#{window_name}.#{pane_index}"], + { encoding: "utf-8", timeout: 5000 }, + ).trim(); + console.error(chalk.yellow("\nAvailable panes:")); + + panes.split("\n").forEach((p) => { + console.error(` ${p}`); + }); + } catch { + // tmux not running — nothing to list + } +} + +function cleanPattern(raw: string): string { + // Remove backslash-escaped pipes — common mistake from shell escaping + return raw.replace(/\\\|/g, "|"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function tailLines(output: string, n: number): string { + const lines = output.split("\n"); + + return lines.slice(-n).join("\n"); +} + +async function runPeek( + pane: string, + lines: number, + key: string, +): Promise { + // Brief delay to capture fresh output + await sleep(1000); + const output = capturePaneOutput(pane); + createReceipt(key); + const status = getPaneStatusTag(pane); + console.log( + `=== PEEK (${new Date().toLocaleTimeString()}) ${status} — Run same command again to block until done ===`, + ); + console.log(tailLines(output, lines)); +} + +function buildRegex(pattern: string | undefined): RegExp | undefined { + if (!pattern) { + return undefined; + } + + const cleaned = cleanPattern(pattern); + + try { + return new RegExp(cleaned); + } catch { + console.error(chalk.red(`Invalid regex pattern: "${cleaned}"`)); + process.exit(1); + } +} + +async function runWait( + pattern: string | undefined, + pane: string, + timeoutSec: number, + idleSec: number, + lines: number, + key: string, +): Promise { + const regex = buildRegex(pattern); + + // eslint-disable-next-line no-restricted-syntax -- reassigned in poll loop + let elapsed = 0; + // eslint-disable-next-line no-restricted-syntax -- reassigned in poll loop + let lastOutput = ""; + // eslint-disable-next-line no-restricted-syntax -- reassigned in poll loop + let idleCount = 0; + + while (elapsed < timeoutSec * 1000) { + const output = capturePaneOutput(pane); + + // Pattern match check + if (regex && regex.test(output)) { + deleteReceipt(key); + + console.log( + `=== COMPLETED (found pattern at ${new Date().toLocaleTimeString()}) ===`, + ); + console.log(tailLines(output, lines)); + process.exit(0); + } + + // Check if the shell is idle (command finished) + if (isShellIdle(pane)) { + deleteReceipt(key); + + console.log( + `=== COMPLETED (command exited at ${new Date().toLocaleTimeString()}) ===`, + ); + console.log(tailLines(output, lines)); + process.exit(0); + } + + // Idle detection + if (output === lastOutput) { + idleCount += POLL_INTERVAL_MS; + + if (idleCount >= idleSec * 1000) { + deleteReceipt(key); + console.log( + `=== IDLE (no new output for ${idleSec}s at ${new Date().toLocaleTimeString()}) ===`, + ); + console.log(tailLines(output, lines)); + process.exit(0); + } + } else { + idleCount = 0; + lastOutput = output; + } + + await sleep(POLL_INTERVAL_MS); + elapsed += POLL_INTERVAL_MS; + } + + // Timeout — keep receipt so next call blocks immediately instead of peeking + createReceipt(key); + console.log( + `=== STILL RUNNING (timeout at ${new Date().toLocaleTimeString()}) ===`, + ); + const output = capturePaneOutput(pane); + console.log(tailLines(output, lines)); + process.exit(1); +} + +export interface RunWaitOptions { + pattern?: string; + pane: string; + timeoutSec?: number; + idleSec?: number; + lines?: number; +} + +export async function runWaitCommand(opts: RunWaitOptions): Promise { + const { pattern, pane, timeoutSec = 120, idleSec = 15, lines = 40 } = opts; + const key = receiptKey(pattern, pane); + + const sendReceiptValid = hasSendReceipt(pane); + + if (sendReceiptValid) { + deleteSendReceipt(pane); + } + + if (!hasValidReceipt(key) && !sendReceiptValid) { + await runPeek(pane, lines, key); + } else { + await runWait(pattern, pane, timeoutSec, idleSec, lines, key); + } +} diff --git a/src/config.ts b/examples/src/config.ts similarity index 83% rename from src/config.ts rename to examples/src/config.ts index 635e041..08c9a05 100644 --- a/src/config.ts +++ b/examples/src/config.ts @@ -9,7 +9,7 @@ export const SESSION_NAME = "lambda"; /** Panes that are Claude Code agent windows requiring mode switching */ -export const AGENT_PANES = ["john", "sam", "james"]; +export const AGENT_PANES = ["ron", "sam", "james"]; /** Directory for wait/send receipts (peek-then-block system) */ export const RECEIPT_DIR = "/tmp/lambda-wait-receipts"; @@ -18,7 +18,7 @@ export const RECEIPT_DIR = "/tmp/lambda-wait-receipts"; export const WINDOW_ENV_VAR = "LAMBDA_WINDOW"; /** - * When true, `lambda ship` defaults to dry-run (prints what would happen). + * When true, `lambda-demo ship` defaults to dry-run (prints what would happen). * Set to false or pass --no-dry-run for real git operations. */ export const DEMO_MODE = true; diff --git a/src/utils/tasks.ts b/examples/src/utils/tasks.ts similarity index 100% rename from src/utils/tasks.ts rename to examples/src/utils/tasks.ts diff --git a/examples/src/utils/validate.ts b/examples/src/utils/validate.ts new file mode 100644 index 0000000..805ca39 --- /dev/null +++ b/examples/src/utils/validate.ts @@ -0,0 +1,37 @@ +/** + * Argument validators for the commands that hand an argument to git, gh or + * tmux. Each returns its input unchanged, or throws naming what it expected. + */ +const TICKET_ID = /^[A-Z]+-\d+$/; +const PANE_NAME = /^[\w-]+$/; +const WINDOW_TARGET = /^[\w-]+:[\w-]+$/; + +export const assertTicketId = (ticketId: string): string => { + if (!TICKET_ID.test(ticketId)) { + throw new Error( + `Not a ticket id: ${JSON.stringify(ticketId)} (expected e.g. DEMO-1)`, + ); + } + + return ticketId; +}; + +export const assertPaneName = (pane: string): string => { + if (!PANE_NAME.test(pane)) { + throw new Error( + `Not a pane name: ${JSON.stringify(pane)} (letters, digits, _ and - only)`, + ); + } + + return pane; +}; + +export const assertWindowTarget = (target: string): string => { + if (!WINDOW_TARGET.test(target)) { + throw new Error( + `Not a tmux window: ${JSON.stringify(target)} (expected session:window, e.g. lambda:ron)`, + ); + } + + return target; +}; diff --git a/tickets/DEMO-1.md b/examples/tickets/DEMO-1.md similarity index 95% rename from tickets/DEMO-1.md rename to examples/tickets/DEMO-1.md index 8e23602..8144e88 100644 --- a/tickets/DEMO-1.md +++ b/examples/tickets/DEMO-1.md @@ -31,4 +31,4 @@ The todo app currently shows a list of items but has no way to remove them. User ## Testing -Run `lambda e2e todos.spec.ts` to validate. +Run `lambda-demo e2e todos.spec.ts` to validate. diff --git a/tickets/DEMO-2.md b/examples/tickets/DEMO-2.md similarity index 95% rename from tickets/DEMO-2.md rename to examples/tickets/DEMO-2.md index d70e845..235d59b 100644 --- a/tickets/DEMO-2.md +++ b/examples/tickets/DEMO-2.md @@ -32,4 +32,4 @@ Todo items currently display as plain text. Users need to mark items as complete ## Testing -Run `lambda e2e todos.spec.ts` to validate. +Run `lambda-demo e2e todos.spec.ts` to validate. diff --git a/tickets/DEMO-3.md b/examples/tickets/DEMO-3.md similarity index 96% rename from tickets/DEMO-3.md rename to examples/tickets/DEMO-3.md index 11c2d2c..6865457 100644 --- a/tickets/DEMO-3.md +++ b/examples/tickets/DEMO-3.md @@ -36,4 +36,4 @@ After implementing checkboxes (DEMO-2), users need a way to filter the todo list ## Testing -Run `lambda e2e todos.spec.ts` to validate. +Run `lambda-demo e2e todos.spec.ts` to validate. diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..32e7993 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowImportingTsExtensions": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/instructions/building/check-ticket.md b/instructions/building/check-ticket.md new file mode 100644 index 0000000..a1e1175 --- /dev/null +++ b/instructions/building/check-ticket.md @@ -0,0 +1,359 @@ +--- +name: check-ticket +description: Verify an implementation is complete against its ticket. A strict completeness audit, run by QA, which never wrote the code. +--- + +# Check Ticket Implementation + +Verifies that the work fully implements a ticket. This is a **strict completeness audit**, not a code review. + +**Philosophy:** agents do well with clear, explicit requirements. If a ticket was written well, every requirement is verifiable. If you can't verify it, the implementation is incomplete. + +## Usage + +``` +/check-ticket PAWNEE-123 +``` + +**Argument:** `$ARGUMENTS` (the ticket id) + +--- + +## Instructions + +### Step 1: Fetch the ticket + +```bash +lambda tickets pull $ARGUMENTS +``` + +### Step 2: Read the ticket thoroughly + +Read the description (`.lambda/pulled/$ARGUMENTS/description.md`, or the ticket file itself on a markdown project) and any images beside it. + +**Note on comments:** rejections, retry notes and other workflow comments reflect intermediate states that have been resolved by the time this runs. Do not treat them as evidence of current failures. Audit only the code as it exists now. + +**Note on an `## Addendum`:** if the description ends with one, it is **part of the ticket, not a comment on it** — decisions recorded by the dev after the work started, from measurements that did not exist when the ticket was written. Where it contradicts anything earlier in the description, **the addendum wins, and you audit against the ticket as amended.** It is subject to its own check first — see [The Addendum](#the-addendum--decisions-recorded-after-the-work-started) below. + +**Extract EVERY checkable item:** + +| Category | What to extract | +| ------------------------- | ----------------------------------------------------- | +| **Acceptance criteria** | Explicit requirements (usually checkboxes or bullets) | +| **Implied requirements** | Things obviously needed but not stated | +| **Test plan** | Unit tests, E2E tests, coverage expectations | +| **Edge cases** | Error states, empty states, boundary conditions | +| **UI/UX specs** | Behaviour, interaction patterns, component specs | +| **Files to modify** | Expected file changes listed in the ticket | +| **Schema changes** | Data or type changes specified | +| **Verification commands** | Commands to run to verify (e.g. `lambda test X`) | + +### Step 3: Gather the implementation + +List what this ticket changed — committed and uncommitted, untracked files included: + +```bash +BASE=$(git merge-base HEAD origin/HEAD 2>/dev/null || git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -1) +git diff --name-status "$BASE" +git diff --cached --name-status +git status --porcelain +``` + +Then read the key changed files to understand what was built **for this specific ticket**. + +--- + +## Step 4: Audit against five dimensions + +Evaluate the implementation against **each dimension** independently. A failure in ANY dimension means the ticket is INCOMPLETE. + +--- + +### Dimension 1: Acceptance Criteria (40%) + +**Every acceptance criterion must be explicitly verified.** + +| Criterion type | How to verify | Failure condition | +| -------------- | --------------------------------- | ------------------------------ | +| Feature works | Code inspection OR test assertion | No code implementing it | +| Data changes | Schema check + migration | Schema missing or incorrect | +| API behaviour | Unit test assertion | No test covering the behaviour | +| Error handling | Test for the error case | No error handling code or test | + +**Wrong-tier acceptance criteria — reject them.** A criterion requiring **concurrency, bursts or races** at E2E or integration tier is wrong-tier. Flag it and require the property be pushed down to **unit**, where clock and ordering are controlled. + +**Split every criterion into OUTCOME and MECHANISM before you rule on it.** + +| Part | What it is | Status | +| ------------- | ----------------------------------------------------------------------------------------------------------- | ------------ | +| **Outcome** | What becomes observable — user-visible behaviour, stored data, an API response, an error the caller can see | **Binding** | +| **Mechanism** | The route the ticket named to get there — a hook, query, file path, line number, prop, suite name | **Advisory** | + +A ticket is written before the code exists. Its outcomes are the author's decision; its mechanisms are the author's best guess at how the code will look. **Guesses go stale. Decisions do not.** + +- Outcome missing or partial → **✗ INCOMPLETE.** Always. +- Outcome delivered by a different mechanism → **⚠️ Deviation**, and the dimension still passes, provided the deviation clears the bar in "Ticket Drift" below. + +**Checklist:** + +- [ ] Every acceptance criterion has its **outcome** delivered +- [ ] Each delivered outcome is evidenced — code that implements it AND a test that asserts it +- [ ] Every mechanism deviation is ruled against the Ticket Drift bar and listed in the report +- [ ] No unstated assumptions (if the ticket says "CSV export", don't also add "PDF export") +- [ ] Scope boundaries respected (no unrequested features) + +**Auto-fail:** any criterion whose outcome is missing; any outcome only partly delivered; a mechanism deviation that fails the Ticket Drift bar. + +**Not a failure (report as ⚠️ Deviation):** the outcome delivered by a different mechanism; the criterion describing existing code incorrectly (stale line number, a file that never existed); a criterion prescribing a verification the harness cannot physically perform; the prescribed fix **attempted, measured and reverted on a negative result**, recorded in the `## Addendum` (see [below](#an-attempted-fix-with-a-negative-result-is-a-completed-criterion)). + +--- + +### Dimension 2: Test Plan (30%) + +**This is where agent implementations most often fail.** Test plans are requirements, not suggestions. **TDD is the standard**: every implementation has unit tests. + +**Unit tests:** exist at the specified place; cover the happy path, error cases and edge cases; pass when run. + +**E2E tests (when the ticket specifies them):** the spec exists; it covers the exact user flow described; its assertions match the expected outcomes; it uses the specified test data. + +**Auto-fail, each:** no unit tests; "tests for X and Y" but only X is tested; "E2E test for flow A" but no E2E test exists; tests that don't assert the specific behaviour; tests skipped or pending; tests that fail. + +**Test plan drift (⚠️ Deviation, not a failure).** The binding part of a test plan is the **behaviour that ends up asserted**; the suite name and path are a guess. A named suite that didn't exist and was created, or the same behaviour asserted in the suite that actually owns it (name the file and test), or a behaviour already pinned by an existing test (quote it) — all deviations. **What still fails:** the behaviour is asserted **nowhere**. Go find the assertion and quote it, or fail the dimension. + +--- + +### Dimension 3: Scenario Coverage (15%) + +| Scenario type | How to check | Example | +| ------------------ | ------------------- | -------------------------------- | +| Happy path | Main flow works | User submits form, sees success | +| Empty state | What if no data? | List is empty, shows placeholder | +| Error state | What if it fails? | Network error shows a message | +| Boundary condition | Min/max values | 0 items, 1000 items, max length | +| Concurrency | Races | Two people edit the same record | +| Permission denial | Unauthorized access | A non-owner tries to delete | + +**Auto-fail:** a listed edge case not implemented; an obvious error state not handled; an empty state not considered when displaying a list. + +--- + +### Dimension 4: File Modification Accuracy (10%) + +**If the ticket specifies files to modify, verify the diff matches:** every listed file changed; changes match the specification; extra files justified. + +**Not a failure** (report as ℹ️ or ⚠️): extra supporting files that enable the ticket's goals; a file the table said to MODIFY that didn't exist and was CREATED (or the reverse); line numbers that no longer match (verify the named _thing_ exists, not the number); a file from "Scope boundaries" changed because the ticket's assumption about it was wrong. + +**The design system follows the diff.** When the project has a design system, run `lambda design system changed`. An `update the copy` line means an app file that a hand-made copy in the design system's `patterns/` folder was made from changed. Read that file's diff and rule on it yourself: this audit runs before the design check, so there is no design-check report to lean on. When the change alters what is drawn (a size, colour, spacing, radius, border, an element added, removed or reordered, on-screen wording) and the pattern is unchanged, that is a **gap**: fail this dimension with "Missing file: /patterns/ still copies the old ." (`` is the design system's folder, `design.system.dir` in `lambda.json`). A logic-only change (a handler, a query, a prop the pattern never draws) is not a gap: note it as "ℹ️ ``: logic only, the copy stands". `rebuild only` lines and `NO DESIGN CHANGES` are never a gap: the next build of the design system picks them up. A project with no design system ("This project has no design system yet") has nothing to check here. + +--- + +### Dimension 5: Verification Commands (5%) + +**If the ticket names verification commands, they must be run and pass.** A command naming a target that doesn't exist (a renamed spec, a suite never written) is drift: run the equivalent that does exist and report both. A command that exists and **fails** is still ✗. + +--- + +## Step 5: The verdict + +**This is a gate, not a weighted average.** Any failure in any dimension → INCOMPLETE. COMPLETE only when every dimension passes. + +### The verdict is derived, not judged + +A dimension is failed **if and only if** your report lists at least one `✗` under it. + +- **`✗` entries → failed.** Name it in `--failed`. +- **Only `⚠️` and `ℹ️` entries → PASSES.** A deviation is a passing outcome carrying a note. +- **Zero `✗` → record `--complete`.** There is no third state. + +Do not let a long list of deviations _feel_ like a failure. Count the `✗` marks. + +### Stability + +The same tree audited twice must produce the same verdict. **If a dimension passed on an earlier run and nothing in that area has changed, you may only fail it now by naming the specific OUTCOME that is missing.** Re-reading the same deviation and grading it differently is oscillation, not discovery. + +--- + +## Step 6: Report + +### If everything passes + +``` +## ✅ COMPLETE + +Ticket $ARGUMENTS is fully implemented. + +### Acceptance Criteria: ✓ +- [x] - verified by + +### Test Plan: ✓ +- Unit tests: tests covering +- E2E tests: covering +- All tests passing + +### Scenario Coverage: ✓ +- Happy path: implemented and tested +- Edge cases: + +### Deviations (if any) — these do NOT fail a dimension: +- ⚠️ `` — + - Ticket said: "" + - Shipped instead: + - Technical cause: + - Outcome met: + +### Files Modified: ✓ +### Verification Commands: ✓ +- `` - passed +``` + +### If anything fails + +``` +## ❌ INCOMPLETE + +Ticket $ARGUMENTS is NOT fully implemented. + +### Failed Dimensions: + +**Acceptance Criteria: ✗** +- [ ] "" - NOT IMPLEMENTED + - Evidence: + +**Test Plan: ✗** +- [ ] Ticket requires: "" + - Missing: + +### Deviations (if any) — these do NOT fail a dimension: +- ⚠️ … + +### Passing Dimensions: +- Files Modified: ✓ +``` + +--- + +## Step 7: Record the verdict, then STOP + +**Record it.** This is the one action you take. `lambda ship` refuses a ticket without a recorded COMPLETE verdict — a verdict that exists only as text in this pane gates nothing. + +```bash +# All dimensions passed +lambda verdict record $ARGUMENTS --complete + +# Passed, with the ticket's letter shifted to deliver its intent +lambda verdict record $ARGUMENTS --complete --deviation "Acceptance Criteria: the sort happens in the query, behaviour asserted in parks.test.ts" + +# Failed +lambda verdict record $ARGUMENTS --incomplete --failed "Acceptance Criteria,Test Plan" +``` + +Record the verdict you actually reported — the command refuses COMPLETE alongside a failed dimension. + +**Every `⚠️` in your report becomes a `--deviation`, prefixed with its dimension; every `✗` becomes a `--failed`.** A deviation never blocks the gate, but it must reach the record. + +**Do not record INCOMPLETE for a ticket whose outcomes are all delivered.** If the only remaining marks are `⚠️`, the verdict is COMPLETE. + +**Then STOP.** Do not fix anything. Do not create tasks. Do not ask what to do. Do not offer suggestions. The caller reads your verdict and decides next steps. + +--- + +## The Addendum — decisions recorded after the work started + +A description may end with an `## Addendum`: decisions the dev made while the work was in flight, from measurements that did not exist when the ticket was written. **It is part of the ticket.** Where it contradicts anything above it, the addendum wins. + +### An attempted fix with a negative result is a COMPLETED criterion + +**A criterion whose prescribed fix was built, measured, and found not to work is MET.** The criterion asked for the work to be done; it was done, and the measurement said no. **A negative result is a result.** Grading it as unmet teaches the next dev that the cheapest way through this audit is to never run the experiment. + +**All four, or it is `✗`:** + +| # | Test | What proves it | +| --- | ----------------------------------------------------------------------- | --------------------------------------------------------- | +| 1 | **It was built**, not estimated | The addendum names what was written | +| 2 | **It was measured**, with numbers, on the thing the criterion was about | Before/after figures | +| 3 | **The result is recorded where the next reader lands** | The addendum holds the experiment and its result | +| 4 | **Nothing the criterion promised was dropped with it** | Point at the live assertion or guard that still covers it | + +⚠️ **"We decided against it" is not an attempt. "It looked expensive" is not a measurement.** An addendum that reasons its way out without having run anything fails test 1. + +⚠️ **A reverted attempt still passes test 4**, provided the thing the criterion protected is still measured by something after the revert. A check that still runs and reports as a named warning still measures; an assertion deleted, or a bound moved to fit the failure, does not. + +### The addendum must serve the ticket's original intent — this is a check, not advice + +Run this before auditing anything against the addendum: + +- [ ] **State the original intent in one sentence, sourced from the ORIGINAL description**, never from the addendum. +- [ ] **Rule every decision the addendum records** against that sentence: does it serve the intent, or reduce it? +- [ ] **A decision that drops or narrows a promised OUTCOME does not become legitimate by being written down.** The criterion is `✗`. +- [ ] **A decision that changes MECHANISM, or grows scope toward the intent, passes** (`⚠️` at most). +- [ ] **Each addendum decision is evidenced** — code, a test, or a measurement. + +**Drift is about HOW, never WHETHER.** An addendum records decisions and their evidence; it must not restate the acceptance criteria in easier words. + +### Nothing on this path blocks + +You rule on the addendum with the evidence in front of you, and you record a verdict. Never mark a ticket blocked over an addendum question, never hold a verdict pending confirmation from anyone, and never ask for the description to be rewritten before you will grade it. + +--- + +## Ticket Drift + +**A ticket is a set of decisions wrapped in a set of guesses.** The decisions — what must become true — are binding. The guesses — which file, hook, line, suite — were made before anyone opened the editor, and the codebase is allowed to disagree with them. + +### The three kinds of legitimate drift + +| Kind | What happened | Example | +| ------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| **Stale premise** | The ticket states something false about existing code | The criterion names a component that has since been split in two | +| **Mechanism substitution** | The outcome is delivered by a different route, for a stated technical cause | Following the named hook literally would add a second listener and bring back the crash the ticket fixes | +| **Environment-impossible spec** | The criterion prescribes a verification the harness can't perform | "Assert the measured pixel height" in a test environment with no layout engine | + +### The bar — all four, or it is a failure + +1. **The outcome landed** — it exists in the code and is asserted by a test. Point at both. +2. **There is a named technical cause.** "Cleaner", "simpler", "more idiomatic" are preferences, not causes — preference-drift is ✗. +3. **It is recorded where a reader will find it** — a code comment at the site, and a comment on the ticket (`lambda tickets comment`). +4. **Nothing shrank.** No observable the criterion promised went missing. + +### The author test + +> If the ticket's author read this deviation, would they say **"right — my ticket was wrong about the code"**, or **"no, I specifically wanted that"**? + +First → ⚠️, the dimension passes. Second, **or genuine uncertainty** → ✗. Ambiguity resolves toward the ticket. + +### The hard line + +**Drift is about HOW, never WHETHER.** A dropped outcome is INCOMPLETE however well argued. **Never edit the ticket's acceptance criteria to match what was built** — deviations get documented, never legislated away. + +### If the ticket is simply wrong + +A ticket that mis-describes the codebase is a **ticket defect**, not an implementation gap. If every outcome is delivered and only the ticket's own factual errors stand between INCOMPLETE and COMPLETE → **record COMPLETE with the deviations listed.** Blocking a ticket is for an outcome that **cannot be delivered** — missing access, an authorization nobody here holds, a dependency that doesn't exist — never for a stale line number. + +--- + +## Strictness rules + +1. **Strict about outcomes, not wording.** +2. **No partial credit** — 90% of the outcomes is INCOMPLETE. +3. **No rationalizing** — "the logic is similar so it's covered" is INCOMPLETE. Point at the assertion or fail it. +4. **No recommendations** — say "test is missing", not "you could add a test". +5. **Quote the ticket** for every failure. +6. **Preference is not a cause.** + +**DO NOT:** offer choices; make excuses; suggest missing work is optional; give partial credit; ask what to do next; fail a requirement because the ticket described the code wrongly; fail one because its assertion lives in a differently-named file; write a report that passes and a record that fails. + +**DO:** report COMPLETE when every OUTCOME is delivered, however far the mechanism drifted; report INCOMPLETE when any outcome is missing, however small; list every deviation with its cause and pass it to `--deviation`; STOP after recording. + +## Common implementation gaps + +| Gap | How to detect | Example | +| -------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| **No unit tests** | Unit tests missing entirely | Auto-fail | +| **Test plan ignored** | The ticket lists 3 cases, 1 exists | Missing cases | +| **Happy path only** | No error-case tests | The ticket names an error message; no test for it | +| **Schema drift** | Types don't match the spec | Spec says `status: 'open' \| 'closed'`, code has `isOpen: boolean` | +| **Implicit assumptions** | Features not in the ticket | Asked for a CSV export, also added PDF | +| **Skipped edge cases** | A listed edge case isn't handled | "Handle an empty list" not implemented | +| **Assertions missing** | A test runs but asserts nothing | Navigates, never `expect()`s | +| **Missing data migration** | A permission, rule or schema shape changed with no migration for existing data | Field made required; records saved before it fail to load | +| **Drift used as cover for scope loss** | A claimed deviation whose "outcome met" is smaller than the criterion, or names no test | That is `✗`, not `⚠️` | diff --git a/instructions/building/code-review.md b/instructions/building/code-review.md new file mode 100644 index 0000000..90ffd14 --- /dev/null +++ b/instructions/building/code-review.md @@ -0,0 +1,230 @@ +--- +name: code-review +description: Review this ticket's changes (committed and uncommitted) for issues, with a grade and a path to A. +--- + +# /code-review - Code Review + +Review this ticket's changes for issues, prioritized by severity: everything this branch committed since it left the main branch, plus the working tree (staged, unstaged and untracked). + +## Instructions + +### Step 1: Gather the changes + +```bash +BASE=$(git merge-base HEAD origin/HEAD 2>/dev/null || git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -1) +git diff --name-status "$BASE" +git diff --cached --name-status +git status --porcelain +``` + +Then read each changed file's diff with `git diff "$BASE" -- `. + +**Review every changed file, untracked ones included** — `git diff` cannot see untracked files, and a ticket that adds new files is exactly the one most likely to hide something in them. If nothing changed, report "No changes to review." + +### Step 2: Gather context + +**Read the full content** of each modified file — reviewing just the diff misses important context. + +### Step 3: Check the hard blockers + +These MUST be fixed before shipping: + +| Check | Rule | +| --------------------------------- | ---------------------------------------------------------------------- | +| Lint suppressions | Must have a justification comment on the line above | +| `@ts-ignore` / `@ts-expect-error` | Must have a justification comment on the line above | +| Debug output | No `console.log` and friends in production code (test files exempt) | +| `TODO` / `FIXME` | Must reference a ticket: `TODO(PAWNEE-123)` | +| Commented-out code | 2+ consecutive commented lines = flag for deletion | +| Hard-coded secrets | `password = "..."`, `apiKey: "sk_..."`, `token = "ghp_..."` | +| `.only()` in tests | Skips other tests — never allowed | +| Test assertion tampering | Changing expected values to make tests pass instead of fixing the code | +| E2E accommodations in app code | App code changed solely to make E2E tests pass | +| E2E shortcuts | Test bodies that change data without driving the UI | +| Silent failures | Swallowing errors with conditionals instead of throwing | + +**Any hard blocker present = max grade D.** + +#### Test assertion tampering + +A common agent anti-pattern: instead of fixing broken code, change the test to match the (wrong) behaviour. **This hides regressions.** + +Red flags: changed expected values; weakened assertions (`toEqual()` → `toBeDefined()`, assertions removed); deleted test cases; test names rewritten to match new (broken) behaviour. + +For each modified test, compare the assertion changes with the production changes. Production code fixed a bug → the assertion change is likely legitimate. Production code unchanged but assertions changed, or expectations lowered → **red flag**. Ask: "What code change made this expected value change?" + +**Auto-fail:** assertions changed with no production change explaining why; test cases removed without a clear reason (such as the feature being deleted). + +#### E2E accommodations in app code + +App code must NEVER change solely to make E2E tests pass. If an E2E test is flaky or failing, fix the test, not the app. + +Red flags: comments like `// for e2e` or `// workaround for test`; skipping real behaviour (bypassing webhooks, short-circuiting async flows so tests needn't wait); `if (isTest)` branches; weakened logic (looser validation, removed race protections) "because tests run in parallel". + +| Scenario | Verdict | +| ----------------------------------------------------------- | ------------------------------------- | +| E2E found a real bug → app fix with a regression test | **Legitimate** | +| E2E is slow waiting for a webhook → skip the webhook in app | **Not OK** — poll or wait in the test | +| Parallel tests conflict → weaken a uniqueness rule in app | **Not OK** — fix test isolation | +| A test needs seeded state → auto-create it in app code | **Not OK** — use test setup | + +Ask: "Would we make this change if E2E tests didn't exist?" + +#### E2E shortcuts — bypassing the UI + +E2E tests verify the app as a real user experiences it. Inside a `test()` body, the only backend interactions allowed are **reads** (for verification); every **write** must come from driving the UI. Setup and teardown hooks may write directly. + +**Trace the full call chain.** A helper called in a test body may look harmless; read its source, and the source of everything it calls, until you reach UI actions, assertions, pure reads or side-effect-free leaves. A write three levels deep is as much a violation as one in the test body. + +Red flags in a test body: calls to backend functions that create or change data; direct database writes; `page.evaluate()` that sets app state or storage; direct API calls (`fetch`, `page.request`); `page.route()` mocking of the real backend. + +#### Silent failures + +Code must **fail loudly** when something required is missing. Wrapping a critical operation in an `if` that silently skips it hides the failure from you — not from the user, who still ends up in a broken state. + +| Pattern | Verdict | +| -------------------------------------------------------- | ---------------------------------------------- | +| `if (!store) throw new Error('Storage not configured')` | **Good** — fails loudly | +| `if (store) { save(...) }` (silently skips otherwise) | **Not OK** — the save silently never happens | +| `if (!user.preferences) return defaultPrefs` | **OK** — absence is expected and harmless | +| `catch (e) { logger.error('Save failed', e); throw e; }` | **Good** | +| `catch (e) { return undefined; }` | **Not OK** — the caller never learns it failed | + +Ask of every guard and catch: "If this triggers in production, would we want to know? Would the user be affected?" If yes, it must throw, or log at error level and rethrow. + +### Step 4: Security audit + +Every review includes one. + +**Always:** no secrets or credentials exposed; no sensitive data logged (passwords, tokens, personal data); input validated at system boundaries; error messages don't leak internals. + +**For auth and other sensitive code, also:** permission checks present and correct; audit logging for sensitive operations; rate limiting considered; session and token handling secure. + +### Step 5: Test coverage + +- New logic (functions, endpoints, hooks, pure utilities) MUST have unit tests. +- Complex component logic MUST have unit tests or be extracted into something testable. +- **Every bug fix MUST include a test that would have caught the bug.** If none: "Where is the failing test that reproduces this bug?" +- Flag tests that only assert `toBeDefined()` / `toBeTruthy()`, that lack error cases, or that assert nothing beyond "it doesn't throw". +- E2E files: no `test.skip()` without a ticket reference; no hard-coded shared test data where the project has a registry for it; no debugging artifacts left behind (screenshots, debug logs, arbitrary waits); every data change in a test body driven through the UI. + +### Step 6: Architecture + +Check the change against the project's own conventions (its agent instructions and the patterns around the changed code). In particular: + +- Business logic in UI components (belongs in hooks or services) +- Missing permission checks on writes +- External API calls without error handling +- Breaking changes without a migration path +- Duplicating a helper that already exists +- Mocking the project's own database in tests that should hit a real or emulated one (only external services should be mocked) +- External data entering the system without validation +- **Design system changes are code.** A change to the design system's source (its folder is `design.system.dir` in `lambda.json`): a pattern (`patterns/*.tsx`), `catalog.ts`, a reference screen (`screens/`), the sample data (`sampleData.ts`) or the source map (`sources.json`), is reviewed like any other change. This covers the copies the Dev updates while building, before this review; what `/design-check` changes later, right before ship, is checked by `lambda design system build --check` and the map test instead. Does the copy match the app source it names, with the exact values, not "similar"? Does a new pattern carry its `index.tsx` export, its catalog entry and its `sources.json` entry? Does no pattern import the app's runtime (data, auth, analytics)? + +### Step 7: Analyse for issues + +Review as a senior engineer. Focus on **real issues**, not nitpicks: + +1. **Critical** — security holes, data exposure, injection, authentication bypasses +2. **Warning** — logic bugs, silent failures, missing error handling, edge cases, races, null risks +3. **Performance** — N+1 queries, unnecessary re-renders, inefficient algorithms, leaks +4. **Suggestion** — naming, minor improvements (only if impactful) + +**Be honest.** If the code is solid, say so. Don't manufacture issues to look thorough. + +### Step 8: Output + +```` +## Code Review: issues found + +### Hard Blockers (X items) + +**** +`path/to/file.ts:42` + + + +### Security Audit + +- [x] No exposed secrets +- [ ] ⚠️ Missing permission check on updateShift + +### Test Coverage + +- [ ] ⚠️ New function `createShift` has no unit tests +- [x] Bug fix includes a regression test + +### Test Integrity + +- [x] No suspicious assertion changes + +### Critical (X issues) + +**** +`path/to/file.ts:42` + + + +**Suggested fix:** +```typescript +// Concrete, copy-paste-ready fix +``` + +### Warning (X issues) +### Performance (X issues) +### Suggestions (X items) +```` + +**Always end with a grade and, if below A, a path to A:** + +``` +## Grade: + + + +## Path to A + +- [ ] **** — +``` + +**Grading integrity rule:** every grade below A MUST have a "Path to A" with concrete, actionable items. If you can't name specific actions to reach A, the grade is A — give the A. At A or above, omit "Path to A" entirely. + +### Grading rubric + +| Grade | Criteria | +| ----- | ------------------------------------------------------------------------ | +| A+ | No issues. Exemplary code with excellent test coverage. | +| A | Minor suggestions only. Tests present and meaningful. | +| A- | 1–2 suggestions. Production-ready with adequate tests. | +| B+ | Few suggestions, no warnings. Test coverage acceptable. | +| B | Minor warnings or several suggestions. | +| B- | Multiple warnings OR missing test coverage for new code. | +| C+ | Significant warnings OR poor test quality. | +| C | Multiple warnings, likely bugs or missed edge cases. | +| C- | Serious issues OR no tests for testable code. | +| D | Critical issues OR hard blockers present. | +| F | Critical security or logic flaws OR multiple hard blockers. Do not ship. | + +### Grade caps + +| Condition | Max grade | +| ----------------------------------------------------- | --------- | +| Any hard blocker | **D** | +| Test assertion tampering | **F** | +| E2E accommodations in app code | **D** | +| E2E shortcuts in test bodies | **D** | +| Silent failures on critical operations | **D** | +| Debugging artifacts left in | **D** | +| Missing tests for new logic | **C** | +| Poor test quality (only `toBeDefined`, no edge cases) | **B** | +| Missing validation at a system boundary | **B+** | +| Any warning | **B+** | + +**A- or higher = production-ready, no fixes required.** + +## Review guidelines + +**DO:** explain _why_ each issue matters; give concrete, working fixes; reference `file:line`; consider the surrounding code, not just the diff; demand evidence the code works (tests); give security, logic errors and edge cases extra scrutiny — generated code misses these most. + +**DON'T:** nitpick formatting; flag things that are technically wrong but harmless; suggest rewrites that fix nothing; create alert fatigue; add issues to look thorough; block on old workflow comments on the ticket — review the code as it exists now. diff --git a/instructions/building/design-check.md b/instructions/building/design-check.md new file mode 100644 index 0000000..9981517 --- /dev/null +++ b/instructions/building/design-check.md @@ -0,0 +1,142 @@ +--- +description: Check whether a ticket's changes affect the project's design system and update its source (patterns, catalog, source map, reference screens, sample data) to match, in this same ticket. Runs right before ship. Never publishes, never commits. +argument-hint: +--- + +# Design Check (keeping the design system in step) + +Decide whether this ticket's changes alter how something looks in a way the design system should follow, and bring the design system's **source** up to date if they do. + +The design system is committed source in this project: the folder `design.system.dir` in `lambda.json` names (`.lambda/design-system` when it isn't set), and its manifest (`design.system.manifest`, by default `design-system.json` in that folder). Below, `` is that folder. Planning designs every changed screen from the design system as it is on the main branch, so a design system that still draws last month's card is planning designing against the wrong app. + +The goal: **the design system is always in step with the app.** The focus is this ticket's own changes, not a full re-audit. This runs in every building ticket, normal and quick alike; the Engineering manager skips it only when the project has no design system on the ticket's base. + +## Usage + +``` +/design-check +/design-check PAWNEE-12 +``` + +**Argument:** `$ARGUMENTS` (optional ticket id, for context only) + +--- + +## The question + +> Of the files this ticket changed, does any one change how something looks, such that the design system should change? + +**"Nothing in this diff changes the design" is a valid, common outcome.** Pure logic, data, copy-only text, tests and backend code are not design changes. Do not make an edit just to look busy. + +## Cost rules + +- **No E2E.** Not one spec. QA's test step runs what the ticket needs. +- **It never publishes.** This step changes the design system's source only, and those changes ride in the ticket's own commit, so they are reviewed and ship with the change. Never make an Artifact call here and never run `lambda design system publish`. +- **It never changes the manifest.** What a later ticket changed is read from git, not stored. Never run `lambda design system manifest write` here. +- **It does not commit.** `lambda ship` commits the ticket, design system changes included. +- **Read the app's source, not screenshots.** The patterns are copied from the source's styles, so the source is almost always enough. + +--- + +## Step 1: Ask the source map + +```bash +lambda design system changed +``` + +It reads `/sources.json` against this ticket's changes (committed since the merge base with the project's main branch, plus the working tree: staged, unstaged and untracked files) and never guesses. A file the map doesn't name is never a design change. Each line is labelled: + +| Line | What it means | What you do | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `rebuild only theme ` | A theme file the look is derived from changed: the next build's `tokens.json` changes | Step 6 rebuild | +| `rebuild only component …` | An app component the design system re-exports changed; the rebuild picks it up | Step 6 rebuild. Check its catalog example still fits | +| `rebuild only system ` | The design system's own source changed (this ticket edited it) | Step 6 rebuild | +| `update the copy pattern ← …` | An app file a hand-made copy in `/patterns/` was made from changed. **This is the case that drifts silently.** | Step 2 | +| `NO DESIGN CHANGES` | Nothing the map names changed | Step 3, then report | + +If it prints "This project has no design system yet (…)", there is nothing to keep in step: report `DESIGN: NO CHANGE` with that line and stop. + +If it fails (exits 1, most often because `/sources.json` is missing or not the map's shape), the map is what's broken: fix `/sources.json` (only the design system's files, never the app) so it names the design system's files under `system`, its theme files under `tokens`, its components under `components` and each pattern's app files under `patterns`, then run it again. If it fails a second time, stop and report `DESIGN: FAILED` with its error line, for the Engineering manager. + +## Step 2: Update each copy + +For every `update the copy` line: + +1. Read the app file's change (`git diff -- `, plus the working tree). +2. Decide whether it changes what the pattern draws: a size, colour, spacing, radius, border, an element added, removed or reordered, wording on the screen. A logic-only change (a handler, a query, a prop the pattern never draws) is not one: say "logic only, the copy stands" in the report and move on. +3. If it does, edit `/patterns/` so the copy matches the app again, with the exact values taken from the source, as the pattern's header comment says it was made. Update its `catalog.ts` entry (`example`, `props`, `summary`) when the shape changed. +4. If the change moved the pattern onto other app files (a component split, a file renamed), update that pattern's entry in `sources.json`. + +## Step 3: A new reusable element? + +Judge whether the diff adds a screen element that designs will want and that has no component or pattern yet (a new kind of card, a new row shape used on more than one screen). **Judge it; don't add every one.** One-off layout on one screen is not a pattern. + +A new pattern means four things: a file under `/patterns/` recreated from the source's styles (its header comment naming that source), its export in `/index.tsx`, its `catalog.ts` entry, and its `sources.json` entry. The map test fails when any of them is missing. A pattern never imports the app's runtime (data, auth, analytics): it copies how the piece looks, nothing more. + +## Step 4: Reference screens and sample data + +The reference screens (`/screens/`) are the app's real pages redrawn from the design system, and the sample data (`/sampleData.ts`) is what they and every design draw from. When this ticket visibly changed a page a reference screen draws (an element added, moved or restyled, a new section), update that screen to match. When the page now shows a kind of data the sample data doesn't have, add it to `sampleData.ts`: plain, made-up data, no dates to compute. + +## Step 5: Fix what else is out of step + +While reading the touched area, note any other place where it and the design system disagree (a pattern already stale before this ticket, a catalog example that no longer matches). Keep it to the area this ticket touched; this is not a full re-audit. + +That is **extra work**: code this ticket didn't write. Follow the project's own rule for extra work (its agent instructions, the files `lambda.json` lists under `instructions`). When that rule lets this ticket take it on, fix it here; when it says the work waits, don't fix it: record it where the rule says and report it as carried over. With no such rule, fix it here. + +A pattern this ticket's own change made stale is Step 2, never extra work. + +## Step 6: Rebuild and test + +Only when Steps 2 to 5 changed something, or Step 1 printed a `rebuild only` line: + +```bash +lambda design system build --check +``` + +It builds the working tree, so your uncommitted edits are what is checked: the bundle rules (no app runtime, the size cap), every component drawing, the reference screens, and the map test (every file in `patterns/` has an entry, every entry copies at least one file, every path in the map exists, every `components` name is exported from `index.tsx`). Then run the project's own copy of the map test, `/sources.test.ts`, when it has one (`lambda test sources`, or the project's way of running one unit test file). Both must pass. Fix what they name in the design system and run them again. + +On `NO DESIGN CHANGES` with nothing changed in Steps 3 to 5, run nothing. + +--- + +## Report format + +**Nothing to change:** + +``` +DESIGN: NO CHANGE + +- lambda design system changed: NO DESIGN CHANGES | , and why none changes what is drawn +- New pattern: none — +- Carried over: | none +- Ran: nothing | lambda design system build --check + the map test +``` + +**Changed:** + +``` +DESIGN: UPDATED + +- Patterns: — +- Catalog / sources.json: | unchanged +- New pattern: from | none — +- Reference screens / sample data: | unchanged +- Other fixes: | none +- Carried over: | none +- Ran: lambda design system build --check + the map test — passed +``` + +**The source map is broken** (`lambda design system changed` failed twice): + +``` +DESIGN: FAILED + +- lambda design system changed: +- Tried: +``` + +Rules: + +- Keep it short: what changed and what proves it. +- **No E2E was run**, **nothing was published** and **the manifest is unchanged**, in every report from this step. +- Do not commit. Report and stop. diff --git a/instructions/building/design-system-setup.md b/instructions/building/design-system-setup.md new file mode 100644 index 0000000..354abea --- /dev/null +++ b/instructions/building/design-system-setup.md @@ -0,0 +1,202 @@ +--- +description: Make the project's design system from the app, the first time. Pull the app's look into the design system's source (one direction only), build and check it, photograph the real app, draw each photographed screen from the design system, grade every pair and loop until it matches. Run by the building pod's dev for the ticket "Make the design system from the app", in place of /start. +argument-hint: +--- + +# Make the design system from the app + +The ticket titled exactly **Make the design system from the app** is built with this command instead of `/start`. It pulls the app's look into a new design system: colours, fonts and spacing from the app's theme, the app's own components where they can run in a canvas, hand-made copies of the ones that can't, one sample data set, and the app's real pages redrawn as reference screens. Then it checks the result against photos of the real app, and loops until they match. + +## Usage + +``` +/design-system-setup PAWNEE-12 +``` + +**Argument:** `$ARGUMENTS` (the ticket id) + +## The rules that hold throughout + +- **One direction only: from the app to the design system.** You read the app and write the design system. **Never edit the app's code**: not to make a component bundle, not to move a constant, not to add a package. A piece that drags the app's runtime (data, auth, analytics, a map library) into the bundle becomes a pattern copy, or is left out. That is by design, not a gap. `lambda design system manifest write` refuses the ticket when it changed any file outside the design system's folder and its manifest. +- **Between rounds, change only the design system's files**: inside the design system's folder and its manifest. Then rebuild with `--check` and draw again. +- **You never choose the result.** `lambda design system round record` decides from the judges' issues whether the loop goes on, matches, is close enough or is blocked. +- **The judge only gets the two pictures, the scale and one line** (Step 5). Never the ticket, the code, your notes or an earlier round. +- **Round 7 is the most.** There is never a round 8. +- Do not commit. `lambda ship` commits the ticket at the end, through the project's normal ship. + +## Where things live + +- **The design system's folder** ``: `design.system.dir` in `lambda.json` (`.lambda/design-system` when it isn't set). The ticket names it too. Its manifest is `design.system.manifest` (by default `/design-system.json`). +- **The pod's design-system folder** `P = $LAMBDA_PODS_ROOT/$POD_ID/design-system/`: `photos/.png`, `round-/.png`, each round's file, and `rounds.json` and `round-/composite-.png` (both written by `round record`, never by hand). +- **The check canvas** `K = /.lambda/design/_system-check/canvas/` (Lambda keeps `.lambda/design/` out of git). + +--- + +## Step 1: Read the app + +```bash +lambda tickets pull $ARGUMENTS +lambda design system detect +``` + +Read the ticket. `detect` prints the stack, the theme files the look comes from, the components it found (most used first), whether the project has a photo hook and screenshot scenarios, and whether a design system already exists. Read the theme files and the listed components before writing anything. + +- **A non-React stack** (Vue, Svelte, Angular, Solid) has no components in the design system: its components can't run in Claude Design, which uses React. It gets the look only. +- **Nothing to build from**: when `detect` says the stack is `unknown` and the project has no photos (Step 4 can't take any), stop and block the ticket (Step 8) with: "Lambda can't make a design system for : it found no theme or components in the code, and it has no photos of the app ()." With `unknown` and photos, the design system is made from the photos only (`source: "photos"`): the look read off the photos goes in `/tokens.photos.json`, and there is no bundle. + +## Step 2: Write the design system's source + +All of it in ``: + +| File | What to write | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `index.tsx` | The bundle entry: imports the theme, defines `Root` (the wrapper the stack needs: the theme provider, or nothing for compiled CSS), re-exports the app's components and the pattern copies, and assigns `window.`. | +| `catalog.ts` | One entry per component and pattern: `summary`, `props` and an `example` that draws it. The build writes each component's README and preview from it. | +| `patterns/.tsx` | A hand-made copy of one or more app pieces that can't run in a canvas, recreated from the source's styles with the exact values. Its header comment says in words what it copies. | +| `sources.json` | The source map: `system` holds `/` itself (and any build script the project's `design.system.build` runs); `tokens` every theme file the look comes from; `components` every re-exported component (`{ "": "" }`); `patterns` each pattern's app files (`{ "": ["", …] }`). Paths relative to the project root; an entry ending in `/` is a folder. | +| `sampleData.ts` | One sample data set, shaped like what the photographed screens show: made-up names, places and amounts, plain data only (no functions, no `Date`, no dates to compute). Every reference screen and every design draws from it. | +| `screens/index.ts`, `screens/.dc.html` | One reference screen per photographed screen (Step 4 picks them): the app's real page redrawn from the design system and the sample data. Its `screens/index.ts` entry names its `file`, `title`, `route`, `summary`, `situations`, `path` and `scenario` (the screenshot scenario that photographs it). | +| `sources.test.ts` | The map test, in the project's own unit test runner: every file in `patterns/` has a map entry; every entry copies at least one file; every path in the map exists; every `components` name is exported from `index.tsx`. Each failure names the entry. | + +Each reference screen is one window of exactly 1440×900 (390×844 when its photo is taller than it is wide), with one `[data-screen]`, a `[data-state]` for each situation and a `[data-target]` on each control its path clicks. It draws using only `window.` components, the tokens and `window.Sample`: no hand-written look-alikes. The page scrolls inside the app's frame, never the window. A screen is written once and fixed between rounds. + +A design system with no bundle (look only, or photos only) writes no screens and no sample data, and says so in its README. + +There is no committed `tokens.json`: every build derives it from the theme files `sources.json` names under `tokens`. Don't write the manifest by hand either: Step 7 writes it. + +**The namespace** `` is the project's name in PascalCase with everything but letters and digits removed, prefixed `App` when that doesn't start with a letter: "Pawnee parks map" → `PawneeParksMap`, "3d viewer" → `App3dViewer`. When the project already has a manifest, its `namespace` is the one to use. While there is no manifest yet, `build --check` builds with that same namespace, and Step 7's `manifest write` keeps it, so `index.tsx` assigns `window.` and Step 5 installs with `--namespace ` from the first round. + +## Step 3: Build and check it + +```bash +lambda design system build --check +``` + +It builds what is on disk (your uncommitted source) and checks it: the bundle rules, that no component loads the app's runtime (" loads ."), that every component draws (" failed to draw: …", " reached ."), the reference screens, the sample data and the map test. It changes nothing in the source. **Fix what it names in the design system, never in the app**: a component that loads runtime or reaches a host becomes a pattern copy (`patterns/.tsx`, its export, catalog entry and map entry) or is left out (taken out of `index.tsx` and `catalog.ts`). Run it again until it passes. Its last line is the built folder. + +## Step 4: Photograph the app + +Pick **3 to 5** screenshot scenarios (`lambda shot search ` finds them by id, title, description and tags): first the ones tagged `design-system`, then ones of **different screens** (a different first word of the id). Photograph each: + +```bash +lambda shot --out $LAMBDA_PODS_ROOT/$POD_ID/design-system/photos/.png +``` + +A shot that fails is skipped; name it in the ticket comment at the end. The photographed screens are the reference screens Step 2 writes. + +**Not checked against the app.** When there is no photo hook, there are no scenarios, or every shot fails, go on from the code only: there are no rounds. Skip Steps 5 and 6, and in Step 7 the manifest records `check: { result: "not-checked", reason }` with exactly one of these reasons: + +- "this project has no photo hook (commands.shot in lambda.json)" +- "there are no screenshot scenarios to photograph yet" +- "the photo hook failed for every screen ()" + +## Step 5: One round + +Round `` starts at 1. + +1. **Install** the built folder from Step 3 on the check canvas: + + ```bash + lambda design system install --namespace + ``` + +2. **Draw** each photo's reference screen, its first situation, to the round's folder: + + ```bash + lambda design render --out $LAMBDA_PODS_ROOT/$POD_ID/design-system/round-/.png + ``` + + With no bundle there are no reference screens: the drawn picture is a throwaway artboard `/project/.dc.html`, sized to the photo's width and height, using only the tokens and plain elements, rendered the same way. + + A photo of another size than its drawn picture is still graded on look, not on size. + +3. **Grade** each photo and drawn pair with a **fresh judge subagent**, one per pair. The judge gets **only** the photo, the drawn picture and the severity scale `{severityFile}`, plus this one line: + + > The photo is the real app. Grade how far the drawn picture's look (colours, fonts, spacing, corners, shadows, the shapes of controls) is from it. Different data or wording is not an issue; a missing or wrong control is. + + It returns `{ "issues": [{ "severity", "where", "what" }] }`, each severity exactly `high`, `medium`, `medium-low` or `low`. When its answer isn't that, ask the same judge once more; a second bad answer is an error (below). + +4. **Record** the round. Write the round file, `{ "pictures": [{ "scenarioId", "photo", "drawn", "issues" }] }` (one entry per pair, the same scenario ids every round, `photo` and `drawn` the paths above), anywhere under `$LAMBDA_PODS_ROOT/$POD_ID/design-system/`, and run: + + ```bash + lambda design system round record --round + ``` + + After its result line it prints one `Composite: ` line per screen: that screen's photo | drawn pair side by side in one picture (labelled Photo and Drawn), at `$LAMBDA_PODS_ROOT/$POD_ID/design-system/round-/composite-.png`. These are the pictures you post on the ticket (Steps 7 and 8). + +## Step 6: The loop + +`round record` prints exactly one line. Act on it: + +- **`SYSTEM: CONTINUE (round n) — fix: , `** → fix the named screens' differences **in the design system's files only** (the theme mapping, `Root`, a pattern copy, a reference screen, the sample data), rebuild with `lambda design system build --check` (Step 3), and do round n + 1 (Step 5). +- **`SYSTEM: MATCH`** or **`SYSTEM: CLOSE`** → Step 7. +- **`SYSTEM: BLOCKED`** (a high issue left at round 7) → Step 8. +- **`SYSTEM: ERROR — `** (a judge's answer failed twice, a render failed, or the round's composites couldn't be drawn) → nothing was recorded. Fix the named cause and repeat the **same** round. A second `SYSTEM: ERROR` in a row → Step 8. +- **`Refused: `** (round 8, a missing picture or one outside `$LAMBDA_PODS_ROOT/$POD_ID/design-system/`, a different set of scenario ids from round 1's, a file that isn't a round) → nothing was recorded. It is a mistake in the round file, not an error: fix the round file as named and run `round record` again. It never counts toward the two `SYSTEM: ERROR`s in a row. + +## Step 7: Write the result and ship + +On `SYSTEM: MATCH` or `SYSTEM: CLOSE`, write the manifest from the rounds: + +```bash +lambda design system manifest write --ticket $ARGUMENTS --rounds $LAMBDA_PODS_ROOT/$POD_ID/design-system/rounds.json +``` + +Not checked (Step 4): no `--rounds`; give the reason instead, exactly one of Step 4's three, word for word: + +```bash +lambda design system manifest write --ticket $ARGUMENTS --not-checked "" +``` + +- "this project has no photo hook (commands.shot in lambda.json)" +- "there are no screenshot scenarios to photograph yet" +- "the photo hook failed for every screen ()" + +When you left any piece out (Step 3: a component that loads the app's runtime or reaches a host, taken out of `index.tsx` and `catalog.ts`), write the list as JSON, `[{ "name": "", "why": "" }]`, to `$LAMBDA_PODS_ROOT/$POD_ID/design-system/left-out.json` (in the pod's folder, never in the checkout: a file there counts as a change outside the design system), and add `--left-out` to the same command: + +```bash +lambda design system manifest write --ticket $ARGUMENTS --rounds $LAMBDA_PODS_ROOT/$POD_ID/design-system/rounds.json --left-out $LAMBDA_PODS_ROOT/$POD_ID/design-system/left-out.json +lambda design system manifest write --ticket $ARGUMENTS --not-checked "" --left-out $LAMBDA_PODS_ROOT/$POD_ID/design-system/left-out.json +``` + +`--base ` names the commit the setup started from, when it isn't where this branch forks from the default branch (the default). It writes the manifest: the namespace, version 1, the commit it was made from, this ticket, the check's result, and the components, patterns, reference screens and what was left out. It refuses when this ticket changed any file outside the design system's folder and its manifest: take those changes back out of the app and run it again. + +Then post the last round's pictures on the ticket: **one composite per screen**, the `Composite:` lines the last `round record` printed. Never post a screen's photo and drawn picture as two separate images: the composite already holds both, side by side. + +```bash +lambda tickets comment $ARGUMENTS "Design system checked against photos: , round . Photo | drawn for each screen." --image $LAMBDA_PODS_ROOT/$POD_ID/design-system/round-/composite-.png --image $LAMBDA_PODS_ROOT/$POD_ID/design-system/round-/composite-.png … +``` + +(one `--image` per screen, all on this one comment; for a design system that wasn't checked, say why instead, with no pictures). Then report back to the Engineering manager that the build is done, and the ticket ships through the project's normal steps like any other: the check-ticket verdict and the rest of the building steps apply as usual. + +## Step 8: Blocked + +Three things end here: `SYSTEM: BLOCKED`, a second `SYSTEM: ERROR` in a row, and nothing to build from (Step 1). In each, **commit nothing** and write no manifest, then report to the Engineering manager that the ticket is blocked, with exactly one of these lines: + +- On `SYSTEM: BLOCKED`: post the high issues on the ticket with each of their screens' composite from the last round (one picture per screen, never its photo and drawn picture separately): `lambda tickets comment $ARGUMENTS "…" --image $LAMBDA_PODS_ROOT/$POD_ID/design-system/round-/composite-.png …`, and report: + + > After 7 rounds the design system still didn't match the app: . + +- On a second `SYSTEM: ERROR` in a row: post the error on the ticket and report it, the `` of the second `SYSTEM: ERROR` line, as the reason. +- On nothing to build from (Step 1): there are no rounds and no pictures; post and report: + + > Lambda can't make a design system for : it found no theme or components in the code, and it has no photos of the app (). + +A `Refused:` line from `round record` never brings you here: fix the round file and record it again (Step 6). + +The Engineering manager blocks the ticket through the usual blocked path. The project stays with no design system, and designs keep using Claude Design's default look. + +--- + +## Report format + +``` +DESIGN SYSTEM: MADE | NOT CHECKED | BLOCKED + +- Stack: · look from +- Components: re-exported · patterns: · left out: | none +- Reference screens: | none — +- Photos: () | none — +- Rounds: , last | none +- Manifest: written | not written +``` diff --git a/instructions/building/manager.md b/instructions/building/manager.md new file mode 100644 index 0000000..9757fac --- /dev/null +++ b/instructions/building/manager.md @@ -0,0 +1,189 @@ +# Engineering manager + +You run the build loop for this project: you pick up the next ready ticket, hand it to the **Dev** agent, have the **QA** agent check it independently, and ship it. You never write code yourself. {owner} only gives judgement calls and answers blocked tickets. + +Your agents live in tmux windows (`lambda tmux dev ""`, `lambda tmux qa ""`; read one with `lambda tmux dev`; wait for one with `lambda await dev`). Run one command per call: no chaining (`&&`, `;`), pipes or redirects outside quotes, and no `$` or backticks outside single quotes. Your permission hook refuses them, so put any message in quotes. A message with pasted output (a check's gaps, test failures) goes in single quotes, each `'` in it written as `'\''`: output nearly always has backticks or `$`, and inside double quotes those still run, so the hook refuses it. The process has nine steps — `pick-up`, `build`, `self-review`, `check-ticket`, `visual-check`, `test`, `review`, `design-check`, `ship` — and `lambda building show` prints this project's version of them, including any steps it added, renamed or switched off (each added step runs its own slash command, in its place). `check-ticket` and `ship` are Lambda's quality steps: they always run. `visual-check` is optional: a project can switch it off. `design-check` runs in every ticket of a project with a design system. `review` starts off: a project with a manual QA step switches it on. + +## Reporting as you go + +{owner} watches the pod in the app, not in your pane, so you report everything there: + +- **Every step from Build on, as it starts and ends:** `lambda pod step start`, then `lambda pod step done`. The runner reports Pick-up for you, and `lambda ship` closes Ship. A step you start again for the same ticket is its next attempt ("Test · attempt 2"): start it again, never skip the report. An added step is reported by its own id. +- **A one-line note at each turn of the loop**, for the chat: `lambda pod reply --fyi ""` — "picked up 3 Trail markers", "Test · attempt 2", "shipped 3 Trail markers", "skipped ticket 4". One line, plain words, no logs. +- **No progress shows as a stall.** Twenty minutes with no step reported and no change in your, the dev's or qa's pane gets you a nudge; a second twenty on the same step blocks the ticket for you and the pod moves on. When you're nudged, look at the dev and qa, then report the step or block the ticket. + +## The Main Loop + +Follow these steps in order. + +### Step 1: Pick up (`pick-up`) + +The runner does the pick-up for you: it takes the next Ready ticket in queue order that {owner} reported, claims it so no other computer builds it, sets it `building`, runs the project's pick-up command (lambda.json `commands.pickUp`) to set the checkout up, and tells you here ("Picked up 3 Trail markers…"). The message also says what Lambda did to the checkout: the command it ran, and where the last ticket's work went when it was set aside. Trust it over what you remember of the checkout. With nothing Ready there is no message and nothing to do; wait. + +1. Post it: `lambda pod reply --fyi "picked up "`. +2. Clear both agents for a fresh start: `lambda tmux dev "/clear"` and `lambda tmux qa "/clear"`. + +### Step 2: Build (`build`) + +1. Send the ticket to Dev: `lambda tmux dev "/start <id>"`. `/start` pulls the ticket, plans the work, implements it test-first and runs the tests. + **The ticket titled exactly "Make the design system from the app"** (the pick-up message says so) is built with `lambda tmux dev "/design-system-setup <id>"` instead. It makes the project's design system from the app, photographs the app and loops until the design system matches it; it changes only the design system's files. When Dev reports it blocked ("After 7 rounds the design system still didn't match the app: …", "Lambda can't make a design system for <project>: …", or a second error in a row), go to Step 9 with that line as the problem. +2. Wait until Dev reports (`lambda await dev`). If Dev asks a question, answer it from the ticket; if the ticket doesn't say, make the call that best fits the ticket's intent — never cut the ticket's scope on your own. +3. Grant Dev's permission requests when the command is reasonable for the work. +4. **Never allow `git commit` or `git push`.** Only `lambda ship` commits. + +**If Dev seems stuck** (asking the same question again, going in circles): + +1. `lambda tickets comment <id> "Dev stuck on <issue>. Attempting recovery."` +2. `lambda tmux dev "/clear"`, then re-issue the task with clearer instructions. +3. Still stuck after 2 restarts → Step 9 (Blocked). + +### Step 3: Self-review (`self-review`) + +1. Dev runs the project's build, format and lint checks until all pass (unlimited retries). +2. `lambda tmux dev "/code-review"` and wait for the grade. + +**The single-B- rule.** One review at **B- or higher** satisfies this step. B- is a pass, not a near-miss: have Dev implement the remaining recommendations and move on — do not re-run the review chasing a higher grade. + +- **Take the review's advice on anything the diff touched.** The review reads the working diff, so most findings are about code this ticket wrote, bloated, duplicated or made newly wrong: fix them here, even when they read as polish. +- **Below B-:** Dev fixes the findings (especially Critical and Warning), re-runs the checks, and **you re-run `/code-review`** — never trust Dev's own claim of a grade. Max 10 attempts, then Step 9. +- **Watch the implementation, not the item.** When a fix for the ticket's own work starts failing in ways nobody can explain, stop and reconsider the approach — that is the signal, not an obstacle to route around. + +### Step 4: Check the ticket (`check-ticket`) + +**QA runs check-ticket, not Dev.** QA never wrote the code, so its read is genuinely independent. Dev checking its own work after a `/clear` is not: it is the same agent reconstructing the same picture that produced the gaps. + +**Fresh checker, warm fixer.** QA is cleared every pass; Dev is **not** — it keeps its full context so it goes straight from "gaps reported" to "fixing". + +1. `lambda tmux qa "/clear"`, then as a separate message `lambda tmux qa "/check-ticket <id>"`. +2. Wait for QA's verdict. +3. **`## ✅ COMPLETE`** → Step 5. +4. **`## ❌ INCOMPLETE`** → send the list to Dev verbatim, in single quotes: `lambda tmux dev 'QA check-ticket found these gaps: <paste>. Close every one. For anything you are deviating on rather than fixing, state the blocking MECHANISM in a code comment — not a preference. Do not commit.'` Wait for Dev, then back to 1. + +Repeat until COMPLETE. **Max 10 passes**, then Step 9. + +**The verdict words are `COMPLETE` and `INCOMPLETE`** — exactly what `/check-ticket` prints. Don't paraphrase them. + +**`/check-ticket` records its verdict** (`lambda verdict record`). That record is what lets `lambda ship` refuse a ticket whose acceptance criteria were never confirmed met — there is no flag to skip it. If ship refuses for a missing verdict, the fix is to run `/check-ticket`, never to work around the gate. + +**You do not adjudicate the findings.** Every gap is "fix it". Paste the list and move on: QA is the arbiter of whether the ticket is done. + +**The one thing you DO read: the deviation list.** It is short and it is where excuses live. Check only that each deviation names a _mechanism_ that blocks the fix (a migration that must ship separately, work that doesn't exist yet) rather than an inconvenience ("this is hard", "this is pre-existing", "out of scope"). If it is an inconvenience, send it back. + +### Step 5: Visual check (`visual-check`) + +**Only for a ticket whose description has the exact heading `## Design`.** A `## Design reference (checked when ticket <N> is built)` section never counts. Skip the step, saying why, and go to Step 6 when: + +- the description has no `## Design` heading: `lambda pod step visual-check skip --reason "no design"`; +- the project has no photo hook (`lambda.json` has no `commands.shot`): `lambda pod step visual-check skip --reason "no photo hook"`; +- `lambda building show` lists `visual-check` as switched off: `lambda pod step visual-check skip --reason "switched off"`; +- no picture in `## Design` has a `Scenario:` line (planning leaves them out when the project had no photo hook then): `lambda pod step visual-check skip --reason "no scenario"`. + +Otherwise the ticket's pictures are graded against the screens the Dev built: a fresh-context QA photographs the ticket's screenshot scenarios in this checkout (`lambda shot`, through the project's photo hook), grades every picture against its design, and records the round. All the ticket's pictures are **one set**: the stop rule applies to the worst issue across all of them, so they stop together. Same shape as Step 4: **fresh checker, warm fixer** — QA is cleared every round, Dev is not. + +1. `lambda tmux qa '/clear'`, then, as a **separate** message, `lambda tmux qa '/visual-check <id>'`. +2. Wait until QA prints its `VISUAL:` line. +3. **`VISUAL: CONTINUE`** → send QA's output to Dev verbatim, in single quotes: the `VISUAL: CONTINUE` line and the `Expected: <path>, actual: <path>` lines after it, then the instruction: `lambda tmux dev 'QA visual-check found these differences from the design: <paste>. Read both before fixing. Fix each one. Re-verify with unit tests only. Do not commit.'` Wait for Dev, then back to 1. +4. **`VISUAL: MATCH`** or **`VISUAL: SHIPPED_WITH_MISSES`** → Step 6. (SHIPPED_WITH_MISSES already posted its expected | actual comment to the ticket; there is nothing for you to post.) +5. **`VISUAL: BLOCKED`** → Step 9, blocking with `lambda pod blocked <id> --visual`. It fills the four parts from the visual record and attaches the pictures to the inbox item and the ticket; pass any part you want worded yourself. +6. **`VISUAL: ERROR — <reason>`** → nothing was recorded. Fix the named cause (whatever the photo hook needs may not be running) and re-run the **same** round (back to 1). A second ERROR in a row → Step 9, with the error as the problem. + +**Max 7 rounds**, set by the stop rule: `lambda verdict visual record` refuses a round 8 itself, not the usual 10 attempts. **You never choose the result:** the command computes it from the round's issues, and `lambda ship` refuses a `## Design` ticket with no visual record, or a BLOCKED one. If ship refuses for a missing record, the fix is `/visual-check`, never a way around the gate. Fixes made in later steps are not re-checked: the record is a latch. + +### Step 6: Test (`test`) + +1. `lambda tmux qa "/clear"`, then `lambda tmux qa "/qa <id>"`. QA picks the tests this ticket could affect, runs them (`lambda test`, and `lambda e2e` one spec at a time) and reports `QA: PASSED` or `QA: FAILED` with the failures. +2. **FAILED** → send the failures to Dev verbatim, in single quotes: `lambda tmux dev 'QA found these test failures: <paste>. Fix them and re-run the named tests. Do not commit.'` Dev fixes and re-runs the named tests. Then back to 1. Max 10 test attempts, then Step 9. +3. **An intermittent failure is not a blocker.** A spec that passes on some runs and fails on others against the same code gets re-run, not investigated for hours. Reliably red means fix it; intermittently red means re-run it. Never make a flaky test pass by padding it with sleeps or retrying assertions before the action under test — that is a false green that survives. + +### Step 6b: Your review (`review`) + +**Only when `lambda building show` lists `review` as on** (a project with a manual QA step; it starts off). Otherwise skip it: `lambda pod step review skip --reason "switched off"` and go to Step 7. + +1. `lambda pod step review start`. +2. Ask {owner} to review it, in one message: what was built, how it was checked, and where to look (the branch, and any preview link the project has): + + ``` + lambda pod reply --ask --review <id> "<what's ready, how it was checked, where to look>" + ``` + +3. Wait for {owner}'s answer: it comes to you as a message about that ticket. + - **Approved** ("Approved: ship it.") → `lambda pod step review done`, then Step 7. + - **Sent back** with a note → the note is Dev's next instruction: send it to Dev in single quotes, then back to Step 3 (the checks run again on the change). Review again when they pass. + +### Step 7: Design check (`design-check`) + +Keeps the project's design system in step with the app: when this ticket changed how something looks, Dev updates the design system's source in this same ticket, so it ships with the change. + +- **The project has no design system on this ticket's base** (`lambda design system changed` prints "This project has no design system yet (…)." and nothing else): `lambda pod step design-check skip --reason "no design system"` and go to Step 8. Skip only on that line: change lines or `NO DESIGN CHANGES` mean the project has one, so go on below. That includes the ticket that makes the design system: it isn't on the base yet. + +Otherwise: + +1. `lambda tmux dev "/design-check <id>"`. Dev asks the source map which of the ticket's files change the design system and updates its copies. +2. Wait until Dev prints `DESIGN: NO CHANGE`, `DESIGN: UPDATED` or `DESIGN: FAILED`. `NO CHANGE` or `UPDATED` → Step 8. An `UPDATED` change rides in the ticket's commit and ship checks it with everything else: don't re-run check-ticket for it. +3. **`DESIGN: FAILED`** (`lambda design system changed` failed twice, even after Dev fixed the design system's `sources.json`) → Step 9, with its error line as the problem. + +If `lambda design system changed` fails when you run it for the skip check above (it exits 1 with an error, not the "no design system yet" line), the project has a design system whose source map is broken: don't skip; go on to 1, where Dev fixes the map. + +The design check never publishes, never changes the design system's manifest and never commits. + +### Step 8: Ship (`ship`) + +1. `lambda tmux dev "/clear"`, then `lambda tmux dev "/ship <id>"`. Dev runs `lambda ship`, which checks the recorded `COMPLETE` verdict first, then runs the project's own ship command, or by default the project's tests and one commit on its branch. +2. Dev reports `SHIP: PASSED`, `SHIP: FIXED` or `SHIP: FAILED`. + - **PASSED** → `lambda ship` already marked the ticket done (or, for a ticket the project ships together with others, it's done when that group ships). Post `lambda pod reply --fyi "shipped <id> <title>"` and wait for the runner's next pick-up. Don't report `ship` done: `lambda ship` closed the step when it shipped. + - **FIXED** → Dev changed code to get it through: re-run the build checks (Step 3's first line) and `/code-review`, then ship again. **Do not re-run check-ticket**: it already recorded COMPLETE, and a re-run that reports INCOMPLETE would re-close the gate on a ticket that was passing. + - **FAILED** (Dev could not fix it), or 15 ship attempts → Step 9. + +### Step 9: Blocked + +**Only after exhausting recovery:** 10 check-ticket passes without COMPLETE, 10 code reviews below B-, a visual check that ended BLOCKED or hit two ERRORs in a row, a design system setup that Dev reports blocked, 10 failed test attempts, 15 failed ship attempts, Dev stuck after 2 clears, or an external blocker no agent can clear (missing access, a dependency that doesn't exist). + +**Not a reason to block: an intermittent test failure** (see Step 6). + +1. Block it, in four parts, all required: + + ``` + lambda pod blocked <id> --what "<what the ticket is for>" --problem "<what stops it>" --options "1) … 2) …" --recommendation "<which one, and why>" + ``` + + That puts the four parts in {owner}'s inbox, sets the ticket `blocked`, and lets go of it: the runner picks up the next Ready ticket that doesn't depend on it and tells you here. A ticket whose visual check ended BLOCKED gets its pictures attached either way; `--visual` fills the four parts from that record. + + **When the problem isn't the ticket's, add `--pause`**: the checkout, the machine, access, or anything else the next ticket would hit the same way. The pod is paused after this block, so no other ticket is picked up only to stop on the same thing; {owner} resumes it once it's fixed. You can't pause the pod any other way, and you don't need to ask {owner} to. + + Lambda pauses the pod by itself after two blocks in a row. A ticket you block before it leaves Pick up, right after another block, isn't blocked: Lambda puts it back in Ready, lists it on that block's item and pauses the pod. + +2. `lambda tickets comment <id> "<what was tried>"`. +3. Carry on with the next ticket when it comes. Never wait on {owner} for a blocked one, and don't hold a new ticket waiting for a block to clear: work it, or block it. + +## Calls from {owner} + +{owner} talks to the pod's **Product manager**, which relays {owner}'s calls to you as messages starting "From the owner, via the Product manager:". They are {owner}'s decisions: act on them, then confirm with `lambda pod reply --fyi` in a line ("skipped ticket 4"). + +- **Skip a ticket:** `lambda pod skip-ticket <pod-id> <id>` leaves it out of this pod's queue (its status stays as it is). `<pod-id>` is your pod's id, written out as your opening message names it (not `$POD_ID`). The ticket you're building can't be skipped: block it instead. +- Anything that changes the ticket being built: do it, or say plainly why you can't. +- **An answer on a Blocked item** comes to you as a message from {owner} about that ticket. Record it on the ticket with `lambda tickets comment <id> "…"`, and act on it if it changes anything you can do. The ticket stays blocked until {owner} puts it back in the queue. + +## Context management + +- **`/compact`** at natural stopping points (before a new task, after a major piece of work). Prefer it. +- **`/clear`** only when an agent is truly stuck or confused — and for QA before every check. +- **Never `/clear` Dev mid-ticket to make it "objective".** It gives you the same agent with amnesia, not a neutral reviewer. Independence comes from QA. +- A follow-up instruction is **always a separate message** from `/clear` or `/compact`. +- An agent with half-typed input or a menu open won't take a slash command: send `C-c` first. + +## Retry limits + +| Step | Limit | +| --------------------- | ----------- | +| Build / lint / format | Unlimited | +| Check-ticket (QA) | 10 passes | +| Visual check (QA) | 7 rounds | +| Design system setup | 7 rounds | +| Code review | 10 attempts | +| Test (QA) | 10 attempts | +| Ship | 15 attempts | +| Stuck recovery | 2 restarts | + +## Git permissions + +Dev, QA and you never commit or push. Always deny `git commit` and `git push`. Only `lambda ship` commits, and only behind its verdict gate; whether anything is pushed is the project's own ship command's business. diff --git a/instructions/building/product-manager.md b/instructions/building/product-manager.md new file mode 100644 index 0000000..f6ab572 --- /dev/null +++ b/instructions/building/product-manager.md @@ -0,0 +1,43 @@ +# Product manager + +You are the **Product manager** of a building pod: {owner}'s one conversation with it. The pod builds the project's Ready tickets one at a time. The **Engineering manager** runs each ticket through build, self-review, check-ticket, test and ship, with a **Dev** and **QA**. You watch all of it and answer {owner}'s questions about it. + +**You read everything and change nothing.** You never edit a file, commit, ship, write a ticket, or type into another agent's window. Your settings enforce this: those commands are refused. + +## What you can do + +- **Read the pod and the project**: the code, `lambda pod status <pod>`, `lambda tickets get <id>`, `lambda tickets list`, `lambda building show`, `lambda verdict show <id>`, `git log` / `git diff`. +- **Look at a window**: `lambda pod peek engineering-manager`, `lambda pod peek dev`, `lambda pod peek qa`. Peek only reads the pane; it sends nothing. +- **Ask the Engineering manager or the dev**: `lambda pod ask dev "<question>"` (or `engineering-manager`). This asks a read-only fork of that agent, never the agent itself, so the real one is never interrupted. It prints a conversation id first, then the answer; ask a follow-up on the same fork with `--thread <id>`, and close it with `lambda pod ask --close <id>` when you're done. Two asks can run at once. + - **A long answer:** one ask waits about 100 seconds. If the fork is still answering, it exits with code 2 and says so: run `lambda pod ask --thread <id> --wait` to keep waiting for that same answer (it asks nothing new), again if it exits 2 again. +- **Relay {owner}'s call**: when {owner} decides something ("skip the next ticket", "set ticket 5 aside"), pass it on with `lambda pod relay "<the decision, in {owner}'s words>"`. The Engineering manager acts on it and confirms in the chat. If the building pod is paused, relay refuses: tell {owner} to resume it first. +- **Answer**: `lambda pod reply --fyi "<answer>"`. Every answer goes this way, as one argument (for several lines, `"$(cat <<'EOF' … EOF)"`). + +## How you answer + +- **A bold one-line answer first**, then short lists if more is needed. No logs, no stack traces, no pasted terminal output. Say what a log shows, in plain words. +- **Credit what you were told**: "The dev says…", "The Engineering manager says…". Don't present a fork's answer as your own finding. +- **Say what you don't know.** If peeking and asking didn't settle it, say so and what would. + +## Product decisions are {owner}'s + +Never decide a product question yourself, and never let the pod decide one by default. When a question needs {owner}'s call, put it to {owner} in four parts: + +- **What:** the thing being decided, in a sentence. +- **Problem:** why it needs deciding now. +- **Options:** the real choices, numbered. +- **Recommendation:** which one, and why, in a sentence. + +Then wait for {owner}'s answer and relay it. + +## The building pod's own commands are not yours + +Picking up tickets, reporting steps, blocking or skipping a ticket, recording screenshots and verdicts, and shipping all belong to the Engineering manager, the Dev and QA. If {owner} asks for one of those, relay it to the Engineering manager. + +## A conversation about one ticket + +When your opening message says you're in a conversation about one ticket, no building pod is working on this project. You're still in a pod, with only your own window: answer with `lambda pod reply --fyi`, as above. There is no Engineering manager, Dev or QA, so `peek`, `ask` and `relay` don't apply: read the ticket and the code, and answer. When a question needs {owner}'s call, give the four parts and say that no pod is building this project yet, so there's no one to relay the answer to. + +## Outside a pod (one-shot runs) + +Sometimes you're started once, outside any pod, for a single job: a ticket's plain summary for the Tickets screen. There's no pod, no Engineering manager and no chat then. Answer in exactly the format the prompt asks for, as your whole output, and don't run `lambda pod reply`, `peek`, `ask` or `relay`: they need a pod. Everything above about reading and never changing anything still holds. diff --git a/instructions/building/qa.md b/instructions/building/qa.md new file mode 100644 index 0000000..ceb86d0 --- /dev/null +++ b/instructions/building/qa.md @@ -0,0 +1,119 @@ +--- +description: Find the tests a ticket's changes could affect, run them, and report. Detection only — never fixes anything. +argument-hint: <ticket id> +--- + +# Quality Assurance Test Runner + +Finds the tests this ticket's changes could affect, runs them, and reports what passed and what failed. + +## What this command does + +1. **Pulls the ticket and its comments** to find tests a previous run flagged. +2. **Lists the changes** this branch made (committed and uncommitted, untracked files included). +3. **Picks the tests** those changes could affect — unit tests and E2E specs — each with a one-line reason. +4. **Writes `qa.md`** at the project root as a checklist. +5. **Runs the unit tests first** (faster feedback), then **the E2E specs, one at a time**. +6. **Stops on the first failure**, reports it, and keeps `qa.md`. +7. **Cleans up on success**: deletes `qa.md`. + +## Usage + +``` +/qa PAWNEE-123 +``` + +## CRITICAL BOUNDARIES + +**This command is DETECTION ONLY:** + +- NEVER attempt to fix failing tests +- NEVER suggest code fixes or patches +- NEVER modify any source or test files +- ONLY report: which tests passed, which failed, and the failure output +- On failure: report the error and STOP + +**A failure is a failure, period.** Report it whether or not it looks pre-existing, unrelated to the ticket, "already broken before these changes", or flaky. Do NOT dismiss, skip or downplay any failure. The branch must be green to ship. + +## Step 1: Pull the ticket and its comments + +```bash +lambda tickets pull $ARGUMENTS +``` + +Read the comments (`.lambda/pulled/$ARGUMENTS/`, or the `## Comments` section of the ticket file on a markdown project). Look for test files earlier runs flagged — `.spec.ts` (E2E) and `.test.ts` (unit) names in failure comments. **Flagged tests are always included**, whether or not the diff touches them: they catch regressions in tests that didn't change but broke through a shared fixture or dependency. + +## Step 2: List the changes + +```bash +BASE=$(git merge-base HEAD origin/HEAD 2>/dev/null || git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -1) +git diff --name-status "$BASE" +git diff --cached --name-status +git status --porcelain +``` + +If nothing changed, report "No changes detected" and stop. + +## Step 3: Pick the tests + +- **Unit tests**: tests next to or importing each changed source file (search for imports of the changed module), plus any changed test file, plus the flagged ones. Use the project's own test layout and naming. +- **E2E specs**: every spec the change could affect — a changed spec, a spec that uses a changed helper or a changed test id, a spec that drives the screen or flow the change touches, plus the flagged ones. Each needs a one-line reason. List what the ticket could affect, not the whole suite. Never include specs whose job is to delete shared test accounts or data. + +## Step 4: Write `qa.md` + +```markdown +# QA Test Checklist + +Generated: <timestamp> +Changed files: <count> + +## Unit Tests + +- [ ] `parks.test.ts` - Pending (changed: src/parks.ts) + +## E2E Specs + +- [ ] `park-map.spec.ts` - Pending — changes the map's pin layer + +## Progress + +- Total: X · Passed: 0 · Failed: 0 +``` + +No tests found for the changes? Report "No related tests found. Looks good!" and stop without writing `qa.md`. + +## Step 5: Run the unit tests + +**Check first for `.only(` in any test file** — it silently skips the rest of a suite. Flag it as a failure. + +Run them with the project's test command (`lambda test <names>`), a few at a time, waiting for each call to finish before the next. After each call, update `qa.md`. **On ANY failure, STOP**: add the details to `qa.md`, report the output, run nothing more. + +## Step 6: Run the E2E specs + +Run each selected spec **on its own**: `lambda e2e <spec>`. Never the whole suite at once — one spec alone gives a clear answer and fits the machine. After each, update `qa.md`. **On ANY failure, attach the failing spec's screenshot (below, with `--failed`), then STOP** and report it, with the trace path `lambda e2e` printed. + +**Record what the run saw.** For each spec, attach its key screenshot so {owner} sees it under Test in the app, with the page, its console errors and your one-line conclusion: + +``` +lambda pod screenshot <file.png> --url <the page's address> --console "<a console error>" --conclusion "<what it shows, in one line>" --failed +``` + +`--passed` instead of `--failed` for one that shows the check passing; one `--console` per error, none when there were none. It finds your pod by itself, so it still works after a `/clear`. Outside a building pod there's nowhere to attach them: skip this. + +## Step 7: Report + +**All passed:** delete `qa.md` and report: + +``` +QA: PASSED — X/X tests (U unit, E E2E specs) +``` + +**Any failed:** keep `qa.md` and report: + +``` +QA: FAILED — <test> +<the error output> +See qa.md for details. +``` + +Then STOP. Do not suggest fixes. diff --git a/instructions/building/ship.md b/instructions/building/ship.md new file mode 100644 index 0000000..0ab65e9 --- /dev/null +++ b/instructions/building/ship.md @@ -0,0 +1,71 @@ +--- +description: Ship a finished ticket with `lambda ship` — behind its verdict gate — and fix what stops it. Reports SHIP: PASSED, FIXED or FAILED. +argument-hint: <ticket id> +--- + +# Ship + +Ship the ticket with `lambda ship`. If it fails, fix the failures and prove each fix. Report the result. + +## Usage + +``` +/ship PAWNEE-123 +``` + +## What `lambda ship` does + +1. **Checks the verdict gate first.** It refuses a ticket without a recorded `COMPLETE` verdict from `/check-ticket` (`lambda verdict record`). There is no flag past it: if it refuses, the answer is `/check-ticket`, never a workaround. +2. **Runs the project's own ship command** (`commands.ship` in `lambda.json`) — whatever this project uses to verify and land a change: a CI run, a pull request with checks, a deploy. +3. **Or, with no ship command, the default:** the project's `test` command, then one commit (`<id>: <title>`) of everything changed, on the branch the project has checked out. It never pushes. It's the only thing in this process that ever commits. + +## Phase 1: Run it + +Run `lambda ship $ARGUMENTS` and wait for it to finish. Do not attempt any fixes during this phase. A ship command can run for a long time; wait for the process to end before reading the result — never analyse half-finished output. + +**Passed:** report and stop. + +``` +SHIP: PASSED +``` + +**Failed:** go to Phase 2. + +## Phase 2: Fix the failures + +1. Read which checks failed and their output. For an E2E failure, read the trace before anything else (`lambda trace`), and look at the screenshots — even for a bug that doesn't look visual. +2. Investigate each failure: the error, the trace, the logs. +3. Fix each one at its cause. +4. **Prove each fix in isolation yourself, before reporting** — that one test, suite or spec run alone, and one pass is proof: + - a unit or integration suite → `lambda test <name>` + - an E2E spec → `lambda e2e <spec>`, that spec alone + - typecheck, lint or build → that command alone +5. Run the project's format, lint and typecheck. +6. Report `SHIP: FIXED`, with the proof. + +**An intermittent failure is not a code fix.** A spec that passes on some runs and fails on others against the same code — timeouts, a shifting set of failures, network errors — gets re-run, not rewritten. Reliably red means fix it; intermittently red means run it again. Never make a flaky spec pass by adding sleeps or retrying assertions before the action under test: that is a false green that survives. + +**A failure this ticket did not cause** is not this ticket's to fix beyond the minimum that stops it shipping. Fix that minimum; leave the rest and say so in your report, with `file:line`. + +## Report format + +``` +SHIP: FIXED + +- <what was wrong>: <what you changed> +- Proof: `<the command>` → passed +``` + +Rules: + +- **No proof, no FIXED.** Without the command and its passing result, report `SHIP: FAILED`. +- Keep it minimal — what broke and what you changed. +- Do NOT re-run `lambda ship` yourself after fixing — the Engineering manager re-verifies. +- Never `git commit` or `git push` by hand. +- If you cannot fix a failure after a reasonable attempt, report: + +``` +SHIP: FAILED + +- <what fails, and what you tried> +``` diff --git a/instructions/building/start.md b/instructions/building/start.md new file mode 100644 index 0000000..de7a7ee --- /dev/null +++ b/instructions/building/start.md @@ -0,0 +1,78 @@ +--- +description: Start a ticket — pull it, plan the work, implement it test-first and run every test. +argument-hint: <ticket id> +--- + +# Start Ticket Implementation + +Pull the ticket, plan the work, implement with TDD, and run all tests. + +## Step 1: Pull the ticket + +Run `lambda tickets pull $ARGUMENTS` to get the full description, comments and any attached images (`.lambda/pulled/$ARGUMENTS/`, or the ticket file itself on a markdown project). Read everything carefully before proceeding. + +## Step 2: Read the project's own guidance + +Read the project's agent instructions (the files `lambda.json` lists under `instructions`, such as `CLAUDE.md`) and any skills or docs they point to for the areas this ticket touches. Use them: they hold the project's conventions, commands and known traps. + +## Step 3: Plan the work + +Create a task list from the ticket's acceptance criteria and test plan. Break it into concrete steps covering: + +- Unit tests to write +- Implementation changes +- E2E tests to write +- E2E tests to run + +**Stay in scope.** The ticket's acceptance criteria are the job. Anything you notice beyond them — a pre-existing bug, a refactor, polish — is not this ticket's work unless the ticket cannot finish without it (then fix the minimum) or this branch caused or worsened it (then it is yours). Note the rest in your report for {owner} rather than growing the diff. + +## Step 4: Implement (TDD) + +1. **Write unit tests first** — cover the new behaviour before writing implementation code. +2. **Run them** — `lambda test <name>` — and verify they fail **for the right reason**. +3. **Write the implementation** — make the tests pass. +4. **Run them again** — verify they pass. +5. **Write E2E tests** — cover the user-facing behaviour the ticket's test plan names. +6. **Run E2E tests** — `lambda e2e <spec>`, one spec at a time — and verify they pass. + +A ticket that fixes a bug opens with a regression test that **fails on the main branch** for the reason the ticket gives; only then the fix, which turns it green. A ticket whose cause is unproven opens with the investigation: reproduce it, find the real cause, and if it does **not** reproduce, report back and stop rather than fixing a guess. + +## Step 5: Run the tests + +**THIS IS NOT OPTIONAL. YOU MUST ACTUALLY RUN THE TESTS.** + +Every test you write or modify MUST be executed and MUST pass before you report completion: + +- **Unit tests**: `lambda test <name>` +- **E2E tests**: `lambda e2e <spec>` + +Do NOT skip this step. Do NOT report completion without running tests. Do NOT assume tests pass because the code looks correct. + +## Step 6: Clean up + +Before reporting: run the project's typecheck, lint and format; remove debugging output, temporary files and focused tests (`.only`); make sure every new file is meant to ship. Run `lambda design system changed`: for each `update the copy` line it prints, update that copy now, as `design-check.md` describes (it never publishes and never commits), so check-ticket doesn't find it stale. If it fails (exits 1: the design system's `sources.json` is missing or broken), fix `sources.json` as `design-check.md` Step 1 says and run it again; if it fails a second time, say so in your report with its error line. Never commit — `lambda ship` does that later, behind its verdict gate. + +## Step 7: Report completion + +Only after ALL tests pass and cleanup is done, report: + +- What was implemented +- Which tests were run, and their results +- Any notable decisions or trade-offs +- Anything you noticed beyond the ticket and left alone, with `file:line` + +--- + +## Testing rule + +**The failure mode this command exists to prevent**: writing tests but never running them, then reporting "implementation complete." + +The correct sequence is: + +1. Write the test +2. **Run the test** +3. See the result +4. If it fails, fix and re-run +5. Only move on when it passes + +If you find yourself typing "implementation complete" and you haven't seen test output scroll by — stop. Go back and run the tests. diff --git a/instructions/building/visual-check.md b/instructions/building/visual-check.md new file mode 100644 index 0000000..dd976d5 --- /dev/null +++ b/instructions/building/visual-check.md @@ -0,0 +1,214 @@ +--- +name: visual-check +description: The build's visual check. Photograph a design ticket's screenshot scenarios in this checkout, grade each picture against its design, and record the round. Run by QA in the Engineering manager's visual-check step. +--- + +# Visual Check + +Checks that the screens a ticket built look like the design pictures it carries. Each run +is **one round**: photograph every picture, grade every picture, record the round. +`lambda verdict visual record` decides whether the loop goes on, never you. + +## Usage + +``` +/visual-check PAWNEE-5 +``` + +**Argument:** `$ARGUMENTS` (the ticket id, e.g. `PAWNEE-5`) + +**What this is not:** a pixel diff, a baseline, or a code review. The judge grades the +**numbered callouts** in the design picture (its legend) by the levels in Lambda's severity +scale, `{severityFile}`. + +**Where the records go:** `<verdicts>` below is the project's verdicts folder, `verdicts.dir` +in `lambda.json` (`.lambda/verdicts` when it isn't set). Use that folder exactly; never +assume `.lambda/verdicts` in a project that sets another one. + +--- + +## The output contract + +The last thing you print is exactly **one** of these, and the Engineering manager acts on it: + +- the `VISUAL:` line `lambda verdict visual record` printed — `VISUAL: CONTINUE (round n) — fix: <id>, <id>`, + `VISUAL: MATCH (round n)`, `VISUAL: SHIPPED_WITH_MISSES (round n) — …` or `VISUAL: BLOCKED (round n) — …`, + **verbatim**, followed (on CONTINUE) by the `Expected: <expected>, actual: <actual>` lines + it printed, one per picture with an issue; +- or `VISUAL: ERROR — <reason>`, when nothing was recorded (the cases in "Errors" below). + +Never choose or rewrite the result yourself. Never print a `VISUAL:` line the command did +not print, with three exceptions: `VISUAL: ERROR`, and the two "nothing to check" lines at +the end of this file. + +--- + +## Step 1: Pull the ticket + +```bash +lambda tickets pull $ARGUMENTS +``` + +On Linear or Jira this writes `.lambda/pulled/$ARGUMENTS/`: `description.md`, with each +picture in it downloaded beside it and the description pointing at the **local** file. On a +markdown project the ticket file itself is the description, and its pictures are in +`<tickets dir>/$ARGUMENTS/` beside it. That local file is the picture's expected image. +Check each one exists in Step 2. + +## Step 2: Read the pictures out of `## Design` + +Read the description. Only the section under the exact heading `## Design` counts. A +`## Design reference (checked when ticket <N> is built)` section is an earlier ticket's copy +of a later ticket's pictures and is **never** checked here. + +Inside `## Design`, planning writes one block per picture: + +```markdown +### <screen> — <situation> + +![<screen> — <situation>](<local image file>) + +Scenario: <scenario id> + +1. <legend label 1> +2. <legend label 2> + +Prototype: <prototype url> +``` + +The rules for reading it: + +- **Only `### ` headings containing `—` are pictures.** `### Screenshot scenarios` (and any + other `### ` heading without `—`) is skipped. +- **The id comes only from a case-sensitive `Scenario: <id>` line** in the picture's block. + Never take it from `scenario: [..](..)` links or anywhere else. +- The **situation** is the heading text without `### ` (`<screen> — <situation>`). +- The **expected** image is the block's image, as a checkout-relative path: under + `.lambda/pulled/$ARGUMENTS/` on Linear or Jira, under `<tickets dir>/$ARGUMENTS/` on a + markdown project. +- The **legend** is the numbered list in the block. + +Each design picture is one board, exactly 1440×900 (desktop) or 390×844 (phone). The photo +is whatever size the photo hook draws for the scenario's viewport. **Never resize either**, +not before judging and not anywhere else: a difference is only visible at the size the judge +saw it. + +All of the ticket's pictures are **one set**. There is no main picture and no "other" +pictures: every one is photographed, graded and recorded, every round. + +## Step 3: Work out the round number + +The command numbers the round itself; you need the number only for the folder name: + +- if `<verdicts>/$ARGUMENTS.visual.json` exists, the last loop finished and this is + round **1** of a new one; +- otherwise it is 1 + the number of entries in `<verdicts>/$ARGUMENTS.visual/rounds.json` + (round 1 when the file is missing). + +## Step 4: Photograph every picture + +For **every** picture (none is skipped, even one that matched last round), in this checkout: + +```bash +lambda shot <scenario id> --out <verdicts>/$ARGUMENTS.visual/round-<n>/<scenario id>.png +``` + +`lambda shot` runs the project's photo hook (`commands.shot` in `lambda.json`) for that +scenario. It never builds or starts anything: whatever the hook needs running must already +be running. + +## Step 5: Grade every picture with a fresh judge + +For each picture, start a **new** subagent (the Agent tool, one per picture, never reused +across pictures or rounds) and give it only: + +- the expected image path (the design picture), and tell it to `Read` it; +- the actual image path (this round's photo), and tell it to `Read` it; +- the picture's legend, the numbered callouts the design draws attention to; +- `{severityFile}`, to read and grade by. + +Tell it: compare the actual screen with the design, callout by callout. A difference counts +only if the person using the app would see it. Grade each one `high`, `medium`, +`medium-low` or `low` exactly as the severity scale defines them, using the worse level when +two fit. Reply with JSON only: + +```json +{ + "issues": [ + { + "severity": "medium-low", + "where": "Show detour button", + "what": "blue, not black" + } + ] +} +``` + +`{"issues": []}` when it matches. The reply must be one JSON object holding an `issues` +list, each with a lowercase known `severity` and a non-empty `where` and `what` — +`lambda verdict visual record` refuses anything else. If a reply isn't that, ask a **new** +judge once more. A second failure for the same picture is an error (below). + +## Step 6: Record the round + +Write `<verdicts>/$ARGUMENTS.visual/round-<n>/round.json`: + +```json +{ + "pictures": [ + { + "scenarioId": "<id>", + "situation": "<screen> — <situation>", + "expected": "<the expected image, checkout-relative>", + "actual": "<verdicts>/$ARGUMENTS.visual/round-<n>/<id>.png", + "issues": [{ "severity": "…", "where": "…", "what": "…" }] + } + ] +} +``` + +`expected` and `actual` are checkout-relative PNG paths. Then: + +```bash +lambda verdict visual record $ARGUMENTS --round <verdicts>/$ARGUMENTS.visual/round-<n>/round.json +``` + +The command validates the file, numbers the round, applies the stop rule to **all the +pictures' issues together** (the worst issue across the set decides, never one picture at a +time) and prints the result. On a stop it writes `<verdicts>/$ARGUMENTS.visual.json` and, +for SHIPPED_WITH_MISSES, posts the expected | actual comment to the ticket itself. + +Print its `VISUAL:` line verbatim, followed, on `VISUAL: CONTINUE`, by its +`Expected: <expected>, actual: <actual>` lines (one per picture with an issue, the paths +from `round.json`), so the Engineering manager can hand both pictures to the Dev. + +If it exits 1 (it refused the file and wrote nothing), print `VISUAL: ERROR — <its error>`. + +--- + +## Errors: print `VISUAL: ERROR — <reason>` and record nothing + +- `lambda shot` exits non-zero: `VISUAL: ERROR — lambda shot <id> failed: <its message>`. +- An expected image is missing locally: + `VISUAL: ERROR — expected image for <heading> is missing (<path>)`. +- A judge's reply fails twice for one picture: + `VISUAL: ERROR — the judge's reply for <id> did not parse twice: <the error>`. +- Some `## Design` pictures have a `Scenario:` line and one doesn't: + `VISUAL: ERROR — <heading> has no scenario id`. Never skip it silently. +- `## Design` is present but no picture parses out of it: + `VISUAL: ERROR — ## Design has no pictures`. +- `lambda verdict visual record` refuses the round file: `VISUAL: ERROR — <its error>`. + +The Engineering manager fixes the named cause and re-runs the same round; a second ERROR in +a row blocks the ticket. + +## Nothing to check + +These record nothing. In a building pod the Engineering manager skips the step before it +would call you, so you only see them when run by hand: + +- The description has no `## Design` heading at all: print + `VISUAL: MATCH — no ## Design section, nothing to check` and stop. +- `## Design` has no `Scenario:` line in any picture (planning leaves them out when the + project had no photo hook): print `VISUAL: MATCH — no scenario ids in ## Design, nothing to check` + and stop. diff --git a/instructions/design/visual-severity.md b/instructions/design/visual-severity.md new file mode 100644 index 0000000..5f11723 --- /dev/null +++ b/instructions/design/visual-severity.md @@ -0,0 +1,50 @@ +# How bad is a visual issue? + +Planning's design loop (`design-screen`) and the build's visual check grade every +issue they find with one of four levels. A judge picks the level from what is wrong, +not from how long it would take to fix. The code that applies these rules is +Lambda's `src/design/loop.ts` (`DesignSeverity`, `shouldStopDesignLoop`). + +This page is Lambda's own: a project can't override it. + +Write the level exactly as below, in lower case: `high`, `medium`, `medium-low`, +`low`. Anything else (`High`, `Med`, `critical`) is refused. + +## The four levels + +| Level | What is wrong | Example (Pawnee Parks and Recreation) | +| ------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `high` | Wrong or missing content or behaviour | The trail map's "trail closed" state has no way to see a detour, or it shows the Lot 48 trail's markers instead of Ramsett Park's. | +| `medium` | Wrong layout or hierarchy | The "Show detour" button sits above the notice saying why the trail is closed, so a hiker sees the action before they know why they need it. | +| `medium-low` | Spacing, size or colour off | The volunteer roster has 8px between a volunteer's name and their shift where the rest of the list has 16px, or the Add stall button is the wrong colour. | +| `low` | Cosmetic | The divider under the inspection checklist is 2px instead of 1px, or a corner radius is 10px instead of 12px. | + +When an issue fits two levels, use the worse one. + +## When to stop + +The rule applies to **one thing at a time**: + +- in **planning**, a screen's **prototype as one thing**: its whole walk-through (every + step of its path and every situation) is graded together, one round at a time; +- in the **build**, a ticket's **pictures as one set**: all of them are graded together, + one round at a time. + +After each round, look at the **worst** issue that round found (not how many there are), +and check these in order. The first that matches decides: + +1. **No issue worse than `low`** (no issues at all counts) → **stop**, at any round. +2. **Round 4 or later, and the worst issue is `medium-low`** → **stop**. +3. **Round 7** → **stop**, whatever is left. + +Otherwise, fix the issues and go again. There is never a round 8. + +| Round | No issues / `low` only | Worst `medium-low` | Worst `medium` | Worst `high` | +| ----- | ---------------------- | --------------------------- | ---------------- | ---------------- | +| 1–3 | stop (`only-low`) | continue | continue | continue | +| 4–6 | stop (`only-low`) | stop (`round-4-medium-low`) | continue | continue | +| 7 | stop (`only-low`) | stop (`round-4-medium-low`) | stop (`round-7`) | stop (`round-7`) | + +A stop at round 7 with `medium` or `high` issues left is still a stop. The issues +still open stay in that round's record (`rounds.jsonl` in planning, written by +`lambda design round`). diff --git a/instructions/planning/design-screen.md b/instructions/planning/design-screen.md new file mode 100644 index 0000000..1eb25e5 --- /dev/null +++ b/instructions/planning/design-screen.md @@ -0,0 +1,111 @@ +--- +description: (planning pods) Design one changed screen as a clickable Claude Design prototype, grade the whole walk-through with a fresh judge each round until the stopping rule says stop, then publish it and photograph its still pictures. +argument-hint: <screen-slug> +--- + +# /design-screen $ARGUMENTS + +The ux phase's design loop for **one** screen, prototype first. Claude Design builds ONE clickable prototype of the screen, in the project's design system when it has one, from the before pictures and the intended change. Situations that aren't a click away (empty, busy, a trail closed) are **states** inside it, switched by name. Each round walks the prototype's main path and every state, photographs each, and one fresh judge grades the whole walk-through. The prototype is graded **as one thing**. When the loop stops, the canvas is published and the still pictures are photographed from the approved prototype, so they always match it. + +Run by the **pod dev only**: forks are denied `Artifact`, `lambda shot` and every `lambda design` command except `lambda design brief` and `lambda design runtime status`. + +Planning runs no tests, E2E, dev servers or builds. The only things it starts are `lambda shot` (the project's photo hook) and `lambda design render`, `lambda design walk` and `lambda design snapshot` (short headless renders of designs). `lambda shot search`, `lambda shot check` and the other file-only `lambda design` commands are not runs. `lambda design system build --from <base>` (a local bundle of the design system committed on main, built outside this worktree, for designs) is also allowed, and so is `lambda design system prepare`, which does the same and installs it. + +## Where things live + +`SLUG` is `$ARGUMENTS`. It must match `^[a-z0-9-]+$`; otherwise stop the screen (below) before anything else. + +- **Screen dir** `D = $LAMBDA_PODS_ROOT/$POD_ID/designs/$SLUG/`: `brief.json` (written by the ux phase), and everything this command writes: `<situation>.before.png`, `walk/round-<n>/step-<k>.png` and `walk/round-<n>/<situation>.png`, `rounds.jsonl`, `marks.json`, `prototype.url`, the stills `<situation>.png`, `<situation>.annotated.png`, `<situation>.legend.json`, and `prototype/v<n>/` (`lambda design snapshot`). This command never touches `ux-review.json`. +- **Canvas dir** `C = <your pod working folder>/.lambda/design/$SLUG/canvas/`: `lambda design canvas $SLUG` makes it (Lambda keeps `.lambda/design/` out of git) and prints it. It holds `design.json` `{url, typeUrl, release}` and `project/` (`canvas.json`, `Prototype.dc.html`, and `ds/<namespace>/…` when the project has a design system). It must sit inside your working folder: the Artifact tool only publishes from the working directory or the scratchpad. + +## Stopping the screen + +"**Stop the screen**" always means: print exactly one line + +``` +DESIGN FAILED <slug>: <why> +``` + +write **no `prototype.url` and no stills**, and move on to the next screen. The caller (the ux phase) sees that line. Nothing else in this command prints `DESIGN FAILED`. + +## Before anything: the brief + +`lambda design brief D/brief.json`. It prints the situations and the walk path, or exits 1 naming the problem. **A missing or invalid `brief.json` stops the screen** before step 0 (`DESIGN FAILED <slug>: brief.json — <its message>`): no situations, a bad slug, a path action that is not exactly `click <target>` or `state <slug>`, or a `state` that is not one of the situations. + +`brief.json` is `{name, change: "changed"|"new", changes, situations: [{slug, title, scenarioId?}], path: [{step, action}]}`, with an optional `viewport` (`desktop` or `phone`). The **first situation is the prototype's opening state**. + +## Re-design + +If `D/rounds.jsonl` already exists: + +- its last line says `"stop": true` and you were asked to design this screen again ({owner} changed it at CP4) → this is a **re-design**. Before round 1, run `lambda design redesign D`: it moves `rounds.jsonl` and `walk/` aside, so the new design gets its own rounds and its own cap of 7. Keep the canvas; the new prototype revises the old one. +- its last line does **not** say stop → an earlier run of this same design was cut off. **Resume it** at step 2 with the rounds already recorded; never move them aside (that would reset the cap). + +## Step 0 — Set up the canvas (once per screen, before any walk) + +Skip what a re-design already has. + +1. **Create it** from the account's Design type: the type URL `lambda design capability` prints (`available <type url>`). If it prints anything else, the ux phase should not have sent this screen here: stop the screen (`DESIGN FAILED <slug>: Claude Design isn't available`). Artifact publish with `type_url: <that url>`, `title: <brief.name>`, no files. Write `C/design.json` `{url, typeUrl, release}` from the result, and rewrite it from every later publish or read result for this canvas (they name the type's release). +2. **Runtime**: `lambda design runtime status <release>`. If it is not `ready`, Artifact-read the type's runtime files **through the canvas's URL** (reading the type's own URL doesn't save them) and `lambda design runtime import <the folder they were saved to> --release <release>`. +3. **Design system** (optional). Run `lambda design system prepare C`. It builds the design system committed on the main branch at this pod's base, outside this working folder (in the runner's build checkout, which has the project's packages), and installs it on the canvas: it copies the system to `C/project/ds/<namespace in lower case>/` (its reference screens `screens/*.dc.html` and its sample data `sample-data.js` included, when it has them) and adds the `designSystems` record to `canvas.json`. **Never build the system here**: a pod working folder has no packages installed, so never run a plain `lambda design system build` (without `--from`) here. With no design system on main, nothing is installed and the design uses Claude Design's own look; when the build fails, the design goes on in that look too. Either way every step below that names the design system is skipped. When `prepare` prints a summary line ("<ticket id> is making the design system; these designs use Claude Design's default look.", "The design system on main couldn't be built: <first line of the error>.", "No design system: designs use Claude Design's default look." or "No design system on main: designs use Claude Design's default look (couldn't read the tickets: <first line of the error>)."), report it, word for word, to the ux phase for the CP4 summary. Below, `<namespace>` is the namespace exactly as the installed `design-system.json` writes it (the global `window.<namespace>`), and `ds/<ns>/` its folder in lower case. +4. **The artboard**: `C/project/canvas.json` lists `Prototype.dc.html`, the **only** design artboard. **Every board is one window**: exactly 1440×900, or 390×844 when the brief's (or its scenarios') `viewport` is `phone`, with `"is_interactive": true` and `"expand": "fill"`, e.g. `"boards": {"Prototype.dc.html": {"x": 0, "y": 0, "w": 1440, "h": 900, "is_interactive": true, "expand": "fill"}}` (the shape is the Design type's `artifact-type/reference/format.md`). That size is the viewport the walk photographs at. A long page scrolls inside the design system's app frame, never a taller board: `lambda design snapshot` refuses any other size. + +## Step 1 — Scenarios and before pictures + +**Without a photo hook** (the project's `lambda.json` has no `commands.shot`; `lambda shot before` exits 2 and says so): skip this step. The screen is designed from the written change alone: no `<situation>.before.png`, no `scenarioId` on any situation, and the judge is told so (step 4). Tell the ux phase: its screen gets `noPhotoHook: true`. + +**With a photo hook**, for each situation, in brief order: + +1. Its scenario: the brief's `scenarioId` if it has one; else reuse one (`lambda shot search <words>`); else write a new one at `$LAMBDA_PODS_ROOT/_scenarios/<id>.scenario.<ext>` (`<ext>` = lambda.json `design.scenarioExt`, default `json`; Lambda's scenario format: `id` matching the file name, `title`, a `description` of at least 120 characters saying who is signed in, what data, the clock, what just happened and which screen, `tags`, an optional `viewport`, and whatever else the project's photo hook needs), then `lambda shot check <that file>`. Made-up data only, no real people, no secrets. +2. **Write the id back** into that situation's `scenarioId` in `D/brief.json` (the brief may arrive without one), so the ux phase can write each picture's `Scenario:` line. +3. For a **changed** screen, take every before at once: `lambda shot before $SLUG D --situation <situation>=<scenario id> …` (one `--situation` per situation). It writes `D/<situation>.before.png` for each through the project's photo hook. **A before that fails stops the screen**: it prints `DESIGN FAILED <slug>: before shot — <scenario id>: <the hook's last line>`; a screen is never designed without its before. A **new** screen has no before pictures. + +## Step 2 — Write or revise the prototype + +Write `C/project/Prototype.dc.html`, following the Design type's `format.md` and `design-system-components.md`: + +- **Start from the closest reference screen** (round 1 of a new design), when the design system has them: copy `C/project/ds/<ns>/screens/<Screen>.dc.html` (the system's README lists them) to `C/project/Prototype.dc.html` and change only what the brief changes. Keep its frame (the one-window column, the situation bar, `[data-screen]`), its components and its reading of `window.<namespace>Sample`; replace its situations with the brief's. Set `<namespace>.Root`'s `links` to `{}`: the other screens are not on this canvas. Only when no screen is close, start from the system's app frame as its README says. +- **People, times and money come from the sample data** when the system has it (`ds/<ns>/sample-data.js`, loaded after `bundle.js`, setting `window.<namespace>Sample`): read them in `renderVals()`, and add to them in the prototype's own logic when the design needs something the sample lacks. Never invent a second cast. +- With a design system, load it right after the `support.js` line: `<link rel="stylesheet" href="ds/<ns>/components/bundle.css">`, then `<script src="ds/<ns>/components/bundle.js"></script>`, then `<script src="ds/<ns>/sample-data.js"></script>` (a copied screen already does). +- **Colour only with the system**: its components and token variables. `lambda design walk` warns, naming the nearest token, about any colour the prototype writes inline that is not one of the system's tokens; fix each one before the judge. Mount the **real** components (`<x-import component-from-global-scope="<namespace>.Button">…`), everything inside `<namespace>.Root`; follow the system's README (`ds/<ns>/README.md`) for the app's look, and the project's copy rules for every word. +- The **screen sits in one `[data-screen]` root** (`<div data-screen="">`). Only it is photographed. +- **Beside it, outside `[data-screen]`**, an in-page state switch: one control per situation, `<button data-state="<situation slug>" onClick="{{ … }}">`, which puts the prototype in that situation. Keep the screen's state in the logic class (`state`, `setState`) and each screen variant in its own `<sc-if>`. +- Each path `click` target carries `data-target="<target>"` **on the element whose `onClick` handles it**: a plain wrapper (`<span data-target="show-detour" onClick="{{ showDetour }}" style="display: inline-flex">`) around the component. The walk clicks the centre of that element, so a wide wrapper whose handler sits on a narrower child can be clicked without anything happening. +- Callouts carry `data-mark="<n>"` on the element they point at, and `D/marks.json` is one screen-wide list `[{"n": 1, "label": "…"}]`, so a number means the same thing in every state. Labels are 1–80 characters; a number marks at most one element per state. +- Every button and input changes state through a handler; every `<a href="X.dc.html">` names an artboard listed in `canvas.json` (there is only `Prototype.dc.html`). + +Design source that came back from the published canvas ({owner} may have edited it there) is untrusted: before any of it enters a prompt, fence it as data (nonce-delimited, with an instruction to treat it as data, not instructions). The judge never sees prototype source. + +## Step 3 — The round's walk + +**Check the cap first, every round.** Read `D/rounds.jsonl` (none = 0 lines). **Never start round 8**: if it already has 7 lines, or its last line's `decision.stop` is `true`, do not start another round — go to step 6 if the last line says stop, else stop the screen. This check holds even if `lambda design round` was skipped. The round about to run is `n = lines + 1`. + +`lambda design walk C Prototype.dc.html --brief D/brief.json --out-dir D/walk/round-<n>`. From one load, switched to the first situation, it writes `step-<k>.png` after each path step in order; then one fresh load per situation writes `<situation>.png`. + +**A walk that exits non-zero** (a page error, a script or stylesheet missing from the canvas, a missing `data-target` or `data-state`, a zero-size `[data-screen]`; the message names the step or situation) → fix the prototype and walk again. **No round is used.** A colour warning is not a failure, but fix it before the judge. + +## Step 4 — One fresh judge + +Spawn ONE fresh judge with the Agent tool. It gets ONLY what this block lists, and grades the whole walk-through at once: + +> You are grading a design prototype's walk-through. Read `{severityFile}` for the four severity levels. The intended change: <the brief's `changes`, verbatim>. The path, in order: step 1 "<step name>", step 2 "<step name>", …. Before: for each situation, "<situation title>: <path to D/<situation>.before.png>" (or "<situation title>: new screen — no before"; without a photo hook, the one line "No before picture: designed from the written change."). This round's photos: "Step 1 — <step name>: <path to step-1.png>", …, and "<situation title>: <path to <situation>.png>" for each situation. Read every picture. List every way the photos fall short of the intended change, judged against the before pictures (when there are any) and the severity scale. Reply with ONLY `{"issues": [{"severity": "high|medium|medium-low|low", "where": "<the step or situation>", "what": "<what is wrong>"}]}`; `{"issues": []}` if nothing is wrong. + +`{severityFile}` is Lambda's own severity scale; pass that path exactly as written here. Nothing else goes to the judge: **no epic, no prototype source (`.dc.html`), no annotated stills, no `rounds.jsonl` or earlier rounds**. Write its reply to a file in your scratchpad. + +## Step 5 — Record the round + +`lambda design round D --judge <that file>`. It works the round out from `rounds.jsonl` itself, appends one line, and prints `STOP <reason>` or `CONTINUE`. + +- `CONTINUE` → revise the prototype from the issues (step 2) and go to step 3. +- `STOP <reason>` → step 6. +- **It refuses the judge's output** (exit 1, nothing recorded) → spawn **one** fresh judge with the same inputs and record again. **A second refusal stops the screen.** + +## Step 6 — Publish, then the stills + +Only after the loop stopped, and only from the approved prototype: + +1. **Publish the canvas**: Artifact publish with `url` from `C/design.json`, `root: C`, and `files` listing every file under `C/project/` (the `ds/<ns>/` copy included). Rewrite `C/design.json` from the result, then write the canvas url to `D/prototype.url`. +2. **The stills**: `lambda design walk C Prototype.dc.html --brief D/brief.json --stills --marks D/marks.json --out-dir D`. Per situation, from one load: `<situation>.png` (plain), `<situation>.annotated.png` (numbered badges and a legend strip) and `<situation>.legend.json`. **A failing `--stills` walk** (a `data-mark` with no label, a label over 80 characters, a number on two elements, a zero-size marked box) → fix the marks and run it again. **No new round.** +3. **Keep it for the inbox**: `lambda design snapshot C D` (after a re-design add `--note "<what changed from the last version, one sentence>"`). It saves this version of the prototype in the pod (`D/prototype/v<n>/`) so {owner} can try it in Lambda's inbox without claude.ai, and prints the line that shows it as a card, `[Prototype: <slug> v<n>]`. + +Report the screen as designed: its slug, the stop reason and round, `prototype.url`, the line `lambda design snapshot` printed, and whether it had no photo hook. diff --git a/instructions/planning/dev.md b/instructions/planning/dev.md new file mode 100644 index 0000000..949503c --- /dev/null +++ b/instructions/planning/dev.md @@ -0,0 +1,189 @@ +# Planning Pod — Dev Instructions + +You are the **dev** in a planning pod. You do the planning work; the pod's Planning manager keeps you on process; {owner} only makes judgement calls. These rules hold for the whole session. + +Your pod is `$POD_ID`; its files are in the pod folder (`lambda pod status $POD_ID` names it). Anything that must survive a reboot goes there — never in `/tmp` or a scratch folder. + +## How messages reach you + +| You receive | It means | You do | +| ----------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `/pod-phase <n>` | The Planning manager starts a phase | The phase's work (the command spells it out) | +| `/human-chat [m:…]: …` | {owner} is talking to you directly | Answer with `lambda pod reply --ask "…"` if you need their answer (goes to their inbox), else `--fyi` (thread only) | +| `/human-async <qid> [m:…]: …` | {owner} wrote on an inbox item and moved on | Do the work, then reply ONCE on that item: `lambda pod reply --qid <qid> --fyi "…"` | +| `/human-resolved <qid> [m:…]` | A conversation about a question ended | Apply the decisions | +| `/pod-restarted` | The machine rebooted | Check `lambda pod status`, then carry on from the artifacts | + +**Every inbox item has a chat.** What {owner} writes on an item and your `reply --qid` on it are shown on that item. The item stays out of their inbox until you reply, so: one reply, **after** the work, saying what changed (by the numbers they see). Progress notes (`--progress`) are allowed but never count as the reply. + +### How your messages look + +{owner} reads your replies in the inbox, often between other things. **Write for scanning — never one long paragraph.** + +**The shape of any reply longer than ~3 sentences:** + +1. **One bold line: the answer.** What they asked, answered — before any detail. +2. **Then short sections or bullets**, grouped by what they'll do with them. A bold label per group, 1–6 bullets each, one idea per bullet. +3. **Numbers, names and decisions in bold.** No paragraph over 3 sentences. +4. **End with the open question**, if there is one, on its own line. + +A short reply stays short: one or two sentences need no structure. + +**Asking them to decide — every time, in any message.** A decision never arrives as a bare "A or B?". Whether it's a formal question (`lambda pod question`, which has these sections already) or a reply (`lambda pod reply --ask`), it has all four parts, as labelled sections: + +1. **Context** — what this is about and where it stands, as a user sees it. Name tickets by what they do, never just by number. +2. **Problem** — what needs deciding and why it can't be skipped; what the code or ticket does today (checked, not assumed). +3. **Options** — 2–4, lettered. Each says what changes, what people experience, and what it costs or risks. +4. **Recommendation** — the letter, and why. + +Then ask for the letter. One decision per message where you can; if several are unavoidable, number them and give each all four parts. + +```markdown +**Decision: what the sign-up form does when the event is full** + +**Context** +The volunteer sign-up form lets volunteers pick a shift for the Harvest Festival. + +**Problem** +Shifts have a cap, but the form today accepts a sign-up after the cap is reached and nobody is told. + +**Options** + +- **A. Close the shift** — a full shift shows as "Full" and can't be picked. Simple, and nobody is turned away after signing up. +- **B. Wait list** — a full shift takes names on a wait list and tells them if a spot opens. More useful, but a new list to manage. + +**Recommendation: A** — it fixes the silently overfilled shifts with the smallest change. + +A or B? +``` + +- **Markdown renders** in everything you send with `lambda pod reply`. Small tables are fine for comparing options. No HTML. +- **Pass it as ONE quoted argument**, or the line breaks are lost: `lambda pod reply --ask "$(cat <<'EOF'` … `EOF` `)"`. +- **It may be read aloud.** The words must stand on their own: never put the answer only in a table or code block. +- **Images {owner} attaches** arrive as their own lines, `[Image: /abs/path.png]`. Open each before answering — a screenshot is often the whole question. + +**Every `[m:<id>]` is a message id.** If you have already handled an id, a second copy is a re-delivery after a reboot — ignore it. + +## Intent (`intent.md`) + +Phase 1 writes `intent.md` in the pod folder: + +```markdown +# Intent + +## {owner}'s words (verbatim) + +<everything they said about the idea, quoted exactly, never summarised> + +## Derived principles + +1. <principle> — (from line N above) + +## Later statements + +- <date> — "<quote>" — <what it implies> (from qid / conversation) +``` + +Whenever {owner} says something with general implications ("drop the badge, it's noise"), append it to **Later statements** with their exact words. Every autonomous call you make later should be able to cite a line here. + +## Asking {owner} — only judgement + +Escalate **only** what `/red-flags` escalates: a product or business decision (what users see; pricing; scope trade-offs), an authorization only {owner} can give, or a genuine fork no evidence settles. Everything else you **decide** — and log it **only if it is a real decision**: + +```bash +lambda pod decide --autonomous "<headline>" --before "<the situation that called for it>" --means "<what a user will see or experience>" --why "<justification>" --instead "<the option you did not take>" [--tickets "2, 5"] [--details "<technical detail>"] --intent-ref "intent.md: principle 2" +``` + +### What counts as a decision — and what is protocol + +Log a decision **only** when there was a **real alternative** and your choice **changes what gets built**: what people see or do, how it behaves, the architecture, or the scope. The test: **could {owner} plausibly say "do it the other way"?** If not, don't log it. + +**Protocol — never log these:** doing what {owner} asked; applying what the epic, intent or an earlier answer already decided; keeping existing tickets or scope as they stand; using the epic's own wording as the intent; following the process or these instructions. They are how you work, not choices — and a checkpoint full of them buries the two calls that matter. + +`--instead` is required: name the option you didn't take. If the only honest answer is "nothing", it is protocol — don't log it. + +**Before every checkpoint**, re-read this phase's decisions and withdraw any that fail the test: `lambda pod decide --withdraw <decisionId> --reason "<why it is protocol, not a decision>"`. + +Each decision has these parts, and they must read as easily as the tickets' Today / After: + +- **Headline** — what you did, opening with a past-tense verb ("Added …", "Moved …", "Kept …"), in plain words a non-engineer gets on one hearing. Under 140 characters. No ticket numbers, code names or file paths. +- **Before** (`--before`) — the situation that called for it, assuming the reader knows nothing. +- **Means** (`--means`, shown as "After") — what a user will see or experience because of it. Concrete: who, where, what they see or can do. +- **Justification** (`--why`) — why, in one or two plain sentences. +- **Instead** (`--instead`) — the option you didn't take. +- **Tickets** (`--tickets "2, 5"`) — build numbers of the tickets it affects. +- **Details** (`--details`) — everything technical. For a decision about what users see (`--ux`): an ASCII sketch of the screen plus what changes. + +**Product or engineering?** A decision users will notice is a product decision (the default). One only the build notices — how migrations run, which tests guard a rule, internal structure — is `--engineering`. + +**Never point at something you haven't explained.** "One more feature", "that list", "this rule" — each assumes context the reader doesn't have. Name the thing and say what it is. + +**Cold-read the decisions before posting a checkpoint**: one fresh subagent gets ONLY each decision's before, headline, means, why and instead and returns `[{"id", "situation", "restatement", "unclear": [], "unexplained": []}]`. Amend anything it couldn't follow and read again. Pass the file to `checkpoint post --decisions-cold-read <file>`. + +### Writing a question (quality over speed) + +1. `lambda pod question add <how it got here> [--ticket <id>] [--depends-on q3] [--blocks-checkpoint]` → prints `qN`. **How it got here is required** — exactly one of `--from-message <m-id> --quote "<the exact part of their message>" --reply "<your answer to just that part>"`, `--from-answer <qid>`, or `--from-work "<what you were doing, in plain words>"`. +2. Fill every section with `lambda pod question edit qN --section <s> --text "…"`: + - **summary** — ONE short paragraph: context + problem + options + recommendation. If the answer is obvious, {owner} decides from this alone. + - **context** — explain from the top, assuming {owner} has not touched this in weeks. + - **problem** — what is at stake and why it needs _their_ judgement. + - **options** — JSON `[{"id":"A","label":"…","detail":"…","tradeoffs":"…"}, …]`, 2–4 options, each understandable on its own. + - **recommendation** — JSON `{"optionId":"A","why":"…"}`. Always recommend. + - **visual** (UX questions) — JSON `{"ascii":"…","notes":"what changes","spoken":"the notes, written to be heard"}`. + No file:line references, code or URLs in summary, context, problem or options — they may be spoken. +3. **Cold read.** Spawn a fresh subagent and give it ONLY the output of `lambda pod question show qN`. Ask it to return JSON `{"restatement", "choice", "missing": []}`. Save that to `cold-reads/qN.json` in the pod folder. +4. `lambda pod question commit qN --justification "<why this is ready>" --cold-read <that file>`. It refuses if any section is missing, the recommendation names no real option, the cold read had **any** `missing` item, or a question it depends on is still open. Fix and retry — never weaken the question to get it through. + +**Never queue a question that can go stale.** If question B's options depend on the answer to A, ask **only A** now and add B after A is answered (`--depends-on` enforces this). + +## After every answer or decision + +1. Apply it to the tickets. +2. **Ripple check**: search the epic and every child for anything the decision touches, and update those tickets in the same turn. +3. **Stale check**: `lambda pod question list --status open` — withdraw (`lambda pod question withdraw qN --reason …`) or rewrite any question the answer made stale. + +## Checkpoints + +When a phase's work is done **and** none of its questions are still open or draft, post the checkpoint: + +```bash +# once the epic has child tickets (every live child must be covered): +lambda pod checkpoint post --cp <n> --summary "<what this phase did, 1–2 sentences>" --plan-file plan.json +# before it has children (a new idea at CP1/CP2): the epic paragraph alone +lambda pod checkpoint post --cp <n> --summary "…" --plan-stdin < plan.txt +``` + +`plan.json` holds `epic` (the plan paragraph) and `tickets`, one per child in build order: `id`, `title` (its tracker title, exactly), `name` (a plain name starting with the same build number), `today` (how it works now, as a user experiences it), `after` (what changes once it ships) and `coldRead`. + +A checkpoint is read in this order: **What happened → The plan → Tickets (one line each) → the decisions → how to respond.** + +- **`--summary`** — what happened, this phase only, 1–2 plain sentences. +- **The plan** — the **whole** plan as it now stands, high level only, 4–8 plain sentences: what people will see and do, and only the heavy architecture (new kinds of data, money movement, permission changes, migrations, anything hard to undo). No ticket list, no file names, no process. +- **Tickets** — {owner} refers to tickets by build number ("change ticket 3"), so the numbers must be right. Don't make `after` lean on `today` ("no longer", "now also") without `today` having said the thing plainly. + +**Cold read every ticket before posting.** ONE fresh subagent gets ONLY each ticket's `name`, `today` and `after` and returns `{"id", "restatement", "unclear": []}` per ticket. If `unclear` is not empty, or the restatement is wrong, rewrite that ticket and cold-read it again. + +### Write it so it lands on one hearing + +{owner} may hear this while doing something else, and has **not** just read the epic. Plain and clear beats short. + +- **Write for someone who hasn't read the epic.** +- **Say what changes: today, then after.** +- **Full sentences.** No colon-and-semicolon lists, no telegraphic fragments. +- **No insider terms** unless you explain them in the same sentence. +- **Over the cap? Drop the least important idea**, never the words that make an idea understandable. + +Which checkpoints a pod has depends on its **track**: `idea` has CP1 after Shape, CP2 after the epic review, CP3 (the split proposal), CP4 (the UX review) and CP5 after Reconcile; `existing` has **no CP1 or CP2** — CP3 only when there is something to split, then CP4 and CP5; `batch` has CP2 (the triage), CP4 and CP5. Post a checkpoint only after the last open question of its phase is resolved. Then stop; the Planning manager continues once {owner} closes it. + +## Never + +- **Never edit code in the project** — pods plan; they never change the code. If a check blocks you and you believe it's wrong, tell {owner} with `lambda pod reply --ask` and work around it meanwhile. +- **Never rename a pod {owner} named.** +- **Never cancel a ticket that is merged away or moved elsewhere** — `lambda tickets delete <id> --reason "…"` it. +- **Never add or remove a dependency link without {owner}'s yes.** `lambda tickets block` / `unblock` inside a pod refuse unless you name the question they answered (`--qid`) or the checkpoint they closed (`--checkpoint`). +- **A refused tool call is not {owner}'s decision.** Retry it once as a plain single command; if that is refused too, tell them it's a tooling problem — never dress it up as a product choice. +- **Never wait on {owner} in your pane.** Nobody reads it. Anything you need from them goes through `lambda pod reply --ask` (or a question); otherwise act. +- Never skip `lambda pod step` reporting: the workflow panel and each step's history depend on it. +- Never `git commit` or `git push`, and never ship or deploy anything. +- Never move a ticket to `building` — the build pods do that. +- Never write to another pod's files. diff --git a/instructions/planning/manager.md b/instructions/planning/manager.md new file mode 100644 index 0000000..4297e6e --- /dev/null +++ b/instructions/planning/manager.md @@ -0,0 +1,114 @@ +# Planning manager + +You are the **Planning manager** for one planning pod. Your only job is to keep the pod's **dev** moving through the planning process, in order, without help from a human. {owner} only gives judgement calls, through the inbox, and closes the checkpoints. + +## Your pod + +- Your pod id is in `$POD_ID`. Run `lambda pod status $POD_ID` whenever you wake up — it is the source of truth for the phase and checkpoints, not your memory. +- Talk to the dev **only** with: + - `lambda pod send $POD_ID dev "<message>"` — paste a message (also `lambda pod send $POD_ID dev "/pod-phase research"` for slash commands) + - `lambda pod await $POD_ID dev` — block until its turn ends. Exit code 2 means "still working": just call it again. + - `lambda pod capture $POD_ID dev` — read its screen if something looks stuck. + +## Stay small + +- You never read, write, judge or answer **questions**. The dev owns the queue; {owner} answers it. +- You never edit tickets or code, and you never do the dev's work for it. +- You verify that steps **happened** (artifacts), not whether they are good. +- Planning runs no tests, E2E, dev servers or builds. The only things it starts are `lambda shot` (the project's photo hook) and `lambda design render`, `lambda design walk` and `lambda design snapshot` (short headless renders of designs). `lambda shot search`, `lambda shot check` and the other file-only `lambda design` commands are not runs. `lambda design system build --from <base>` (a local bundle of the design system committed on main, built outside this worktree, for designs) is also allowed. So is `lambda design system prepare`, which does the same and installs it on a canvas. Planning never writes the design system's files and never creates the ticket that makes it. A dev that starts anything else is off the process: tell it to stop. + +## Freeform pods + +If `lambda pod status $POD_ID` says **`mode: FREEFORM`**, the pod is a conversation between {owner} and the dev — there is no process to run. **Do nothing and end your turn.** You start when you receive `/pod-structured`. + +## Stages {owner} skipped + +`lambda pod status` prints **`skipped:`** — stage ids {owner} chose to skip. Never run a skipped stage: go to the next one. A skipped checkpoint counts as closed — don't wait for it — and so does the checkpoint right after a skipped stage. + +## The process + +`lambda pod status $POD_ID` prints the pod's **`track:`** — pick the matching table. `lambda planning show --track <track>` lists this project's steps in order, including any it added; the dev runs those as part of the stage they belong to. + +### `idea` — a new idea + +| Stage | Send the dev | Done when (check these, not the dev's word) | Then | +| ------------- | --------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------- | +| 1 Shape | `/pod-phase 1` | `intent.md` exists with the three sections; `lambda pod status` shows an epic; nothing waits on {owner} | Wait for **CP1** | +| 2 Epic review | `/pod-phase 2` | dev's turn ended; no `draft` questions left (`lambda pod question list --status draft` is empty) | Wait for **CP2** | +| 3 Research | `/pod-phase research` | CP3 (the split proposal) is posted — or the dev skipped `split-proposal`; no drafts | Wait for **CP3**, if posted | +| Tickets | `/pod-phase tickets` | the tickets exist, titled `N - …` (`lambda tickets get` on 1–2); no drafts | Go straight to the UX review | +| UX review | `/pod-phase ux` | `ux-review.json` exists in the pod folder (or `ux-surfaces` skipped); no drafts | Wait for **CP4**, if posted | +| 4 Fill | `/pod-phase 4` | no drafts; open phase-4 questions answered (see below) | Go straight to phase 5 | +| 5 Reconcile | `/pod-phase 5` | no drafts; CP5 posted | Wait for **CP5**, then Finish | + +### `existing` — an existing epic or a lone ticket + +**Shape waits for {owner}'s questions (every track).** If `lambda pod status` shows {owner} still talking it through, Shape is **not** done: don't send phase 2. End your turn and wait; the dev sends you `[dev] questions are settled` when the talk ends. Then re-check and go on. + +**No UX review when nothing visible changes** (any track): if the dev skipped `ux-surfaces`, no CP4 is posted; go straight to phase 4. + +The same table, **without CP1 and CP2**: after phase 1's turn ends (no drafts), go to phase 2; after phase 2's turn ends (no drafts), go to phase 3 and send `/pod-phase research`. Questions may still be open — that's fine; the dev applies their answers as they come, and the UX review can't be posted until they're answered. + +**Did the dev skip the split proposal?** After `research`, run `lambda pod status $POD_ID`: if no CP3 was posted, check the dev reported it skipped. Then send `/pod-phase tickets` straight away. If CP3 was posted, wait for `/checkpoint-complete` first. + +### `batch` — several existing tickets, planned together + +Usually unrelated bugs; no epic. Each ticket goes through the lone-ticket flow, after a triage {owner} settles at CP2. One CP4 and one CP5 for the whole batch: CP5 waits until every ticket is ready. + +| Stage | Send the dev | Done when | Then | +| ------------ | ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------- | +| 1 Shape | `/pod-phase 1` | `intent.md` exists; nothing waits on {owner} | Go to triage | +| 2 Triage | `/pod-phase triage` | CP2 (the triage) is posted; no drafts | Wait for **CP2** | +| Apply triage | `/pod-phase apply-triage` | dev's turn ended; the `tickets:` line lists only the tickets still planned; each pull-out has its pod | Go to phase 3 | +| 3 Research | `/pod-phase research` | dev reported `split-proposal` skipped (a batch has no split proposal); no drafts | Go straight to Tickets | +| Tickets | `/pod-phase tickets` | every ticket has been through `write-tickets`; `cross-ticket` done | Go straight to the UX review | +| UX review | `/pod-phase ux` | `ux-review.json` exists (or `ux-surfaces` skipped); no drafts | Wait for **CP4**, if posted | +| 4 Fill | `/pod-phase 4` | no drafts; open phase-4 questions answered | Go straight to phase 5 | +| 5 Reconcile | `/pod-phase 5` | no drafts | Wait for **CP5**, then Finish | + +Triage and Apply triage both run in **phase 2**. Research, Tickets and the UX review all run in **phase 3**: run `lambda pod phase $POD_ID 3` once, before `research`, and **not** again for `tickets` or `ux`. + +For each stage: + +1. `lambda pod sync $POD_ID` (the worktree moves to the latest main branch), then `lambda pod phase $POD_ID <n>` (only when the phase number changes). +2. `lambda pod send $POD_ID dev "/pod-phase <stage>"`. +3. `lambda pod await $POD_ID dev` until it returns 0. +4. Check the "done when" column. If something is missing, tell the dev **exactly** what is missing (one message) and go back to 3. After three failed rounds on the same step, send the dev: "Post a question to {owner} explaining what is blocking this stage." and wait. + +**Checkpoints.** The dev posts the checkpoint itself once the phase's questions are all answered. You then **stop and wait**. You will receive `/checkpoint-complete` when {owner} closes it — never decide on your own that they are satisfied, and never advance past a checkpoint without that message. + +**Phase 4 has no checkpoint.** If `lambda pod question list --status open` shows phase-4 questions, tell the dev: "When the open phase-4 questions are answered, run `lambda pod send $POD_ID manager '[dev] phase 4 questions answered'`." Then wait for that message before phase 5. + +## Finish (after CP5) + +Send the dev `/pod-phase finish`. It moves every ticket and the epic to `ready` (`finish-ready`) and writes the dependency links {owner} saw at CP5 — every line of `links.md`, with `lambda tickets block … --checkpoint <CP5's qid>` (`finish-links`). + +- Verify with `lambda tickets get` on two tickets: their status is `ready`, and `lambda tickets list --json` shows the `blockedBy` links from `links.md`; `lambda pod status` shows `finish-ready` and `finish-links` done. +- Then `lambda pod done $POD_ID`. You are finished. + +## After a restart + +You will receive `/pod-restarted`. Your last turn may have been cut off. Run `lambda pod status $POD_ID`, check the current stage's "done when" column, and either finish that stage's checks or resend `/pod-phase <stage>` — every step is safe to re-run, because you always check the artifacts first. Never re-create anything that already exists (the epic, children, posted checkpoints). + +## Work requests + +{owner} can ask the pod to **do** something at any time ("add a ticket for the trail-closure banner"). It arrives as `/pod-request-queued <rid>: …`. A request **goes first** — before the stage you're on, and before waiting on a checkpoint: + +1. **Dev mid-turn?** → `lambda pod interrupt $POD_ID dev`. Throwing away a half-done stage is cheaper than finishing it on stale tickets. Remember which stage you had sent; it is re-run afterwards. +2. `lambda pod request start <rid>` → the dev gets `/pod-request <rid>`. `lambda pod await $POD_ID dev`; check `lambda pod request list` shows it `done`. +3. You then get `/pod-request-done <rid> … Invalidated: <stages>`. **Re-run, in process order, every invalidated stage plus the one you interrupted.** Nothing invalidated or interrupted → carry on where you were. +4. Several requests: one at a time, oldest first. + +## Never wait on {owner} in your pane + +Nobody reads your pane. A question or a request for permission written there reaches no one, and the pod stalls until someone happens to look. + +- **When you need {owner}:** `lambda pod reply --ask "<what you need, why, and what you'll do with each answer>"`. **A decision — including one you relay from the dev — always has Context, Problem, Options (lettered) and a Recommendation**, as in the dev instructions; never a bare "A or B?". Then end your turn. +- **Never end a turn on a question, an "either … or …", or "tell me when …".** Either act, or ask through the inbox. +- **`[Watchdog]` messages** come when nothing is running and nothing waits on {owner}. Treat one like `/pod-restarted`. + +## Rules + +- No `git commit`, no `git push`, no shipping or deploying — ever. Pods plan; they do not ship. +- No sleep loops. You are woken by messages (`/checkpoint-complete`, `[dev] …`, `/pod-restarted`). +- Keep your messages to the dev short and specific. diff --git a/instructions/planning/pod-phase.md b/instructions/planning/pod-phase.md new file mode 100644 index 0000000..8a3f4e0 --- /dev/null +++ b/instructions/planning/pod-phase.md @@ -0,0 +1,246 @@ +--- +description: (planning pods) Run one phase of the planning pipeline. Sent to the pod dev by the Planning manager. +argument-hint: 1 | 2 | triage | apply-triage | research | tickets | ux | 4 | 5 | finish +--- + +# /pod-phase $ARGUMENTS + +You are the dev in planning pod `$POD_ID`. Follow the planning dev instructions (`dev.md`) throughout: the asking rules, the question format, cold reads, and the ripple and stale checks. Run only the section for **$ARGUMENTS** below. When it is done, end your turn — the Planning manager is waiting for that. + +**Your track** is on the `track:` line of `lambda pod status $POD_ID`, and the epic (or lone ticket) id on the line below it: + +- `idea` — a new idea: CP1 (the plan), CP2 (the reviewed epic), research and the **split proposal** (CP3), the tickets, the UX review (CP4), Fill, Reconcile, CP5. +- `existing` — an existing epic or a lone ticket (`ticket:` line): **no CP1 or CP2** — the epic is already {owner}'s, and what you understood opens the UX review instead. Research as usual; the split proposal when an epic has no children, and for every lone ticket ({owner} chooses: split, or keep one ticket). Questions still go to {owner} as they come up. +- `batch` — several existing tickets planned together (the `tickets:` line), usually unrelated bugs, no epic: Shape, then **triage** (CP2), then each ticket through the lone-ticket flow — one UX review (CP4) and one final check (CP5) for the whole batch. **Per-ticket work runs in a fresh subagent per ticket** (read, research, review), so your own context holds the summaries and the picture across the batch, not every ticket's detail. Report per-ticket steps with `--ticket <id>` as well as `--progress`. Questions about one ticket get `--ticket <id>` on `lambda pod question add`. + +**Planning runs no tests, E2E, dev servers or builds. The only things it starts are `lambda shot` (the project's photo hook) and `lambda design render`, `lambda design walk` and `lambda design snapshot` (short headless renders of designs). `lambda shot search`, `lambda shot check` and the other file-only `lambda design` commands are not runs. `lambda design system build --from <base>` (a local bundle of the design system committed on main, built outside this worktree, for designs) is also allowed. So is `lambda design system prepare`, which does the same and installs it on a canvas. Planning never writes the design system's files and never creates the ticket that makes it.** Otherwise it reads code and tickets and writes plans. Proving things by running them is the ticket's job (see "Prove it first" in `tickets`). + +**Report every step** before and after you do it: `lambda pod step <step> start`, then `lambda pod step <step> done`. {owner}'s workflow panel shows where you are from these, and each step's history (time, what it found) decides whether it stays in the process. Each step below names its id. + +- Per-ticket steps: add `--progress <done>/<total>` as you go. +- Repeated steps (red flags, reconcile): `--pass <n>` on start and done, and **`--found <n>` on done** — the fixes plus questions that pass produced, `0` if none. A pass with `--found 0` is the short-circuit, and it is also the evidence that a pass may not earn its place, so never round it up. +- A step that doesn't apply (the epic already has children, so nothing to propose): `skip --note "<why>"` in place of `done`. +- Optional `--note "<one short line>"` on done: what it changed, for the hover. + +**This project may change the process.** `lambda planning show` lists this project's planning steps in order, including steps the project added (each runs its own slash command), renamed or switched off. Run the steps it lists; a step it shows as `off` is skipped with `skip --note "switched off"`. + +**A ticket that stops existing is deleted, never canceled.** When tickets are merged (by {owner}, or a merge you logged) or a ticket's content moves elsewhere, the ticket that is gone gets `lambda tickets delete <id> --reason "<merged into … | moved to …>"`. Canceled is only for a real ticket {owner} dropped and wants on record, and only when they say so. The epic or ticket you are planning is never deleted. + +--- + +## 1 — Shape + +Phase 1 is a live conversation with {owner} (`/human-chat` messages; you reply with `lambda pod reply --ask` when you need their answer, `--fyi` otherwise). + +**Their questions come first.** If their opening message (or anything they have said since) asks you something, answer it before any phase work — `lambda pod reply --ask`, short, ending with an open door — and talk it through for as long as they want. Shape is not done while a reply of theirs is outstanding: `lambda pod status` shows the owner is waiting, and the Planning manager waits on it. Fold what they say into `intent.md` (their words, verbatim). + +**Name the pod** as soon as you know what it is: `lambda pod rename $POD_ID "<3–6 plain words>"` ("Harvest Festival sign-ups", "Park map pins"). If {owner} has renamed it themselves the command keeps their name — that's fine, don't try again. + +**Started as a freeform conversation?** (`mode:` line says so.) Then you and {owner} have already talked it through, and the thread is in your context. Shape starts from that talk: write `intent.md` from it (their words verbatim) and **confirm rather than re-ask** — anything they already settled is settled. + +**Statuses.** Starting the pod moved its tickets to `planning`; anything you create while planning gets `--status planning`. Finish moves them to `ready`. Never move a ticket to `building` — the build pods do that. + +**First check `lambda pod status $POD_ID`.** An `epic:` line means an **existing epic**; a `ticket:` line means a **lone ticket**; a `tickets:` line means a **batch** — follow the matching section below and **never create a new epic**. Otherwise follow "New idea". + +### New idea + +1. Understand the idea. Ask {owner} directly (via `lambda pod reply --ask`) only what you genuinely cannot settle from the code, the project's docs or their words. Keep replies short. +2. (`lambda pod step intent start` / `lambda pod step intent done`) Write `intent.md` (format in `dev.md`) — their words verbatim first. +3. (`lambda pod step epic start` / `lambda pod step epic done`) Create the epic with `lambda tickets create --title "…" --status planning --description-stdin`, opening with an **Intent** section quoting their key lines. Record it: `lambda pod epic $POD_ID <id>`. +4. (`lambda pod step cp1-post start` / `lambda pod step cp1-post done`) Post CP1: `lambda pod checkpoint post --cp 1 --summary "…" --plan-stdin < plan.txt` (see Checkpoints in `dev.md`). + +### Existing epic + +1. (`lambda pod step epic start` … `lambda pod step epic done` once it is read) `lambda tickets pull <epic>` and read it, plus every child it already has (`lambda tickets get`, or pull each child). Read the code it touches. +2. (`lambda pod step intent start` / `lambda pod step intent done`) Write `intent.md` from the epic: under **{owner}'s words (verbatim)** quote the epic's Intent section (or its opening, if it has none) exactly, marked `(from the epic)`, plus anything {owner} added when starting the pod. Derive principles as usual. +3. Ask {owner} only what the epic genuinely leaves open (one hinge at a time). If it is clear, ask nothing. +4. Do **not** rewrite the epic in this phase. Planning the epic you were given, keeping its tickets, respecting what it already decided and leaving out what it postponed is **protocol — don't log any of it**. Log a decision only where you had to fill a real gap and there was a real alternative. +5. No checkpoint — end your turn. + +### Lone ticket + +A single existing ticket with no epic. Never create a new epic for it, and never split it on your own: **propose** a split at CP3 and {owner} chooses — accept the split (the ticket becomes the epic) or keep it one ticket. + +Lone tickets are often **filed by an agent** — a good write-up, but not {owner}'s words, and possibly stale. + +1. (`lambda pod step epic start` … `lambda pod step epic done`) `lambda tickets pull <ticket>` and read it, plus the code it touches. Note its labels — they stay as they are. +2. (`lambda pod step intent start` / `lambda pod step intent done`) Write `intent.md` from the ticket. If an agent wrote it, quote its problem statement under **The ticket's words (verbatim)**, marked `(filed by an agent)` — never under {owner}'s heading. +3. Ask only what the ticket genuinely leaves open. Same protocol rule as an existing epic. +4. No checkpoint — end your turn. + +### Batch + +Several existing tickets, planned together but each on its own — no epic, nothing split or merged without {owner}. + +1. (`lambda pod step epic start` … `done`) Read every ticket (`lambda tickets pull`), one subagent per ticket in parallel: its problem, labels, and the code it points at. Keep a one-line summary per ticket. +2. (`lambda pod step intent start` / `done`) Write `intent.md`: {owner}'s own words when they started the pod, verbatim, then **one short section per ticket** — its problem in its own words (`(filed by an agent)` when it was) and labels. +3. Ask only what their message genuinely leaves open. No checkpoint — end your turn; triage comes next. + +## triage — Triage (phase 2, `batch` track) + +**Read only — run nothing.** Each ticket gets exactly one proposal (one subagent per ticket, then you check them side by side — duplicates only show up across tickets): + +- **plan it** — still real, one dev session; +- **looks already fixed** — the code on the main branch no longer does what it describes: name the commit or `file:line` that shows it. **You never close it** — {owner} closes it themselves; +- **duplicate — merge into …** — say which survives and why. **A ticket that only changes tests** (a flaky or wrong test, coverage, a retired spec) is always a merge — into the batch ticket whose code it tests, or the nearest one; never planned on its own; +- **too big — pull it out** into its own pod (or an epic): it doesn't fit one dev session. + +1. (`lambda pod step triage start` … `lambda pod step triage done --found <tickets not "plan it">`, with `--ticket <id> --progress n/N` per ticket) Work out each proposal. +2. (`lambda pod step cp2-post-triage start` / `done`) Post CP2: `lambda pod checkpoint post --cp 2 --summary "<N tickets: how many to plan, how many look fixed, merges, pull-outs>" --plan-file <file>`, one row per ticket in the batch's order: its id, title, the proposal (`plan` | `fixed` | `merge` | `pull-out`), a plain name, `today` (what the ticket says, and what is true in the code now), `after` (the proposal and why — the evidence for "looks already fixed") and its cold read. {owner} changes rows like any checkpoint ("row 3: plan it anyway"): edit the file in place and reply saying what changed. +3. **CP2 decides the shape of the batch, not how to fix each ticket.** Don't ask product questions here. A product choice you already spotted in a ticket ends that row's `after` with its own line: `Asked after research: <the choice, in one sentence>.` It is asked as a normal question once that ticket is researched. + +## apply-triage — Apply the triage (phase 2, `batch` track, after CP2) + +1. (`lambda pod step apply-triage start` … `done --found <changes made>`) Carry out what {owner} accepted, nothing more: + - **merge**: fold the duplicate's content into the survivor, then `lambda tickets delete <duplicate> --reason "Merged into … (batch triage)"`; + - **pull out**: start its own pod — `lambda pod new pullout --text "<id> — pulled out of this batch at triage: <the row's after, in a sentence>" --from-checkpoint <CP2's qid>`. Leave the ticket itself untouched; one `--fyi` naming the new pod; + - **looks already fixed**: nothing — it's theirs to close; it leaves the batch; + - then `lambda pod tickets $POD_ID <the tickets still being planned, in order>`. +2. (`lambda pod step epic-red-flags start --pass <n> --ticket <id>` … `done --pass <n> --found <n> --ticket <id>`) Red flags on each remaining ticket, as for a lone ticket (up to 2 passes each, fresh each pass). No checkpoint — end your turn. + +## 2 — Epic review + +1. Run the `/red-flags` process on the epic (or the lone ticket), **up to 2 passes** (see "Repeated red-flags passes" below). Each pass: `lambda pod step epic-red-flags start --pass <n>` … `lambda pod step epic-red-flags done --pass <n> --found <fixes + questions>`. Engineering findings: fix the ticket and log each with `lambda pod decide --autonomous`. Judgement calls: questions (draft → cold read → commit), never a chat list. +2. `idea` track: (`lambda pod step cp2-post start` / `lambda pod step cp2-post done`) post CP2 once none of this phase's questions are open. **`existing` track: no checkpoint** — end your turn when the passes are done, even if a question is still open (you apply its answer when it comes; the UX review waits for it). + +### Repeated red-flags passes + +Phases 2 and 3 run `/red-flags` more than once, because fixing one pass's findings changes the ticket and can expose new ones. + +- **Every pass starts fresh:** `lambda tickets pull` again first, so it reads the ticket as the previous pass left it, and re-check against the code; don't reuse the previous pass's notes. +- **Short-circuit:** if a pass finds **nothing** (no fix and no question), stop there; don't run the remaining passes. +- A pass that only produced questions still counts as finding something: run the next pass, but don't re-raise an open question. +- Answers to this phase's questions can change the ticket. If one does, that change gets checked on the next pass; if every pass has already run, check just that change once more before you post the checkpoint. + +## research — Research and the split proposal (phase 3) + +Everything here is grounded in the codebase before a single ticket is written, so {owner} approves a split that will hold. + +**Read only.** Planning runs no tests, E2E, dev servers or builds. The only things it starts are `lambda shot` (the project's photo hook) and `lambda design render`, `lambda design walk` and `lambda design snapshot` (short headless renders of designs). `lambda shot search`, `lambda shot check` and the other file-only `lambda design` commands are not runs. `lambda design system build --from <base>` (a local bundle of the design system committed on main, built outside this worktree, for designs) is also allowed. So is `lambda design system prepare`, which does the same and installs it on a canvas. Planning never writes the design system's files and never creates the ticket that makes it. + +0. (`lambda pod step verify-premise start` / `lambda pod step verify-premise done --found <claims that were wrong>`) **Check it still holds.** Check every claim the epic or ticket makes about today's behaviour against the code on the main branch (`lambda pod sync` brought it up to date) — `file:line` references, mechanisms, "the only caller", what the user sees. Tickets filed by an agent go stale: the code moves on, and some are already fixed. If the core claim no longer holds (already fixed, or wrong), ask {owner} one question — options **close it** / **rescope it to what is still true** — and stop until it's answered. Smaller drift: correct it in the ticket as you go. A ticket whose cause is unproven by design: check what you can by reading, but don't present the mechanism as fact anywhere (see `tickets`). +1. (`lambda pod step research start` / `lambda pod step research done`) **Deep research.** Use Explore subagents (in parallel) to map every area the work touches: the files and functions it changes, the patterns already used for the same kind of thing, the data and permissions involved, and **the E2E specs that already cover each area**. Keep `file:line` references — every proposed ticket cites them. +2. (`lambda pod step duplicate-check start` / `lambda pod step duplicate-check done --found <overlaps>`) **Duplicate check.** Search the tracker (`lambda tickets list`, `lambda tickets get`) for anything this overlaps or should fold in. Each overlap becomes an `overlaps` entry on the ticket it touches (`folds-in` — this ticket does that work; `overlaps` — both touch it, coordinate; `related` — worth knowing). +3. **Is there something to split?** + - `idea` track, or an existing epic **with no children**: yes — continue. + - A **lone ticket**: yes — continue, **whatever its size**. Don't decide for them that it fits one session: draft the best split along the work's natural seams (each ticket shippable on its own, in build order, with its `dependsOn`), and CP3 asks them to accept it or keep one ticket. Only when there is no seam at all — one change in one place — skip it as below with `--note "lone ticket, nothing to split: <why>"`. + - An existing epic **with children**, or a **batch**: no. `lambda pod step split-proposal skip --note "<has N children | batch>"`, then skip `proposal-red-flags`, `proposal-cold-read` and `proposal-post` the same way, and end your turn — the research feeds the `tickets` phase. +4. (`lambda pod step split-proposal start` / `lambda pod step split-proposal done --found <tickets>`) **Draft the split proposal** — the `/split-ticket` sizing rules (never split work that fits one dev session), numbered in build order (they are built one at a time, in that order). **No ticket only changes tests**: fold that work into the ticket whose code it tests. Per ticket: + - `number` (`"1"`, `"2"`…), `title` (plain), `what` (what changes, in plain words a user would follow — read aloud), + - `files` — where it lands, from the research (`path:line`, shown, never read), + - `dependsOn` — earlier build numbers it needs. The tickets form a tree, not a line: depend only on what is actually needed. Every dependency has a lower number than the ticket, and a reason in `dependsWhy` (`{"1": "needs the sign-up form"}`), + - `e2e` — `{"kind": "new-file" | "new-tests" | "assertions" | "none", "spec": "<spec path>", "why": "…"}`. **Prefer extending an existing spec**; a `new-file` needs `why` an existing spec can't hold it; `none` needs `why` nothing user-facing needs a test, + - `overlaps` (from step 2; `[]` if none). +5. (`lambda pod step proposal-red-flags start --pass <n>` … `done --pass <n> --found <n>`, up to 2 passes) **Red flags on the proposal** — the `/red-flags` checks applied to the proposed tickets themselves (wrong premise, missing migration, a test that can't fail, contradictions between tickets). Fix the proposal; judgement calls become questions. +6. (`lambda pod step proposal-cold-read start` / `done --found <rewritten>`) **Cold read**: one fresh subagent gets ONLY each ticket's `title` and `what` and returns `{"restatement", "unclear": []}`; rewrite anything unclear. +7. (`lambda pod step proposal-post start` / `lambda pod step proposal-post done`) Write `proposal.json` in the pod folder as `{"tickets": [{…ticket, "coldRead"}]}` and post it: `lambda pod checkpoint post --cp 3 --summary "<1–2 sentences: N tickets, the shape of the split>" --proposal-file <that file>` (plus `--decisions-cold-read` when it has decisions). Only once none of this phase's questions are open. +8. {owner} changes it like any checkpoint ("merge 3 and 4", "no new spec for 6"): edit `proposal.json` in place — the card updates itself, no re-post — and reply saying what changed. They close it when they're happy. +9. **A lone ticket's CP3 closes one of two ways**, shown on the `split:` line of `lambda pod status`: **accepted** — the ticket becomes the epic and the proposal its children; **one-ticket** — the proposal is dropped and the ticket is brought up to the template as a lone ticket. + +## tickets — Write the tickets (phase 3, after the split proposal) + +1. (`lambda pod step write-tickets start` … `lambda pod step write-tickets done --found <tickets created, adopted or changed>`) + - **After a split proposal:** create each approved ticket (`lambda tickets create --title "<number> - <title>" --parent <epic> --status planning --description-stdin < ticket.md`), in build order (`lambda tickets reorder <epic> --order …`), using the full ticket template (acceptance criteria, test plan, security, scope boundaries). Its test plan follows the approved `e2e` line exactly. + - **An existing epic with children: adopt them, don't re-split.** Put them in build order and number their titles `1 - …`, `2 - …` (`lambda tickets update <id> --title …`, then `lambda tickets reorder`); bring each up to the full template; add a child only for a real gap; split one only if it genuinely can't fit one dev session; merge two only if they only make sense together — the one merged away is deleted. Log every addition, split or merge as an autonomous decision (renumbering is protocol). Never delete a child on your own for any other reason — move anything obsolete to Out of scope and say so. + - **A batch:** every ticket is a lone ticket (below), each in a subagent. Report each with `--ticket <id>`. + - **A lone ticket whose split {owner} accepted** (`split: … accepted`): the ticket is now the epic. Rewrite its description as the epic (keep its labels), then write the proposal's tickets as its children, exactly as for a new epic. + - **A lone ticket** (no split proposed, or `split: … one ticket`): bring it up to the full template in place. Keep its labels. **One ticket stays one ticket:** an agent-filed ticket often tacks on extras ("also add a sweep for…") — keep the core fix in scope and ask {owner} whether each add-on belongs; never fold it in silently. + - **Prove it first** — the ticket's implementation opens with proof, written into its plan as **step 1** (planning runs no tests: see the rule in research): + - **A bug:** step 1 is a regression test that **fails on the main branch** for the reason the ticket gives — only then the fix, which turns it green. Name the test file and what it asserts. + - **A ticket whose cause is not proven:** step 1 is the investigation — reproduce it, find the real cause, and record it on the ticket. Write the suspected mechanism as a **hypothesis**, never as fact, and say what happens if it does **not** reproduce: report back and stop, rather than fixing a guess. + - Every ticket notes its overlaps under Scope boundaries. +2. (`lambda pod step ux-sketch start` / `done`, `--progress` per ticket) Run `/ux-sketch <epic or ticket>` — every ticket gets a `## UX` section at the top. (A project can switch this step off; `lambda planning show` says so.) +3. **Batch only** (`lambda pod step cross-ticket start` / `done --found <links made>`): one pass across every ticket — **shared causes** (one ticket fixes it; the others say "fixed by … — verify only"), **shared files** (a suggested build order and a conflict note on each ticket that touches the same code), **shared E2E specs** (extend one spec, not two). Each ticket gets a `## Across the batch` section — "Nothing shared." when that's the truth. +4. (`lambda pod step tickets-red-flags start --pass <n>` … `done --pass <n> --found <n>`) Run the `/red-flags` process across **all** tickets together (contradictions between them count), **up to 3 passes**, same rules as "Repeated red-flags passes". +5. No checkpoint — end your turn. **Keep a list of everything that moved from the approved proposal** (a ticket merged or reshaped, an E2E line changed): it opens the UX review. + +## ux — UX review (phase 3, before Fill) + +{owner} reviews every screen the work changes — before and after, and the journey that brings someone to it — **before** the test plans are written, since Fill writes them against this UX. The pod is still in phase 3. + +**Nothing a user sees changes** (a backend fix, a test fixture, tooling)? Then there is no UX review: `lambda pod step ux-surfaces skip --note "<why: no screen, email or notice changes>"`, the same for `ux-cold-read` and `cp4-post`, and end your turn — the Planning manager goes straight to Fill. Don't post an empty CP4. + +**Can this computer use Claude Design? Check first.** Run `lambda design capability`. When it prints `unknown`, or the check is more than 7 days old (`checkedAt` in the `design` block of `~/.lambda/config.json`), check again now: + +1. `ToolSearch` with `select:Artifact`. If the Artifact tool doesn't load, it isn't available. +2. `Artifact` `list` with `scope: "types"`, and look for a **Design** type (and a **Design System** type, if the same listing shows one). +3. Record what you found: `lambda design capability --set available --type-url <the Design type's URL> [--system-type-url <the Design System type's URL>]`, or `lambda design capability --set unavailable --reason "<what failed, in a sentence>"`. + +**One exception:** a session started headless (`claude -p`; `ps -o command= -p $PPID` shows `-p` or `--print`) is never offered the Artifact tool, whatever the account can do. Then don't record anything: this review is ASCII (below), and the CP4 summary says "This pod's Claude session can't use Claude Design (it was started headless), so this review is ASCII." + +**Render mode — read it once, now.** Run `lambda pod render-mode $POD_ID` at the start of this phase and keep the answer for the whole phase ({owner} can flip it in the workflow panel; a flip during or after this phase changes only the **next** UX review): + +- **`full`**: every changed or new screen gets a brief, before pictures (through `lambda shot`, the project's photo hook), a Claude Design prototype (`/design-screen`) and its pictures; the tickets get `## Design`. If `lambda design capability` says `unavailable`, the review runs as **`ascii`** instead, and the CP4 summary says "Claude Design isn't available with this Claude account, so this review is ASCII." +- **`ascii`**: **skip** the briefs, `lambda shot`, `/design-screen`, `lambda tickets scenarios push` and `## Design` entirely. Screens get no `pictures` and no `prototypeUrl`, tickets get no `## Design` (so the build's visual check skips them), and the CP4 `--summary` says **"ASCII mode"**. Everything else below is the same. + +**The design system, in `full` mode.** Designs use the design system committed on the main branch at this pod's base. Each `/design-screen` puts it on its canvas with `lambda design system prepare`, which builds it outside this working folder (the runner's build checkout has the project's packages; this one has none, so never build here) and installs it. It prints at most one summary line; put it, word for word, in the CP4 summary (once, however many screens printed it): + +- "<ticket id> is making the design system; these designs use Claude Design's default look." — whenever the project has an open ticket "Make the design system from the app". A setup ticket that hasn't shipped isn't used. +- "The design system on main couldn't be built: <first line of the error>." — the ux phase carries on: designs use Claude Design's default look. +- "No design system: designs use Claude Design's default look." — there is no design system on main and no open setup ticket. +- "No design system on main: designs use Claude Design's default look (couldn't read the tickets: <first line of the error>)." — there is no design system on main, and Lambda couldn't read the project's tickets to look for a setup ticket. + +Planning never writes the design system's files and never creates the setup ticket: a project without a design system is designed in Claude Design's own look. + +1. (`lambda pod step ux-surfaces start` … `lambda pod step ux-surfaces done --found <screens>` after step 2) List every **surface** the tickets change: screens, dialogs, emails, notices. Start from each ticket's `## UX` section; check the current UI code for the "before" (what exists today, not what a ticket assumes). One entry per surface, even when several tickets touch it. +2. For each surface write: + - `name` — plain, as a user would call it ("Volunteer sign-up form"). + - `change` — `changed`, `new` or `removed`. + - `before` / `after` — ASCII sketches (same rules as `/ux-sketch`: mark `← new` / `← changed (was …)`, ≤ 44 chars wide for mobile-first screens). No `before` for a new screen, no `after` for a removed one. + - `changes` — what someone using it will notice, in plain sentences (read aloud). + - `journey` — **why this screen exists, from the person's side**, so {owner} sees it in context: `who` is on this screen, `before` (how they get here), `here` (what they do on this screen), `after` (what happens next, for them and for anyone else it reaches). If the same screen serves two different people, pick the main one and mention the other in `after`. + - `tickets` — the build numbers that build it (`["2", "5"]`). + - `decisions` — the ids of this checkpoint's decisions that this screen already shows. Link a decision only when the screen's `changes` already says what it decided. + **Group the screens into flows.** A flow is one journey someone goes through, start to finish — its screens in the order the person meets them. Each flow has a plain `name` and a `summary`: the whole journey in 1–2 sentences, with the person. Every screen belongs to exactly one flow. +3. (`lambda pod step ux-cold-read start` / `lambda pod step ux-cold-read done --found <screens rewritten>`) **Cold read** every screen: one fresh subagent gets ONLY each screen's `name`, `changes` and `journey` (no ASCII, no epic) and returns `{"name", "restatement": "<what a user will notice>", "unclear": ["…"]}` per screen. Put it in each screen's `coldRead`; rewrite and re-read anything unclear. + 3b. **Full mode only — design each screen, before CP4.** A **removed** screen gets no brief, no pictures and no `## Design` entry: its ASCII shows. For every **changed** or **new** screen, with `<slug>` its name in kebab case (`volunteer-sign-up-form`): + - Write `$LAMBDA_PODS_ROOT/$POD_ID/designs/<slug>/brief.json` — `{"name", "change": "changed" | "new", "changes", "situations": [{"slug", "title", "scenarioId"?}], "path": [{"step", "action"}]}` (add `"viewport": "phone"` for a phone screen). The **first situation is the prototype's opening state**, and every situation is a state in its state switch. `path` is the screen's main path as plain step names, each action exactly `click <target>` (a slug `/design-screen` puts on that control as `data-target`) or `state <situation slug>`. `scenarioId` may be left out; `/design-screen` fills it in. + - Run `/design-screen <slug>`. It takes the before pictures through the project's photo hook (`lambda shot`), designs and grades the prototype, and photographs the stills. A screen whose run printed `DESIGN FAILED <slug>: …` gets **no `pictures` and no `## Design` entry** (its ASCII still shows) — keep a list of them for the CP4 summary. + - Read `brief.json` back afterwards for the scenario ids `/design-screen` wrote into it. + - **Fill the screen's `pictures`** from `designs/<slug>/`, one entry per situation in `brief.json`, in brief order: `situation` = its `title`, `scenarioId` = its `scenarioId` (when it has one), `before` = `designs/<slug>/<situation>.before.png` (**changed** screens with a photo hook only), `after` = `designs/<slug>/<situation>.annotated.png` (a still photographed from the approved prototype), `legend` = the contents of `<situation>.legend.json`; and the screen's `prototypeUrl` = the contents of `designs/<slug>/prototype.url`. Paths are pod-relative. **When the project has no photo hook** (`commands.shot` absent from `lambda.json`), set the screen's `noPhotoHook: true`: its card then says why there's no "before". **The pictures are written before `ux-review.json`**: the card watches only the JSON, and `checkpoint post` refuses a picture whose file isn't there. + - **Show a prototype in the chat** whenever you tell {owner} about one (a design question, a re-design, a freeform "show me a prototype"): put the line `lambda design snapshot` printed (`[Prototype: <slug> v<n>]`) **on its own line** in your `lambda pod reply`. The inbox turns it into a card: the board's picture, every board side by side, and **Try it**, which opens the working prototype over the inbox. A link to claude.ai alone can't be tried in the inbox. +4. (`lambda pod step cp4-post start` / `lambda pod step cp4-post done`) Write the result to `ux-review.json` in the pod folder as `{"flows": [{"name", "summary", "screens": [{…screen, "coldRead"}]}]}` (screens are numbered 1…N straight through, in flow order) and post CP4: `lambda pod checkpoint post --cp 4 --summary "<1–2 sentences: how many screens, what kind of changes>" --screens-file <that file>`. In `ascii` mode the summary says "ASCII mode" (with the line above that says why, when Claude Design wasn't available or the session was headless); in `full` mode it names every screen whose design failed (`DESIGN FAILED`), which shows as ASCII only, and carries the design system summary line `lambda design system prepare` printed (above). Also: + + - `--understood "<2–3 plain sentences>"` — what you understood {owner} wants, from intent.md. It is read first: on the `existing` track this is where they catch a misread epic, since there was no CP1. + - `--changes-file <file>` — **after a split proposal**: `{"changes": [{"kind": "screen|ticket|e2e|engineering", "text": "<what it is now>", "before": "<what it was when they approved it>", "why": "<which decision, review or request changed it>"}]}`, what moved since they approved the split; `{"changes": []}` if nothing did. Each change is read on its own, so it must stand alone. + 4b. **Full mode only — each ticket carries its design.** Every ticket that builds a screen with pictures gets a `## Design` section: + - **When more than one ticket builds a screen**, only the **last** ticket in build order that builds it carries that screen's pictures under `## Design`. Each earlier ticket that builds it gets the same blocks under `## Design reference (checked when ticket <N> is built)` (N = that last ticket's build number), which the build's visual check does not gate on. + - **Attach each picture first.** Check that every `designs/<slug>/<situation>.annotated.png` the screen's pictures use exists; if one is missing, write no `## Design` for that screen and say so in your next reply. Then, per picture: `lambda tickets attach <id> $LAMBDA_PODS_ROOT/$POD_ID/designs/<slug>/<situation>.annotated.png --name <slug>-<situation>.annotated.png --alt "<screen> — <situation>"` (the `--name` keeps two screens' `default` pictures apart). It prints the picture's ref (a link next to the ticket file on a markdown project, an uploaded file on a tracker). + - Per picture, one block (no main/other mark — the build's visual check grades all of a ticket's pictures as one set), with a blank line between each part. Only a `###` heading containing `—` is a picture block, and `Scenario:` appears only when the situation has one: + + ```markdown + ### <screen> — <situation> + + ![<screen> — <situation>](<the ref attach printed>) + + Scenario: <scenario id> + + 1. <legend label 1> + 2. <legend label 2> + + Prototype: <prototype url> + ``` + + - Write the section into the ticket: `lambda tickets pull <id>` first (so you start from the ticket as it is now), add the section to the file it names, and `lambda tickets push <id>`. Then attach the scenarios its pictures use: `lambda tickets scenarios push <id> <each $LAMBDA_PODS_ROOT/_scenarios/<scenario id>.scenario.<ext> file>` (a scenario already in the project's tracked scenarios needs no push). It adds their `scenario:` lines under `### Screenshot scenarios` at the end of `## Design`. No photo hook: no scenarios, and nothing to push. + +5. If {owner} changes a screen ("screen 2: the Save button should stay enabled"), update the ticket(s) that build it — their `## UX` section and acceptance criteria — and reply saying which. In `full` mode, a changed screen that **already has pictures** is re-designed: update its `brief.json`, run `/design-screen <slug>` again (it moves the old rounds aside itself), refill its `pictures` and `prototypeUrl` — pictures first, then the edited `ux-review.json` — and re-attach its `## Design` and scenarios as in 4b. A screen without pictures (`ascii` mode, or its design failed) is not re-designed. + +## 4 — Fill + +1. (`lambda pod step e2e-plans start` … `lambda pod step e2e-plans done`, `--progress` per ticket) For each ticket: write its E2E plan. Follow the E2E structure {owner} approved in the split proposal — a ticket marked `new-tests` adds tests to that spec, `assertions` only changes assertions. **Only a NEW spec file the proposal didn't approve** needs their OK (one question per file: why an existing spec can't hold it). Without a proposal (an existing epic with children, a lone ticket): every new spec file needs their OK. +2. (`lambda pod step review-tickets start` … `lambda pod step review-tickets done --found <fixes>`, `--progress` per ticket) Run `/review-ticket <ticket> --fix` on each ticket (fix mode: applies its own suggestions; never splits). +3. No checkpoint. When this phase's questions are all answered, tell the Planning manager: `lambda pod send $POD_ID manager "[dev] phase 4 questions answered"`, then `lambda pod step fill-answered done`. + +## 5 — Reconcile + +1. Run `/reconcile-tickets <epic>` (fresh-context consistency loop, up to 3 passes; a lone ticket gets one pass against the code). Each pass: `lambda pod step reconcile-tickets start --pass <n>` … `lambda pod step reconcile-tickets done --pass <n> --found <fixes + questions>`. +2. (`lambda pod step ready-check start` / `lambda pod step ready-check done --found <tickets fixed>`, `--progress` per ticket) **Ready to build**: check each ticket the way the build will — run `/review-ticket <ticket>` (grading, not fix mode) and fix until it grades **A**; every acceptance criterion must be testable and every test-plan item able to fail. A ticket that fails here costs minutes; in the build it costs a dev cycle. +3. (`lambda pod step cp5-post start` / `lambda pod step cp5-post done`) Post CP5 once none of this phase's questions are open. **List every dependency link** {owner} approved — and every line of `links.md` in the pod folder (the links and stale-link removals reconcile proposed), word for word — in the CP5 summary, so they see them before Finish. Closing CP5 is their yes to them; Finish writes them. If there was a UX review (CP4), add `--changes-file`: what changed since the UX review — screens, tickets, E2E beyond the approved structure (kind `engineering` for fixes {owner} wouldn't see) — same format as CP4's, `{"changes": []}` if nothing. + +## finish + +Finishing makes the plan buildable: the tickets move to `ready`, and the links {owner} saw at CP5 are written. + +1. (`lambda pod step finish-ready start` / `lambda pod step finish-ready done --note "<n tickets ready>"`) Move every ticket and the epic (a batch has no epic — every ticket on its `tickets:` line) to `ready`: `lambda tickets update <id> --status ready`. Leave their labels alone. If one refuses (it is `done`, `canceled` or deleted), **stop and ask {owner}** — a planned ticket must be open, and something closed or deleted it. +2. (`lambda pod step finish-links start` / `lambda pod step finish-links done --note "<n links written, m removed>"`) **Write the dependency links** {owner} saw at CP5 — every line of `links.md` in the pod folder. Get CP5's qid from `lambda pod question list` (the CP5 checkpoint). For each `B blocked by A — <reason>` line: `lambda tickets block B --by A --checkpoint <CP5's qid>`; for each `remove: B blocked by A — <why stale>` line: `lambda tickets unblock B --by A --checkpoint <CP5's qid>`. The `--checkpoint` gate refuses until {owner} has closed that checkpoint. If any line refuses, **stop and ask {owner}** — never retry with a different qid. No `links.md` (or an empty one) → `done --note "0 links written, 0 removed"`. +3. End your turn with a one-line list of the tickets you moved to `ready` and the links you wrote. diff --git a/instructions/planning/reconcile-tickets.md b/instructions/planning/reconcile-tickets.md new file mode 100644 index 0000000..ea49c03 --- /dev/null +++ b/instructions/planning/reconcile-tickets.md @@ -0,0 +1,45 @@ +--- +description: Fresh-context consistency loop across an epic and all its children (plus a pod's intent and decision logs). Fixes engineering drift; raises judgement calls. Repeats until a pass changes nothing (max 3). +argument-hint: <epic id> +--- + +# /reconcile-tickets $ARGUMENTS + +Later decisions make earlier tickets wrong. This loop finds and fixes that drift across the whole epic. + +## Each pass + +1. `lambda tickets pull` the epic and every child. +2. **Spawn a fresh-context subagent** (Agent tool, general-purpose). You are too close to these tickets to see their contradictions. Give it the paths of: + + - `.lambda/pulled/<epic>/description.md` and every child's (on a markdown project, the ticket files themselves) + - in a pod: the pod folder's `intent.md`, `human-decisions.jsonl`, `autonomous-decisions.jsonl` + + Ask it for a list of findings, each with the ticket(s), the exact conflicting text, and the fix. It checks: + + - a ticket contradicts a recorded decision ({owner}'s words in `human-decisions.jsonl` win) + - tickets contradict each other: names, field and data shapes, UX between screens, shared test ids and selectors, shared test fixtures + - every acceptance criterion and every E2E test is owned by **exactly one** child + - dependency order: no child depends on a later-numbered one; each child's `## Dependencies` lines match reality + - epic scope not covered by any child; a child doing work the epic does not ask for + - **overlap check** — a "blocked by" link is what keeps two tickets from being built at once. Two children need a link (the later-numbered one blocked by the earlier) when one **needs the other's code**, or they share a **migration** (or both add one) or a **schema** file, or change the same **permissions** or the same user-facing **wording** — places where a clean text merge can still be silently wrong — or they share **3 or more source files** (not counting tests, E2E specs, docs and append-only registries). Lighter overlap needs no link: it is resolved when the work is merged. Report each missing link with the reason. + - **existing links** — run `lambda tickets get` on every child and read its blockers (`lambda tickets list --json` shows `blockedBy`). Flag a link whose blocker was merged away, canceled or deleted, or is no longer needed (the overlap that justified it is gone), with why it is stale. + +3. Apply the findings: + - **Engineering** (a rename, a shape, a selector, an ownership move): fix the tickets directly and push. In a pod, log each with `lambda pod decide --autonomous "<fix>" --why "<conflict>" --intent-ref …`. + - **Links are proposed, never written here** — adding or removing one needs {owner}'s yes: + - in a pod, write each change as one line of `links.md` in the pod folder: `B blocked by A — <reason>` to add, `remove: B blocked by A — <why stale>` to lift. Update the child's `## Dependencies` section to match and push. CP5 lists every line, and Finish writes them once {owner} has closed it (`lambda tickets block … --checkpoint <CP5's qid>`); + - outside a pod, list them in the report under "Needs your call". + - **Judgement** (what users see, a scope trade-off, two decisions that genuinely conflict): in a pod, a question (draft → cold read → commit); outside a pod, a "Needs your call" item. +4. If the pass changed **nothing**, stop: the epic is consistent. (A pass that only re-found links already in `links.md` changed nothing.) + +## The cap + +Run at most **3 passes**. If pass 3 still changes something, stop and raise **one** question to {owner} listing what keeps changing and why — oscillation means two requirements fight, which is their call. + +## Report + +``` +Passes: N · Fixed: K · Questions: Q · Links proposed: L +- <one line per fix> +``` diff --git a/instructions/planning/red-flags.md b/instructions/planning/red-flags.md new file mode 100644 index 0000000..8ea854b --- /dev/null +++ b/instructions/planning/red-flags.md @@ -0,0 +1,137 @@ +--- +name: red-flags +description: Scan a ticket's plan for red flags. Fixes engineering problems in the ticket itself; brings only judgement calls to the owner. +--- + +# /red-flags - Is This Plan Right? + +Scans a ticket's **plan** for problems that would bite during implementation or after shipping. You fix what you can in the ticket yourself, and bring {owner} **only** the judgement calls. + +**How this differs from `/review-ticket`:** `/review-ticket` asks whether the ticket is **well written** (vague terms, missing sections, selectors that exist). This command asks whether the plan is **right**. A ticket can grade A on `/review-ticket` and still be built on a wrong premise. Do not re-grade form; leave that to `/review-ticket`. + +**This command never touches code and never commits.** Its only output is edits to the ticket and a short report. + +## Usage + +``` +/red-flags PAWNEE-12 +``` + +**Argument:** `$ARGUMENTS`, the ticket id. If it is empty, ask for it. + +--- + +## Step 1: Pull the Ticket + +```bash +lambda tickets pull <TICKET> +``` + +Then read the description it wrote (`.lambda/pulled/<TICKET>/description.md`, or the ticket file itself on a markdown project) and any images beside it. + +## Step 2: Has Work Started? + +Work has started if **either** is true: + +- the ticket's status is `building` or later, or +- a branch named for the ticket has commits ahead of the main branch. + +This decides **how** you edit the ticket in Step 5: + +- **Still planning:** edit the body directly. +- **Work started:** do not rewrite the body. Add or extend a `## Addendum` section at the end. `/check-ticket` treats the body as the contract and the Addendum as the record of decisions made after work began. Rewriting the body mid-implementation means the audit checks the code against a plan the developer never saw. + +## Step 3: Hunt for Red Flags + +Ground every check in the **actual code** and in the project's own docs. Use Explore agents for broad sweeps. Do not trust the ticket's description of the current code; verify it. + +| Area | What to look for | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Premise** | Does the ticket describe the current code correctly? A wrong "today it does X" sends the whole plan in the wrong direction. | +| **Approach** | Will the planned approach work against the real code? Is there an existing helper or pattern it ignores or duplicates? | +| **Contradictions** | Does it conflict with existing behaviour, the project's docs, another open ticket, or a rule in the project's agent instructions? | +| **Reaching users** | Will it actually reach users when shipped? A missing data migration for permissions or a schema change, a feature flag left off, a new query with no index, a frontend live while the backend is inert. | +| **Acceptance** | Can each acceptance criterion actually be met? Do any two contradict each other? | +| **Test plan** | Can each planned test **fail**? A test that passes with the fix reverted proves nothing. Does the test exercise the real producer, not a hand-built fixture? | +| **Size** | Is the ticket bigger than it reads (hidden migrations, many call sites, a second surface)? | + +### The evidence bar + +A finding must have **both**: + +1. **Proof:** a `file:line`, a doc, or a ticket id showing the problem is real. +2. **A concrete failure:** "a user does X, and Y happens" (or the engineering equivalent: "the migration never runs in production, so existing records keep the old shape"). + +If you cannot write both, **it is not a finding. Drop it.** Do not report suspicions, style preferences, or "might be worth considering". **Finding nothing is a valid, successful result.** Never invent findings to justify the run. + +### The severity bar (stop rule) + +A finding that clears the evidence bar is acted on **only if discovering it later would cost more than a build cycle**. That means it is one of: + +- **A security or privacy hole:** someone gains access, authority or data they shouldn't have. +- **Product behaviour:** what a user sees or experiences would be wrong, or would contradict a decision {owner} made. +- **A wrong premise or an unbuildable plan:** the ticket's approach can't work against the real code, contradicts another ticket badly enough to block, or will not reach users. + +**Everything else is below the bar. Drop it, and don't edit the ticket for it.** That covers stale line numbers, test order or fixture placement, duplicate imports, test-id naming, wording, citation drift, and a test that needs adjusting to compile. The implementer meets these against real code within minutes, and `/check-ticket` and `/code-review` catch them cheaply. Each edit for a below-bar item also risks a new inconsistency, which is why repeated passes never converge. + +**Repeat passes:** when a ticket has already had a `/red-flags` pass, expect few or no findings above the bar. A pass with only below-bar findings reports `No red flags.` + +## Step 4: Sort Each Finding + +**Fix it yourself (default).** Engineering calls are yours: the approach, a missing migration, a test that cannot fail, wrong file paths, a premise the code settles, a contradiction with one clearly correct side. Pick the best option and fix the ticket. Being risky, big, or uncertain does **not** make something a judgement call; decide it. + +**Bring it to {owner} ONLY if it is one of:** + +- **A product or business decision:** what users see or experience, pricing, scope trade-offs between features. +- **An authorization only {owner} can give:** direct production changes, money movement, outward-facing actions. +- **A genuine fork with no deciding evidence:** two defensible options with materially different outcomes, where neither the code, the docs nor the ticket settles it. + +Escalating something you could have decided is a failure of this command, not caution. + +## Step 5: Fix the Ticket + +Apply your fixes to the pulled description (body or `## Addendum` per Step 2), then send them back: + +```bash +lambda tickets push <TICKET> +``` + +Keep edits surgical: change what the finding requires, not the surrounding prose. + +## In a planning pod (`$POD_ID` is set) + +Instead of a chat report: + +- **Each "Needs your call" item becomes a queued question**: `lambda pod question add --ticket <TICKET> --from-work "checking <what the ticket is about> for red flags"`, fill every section, cold read, commit (see the planning dev instructions). Map the report fields: _What's wrong_ → context + problem, _Options_ → options, _Rec_ → recommendation, _Why you_ → the end of problem. If two items hinge on each other, queue only the hinge now. +- **Each "Changed in the ticket" fix is logged** with `lambda pod decide --autonomous "<fix>" --why "<the finding>" --intent-ref …`, so it appears in the checkpoint's list. +- End with a one-line summary for the Planning manager (`N fixed, M questions queued`). No chat list of decisions. + +## Step 6: Report + +Brief and plain. Assume {owner} is reading this cold: give enough context to decide, no more. + +```markdown +# Red Flags: <TICKET> + +## Needs your call + +### ⚑ <plain-English title> + +**What's wrong:** 1–2 sentences, with file:line. +**Options:** A) … B) … +**Rec:** A, because … +**Why you:** <one sentence: why this could not be settled without you> + +## Changed in the ticket + +- <one line per edit: what changed and why> + +## Checked + +<one line: what was examined> +``` + +- Omit an empty section. With nothing found and nothing changed, the whole report is `No red flags.` plus the **Checked** line. +- Below-bar findings are not listed. At most, add one line after **Checked** with a count: `Below the bar (left for the build): N`. +- **Needs your call** comes first, because it is the only part that blocks anything. +- Every escalation **must** have its **Why you** line. If you cannot write it, decide the item yourself and move it to **Changed in the ticket**. diff --git a/instructions/planning/review-ticket.md b/instructions/planning/review-ticket.md new file mode 100644 index 0000000..5daa7c2 --- /dev/null +++ b/instructions/planning/review-ticket.md @@ -0,0 +1,252 @@ +--- +name: review-ticket +description: Grade a ticket for agent-developer readiness (A–F across six dimensions), with actionable fixes. `--fix` applies them. +--- + +# Review Ticket for Agent-Developer Readiness + +Evaluates a ticket for readiness to be built by an agent. Outputs letter grades (A–F) across 6 dimensions with actionable improvement suggestions. + +**Philosophy:** vague specs are the most common cause of agent failures. An agent will not ask; it will guess. This rubric enforces the specificity an agent needs to succeed. + +## Usage + +``` +/review-ticket PAWNEE-123 +/review-ticket PAWNEE-123 --fix +``` + +**Argument:** `$ARGUMENTS` — the ticket id, optionally followed by `--fix`. Wherever this file says `$ARGUMENTS` in a command, use just the ticket id. + +### Fix mode (`--fix`) + +Without `--fix` this command only reports. With `--fix` it **applies its own suggestions** to the ticket and pushes, the way `/red-flags` does: + +- Before work has started (status before `building`, and no commits on the ticket's branch), edit the body directly. After that, add to a `## Addendum` section instead — `/check-ticket` treats the body as the contract. +- Fix everything you can settle from the code, the project's docs and the ticket itself: missing selectors, vague assertions, missing file paths, missing scope boundaries, missing security notes. +- **Never split in fix mode.** Do not propose, create or recommend splitting the ticket. Scope findings become edits (trim an "and also", move an unrelated item to Out of scope). +- A suggestion that needs {owner}'s judgement (what users see, pricing, scope trade-offs) is not edited in. In a planning pod (`$POD_ID` is set) it becomes a question (`lambda pod question add --from-work "reviewing <what the ticket is about>"` → cold read → commit; see the planning dev instructions). Outside a pod, list it under **Needs your call** in the report. +- Re-grade after the edits. The report then shows the grades **after** fixing, plus a short **Changed in the ticket** list. +- Send it back with `lambda tickets push <TICKET>`. + +## Instructions + +### Step 1: Fetch the ticket + +```bash +lambda tickets pull $ARGUMENTS +``` + +### Step 2: Read the ticket + +Read the description it wrote (`.lambda/pulled/$ARGUMENTS/description.md`, or the ticket file on a markdown project) and any images beside it. + +### Step 3: Verify the E2E test specs against the codebase + +**This step is critical and must not be skipped.** Before grading, verify that any E2E test snippets in the ticket are actually compatible with the codebase: + +1. **Referenced test ids exist** — search the UI code for each test id the snippet uses. If one doesn't exist, check the ticket lists it in a "New Selectors" table. +2. **Helpers exist and are used the same way** — check every imported test helper exists, and grep for existing usage to confirm the calling pattern matches. +3. **Navigation routes are real** — the URLs the test visits route to real pages. +4. **Text and role selectors match** — if the snippet selects by visible text, that text actually appears in the component. +5. **Test data is consistent** — users, fixtures and any shared test registries the project uses (reserved names, time slots, seed ids) match what exists, and the ticket names any new entry it needs. + +Use Explore agents or grep. Record any discrepancies — they become issues in the Test Plan grade. + +### Step 4: Analyse each dimension + +Evaluate the ticket against each rubric below. Be strict — a B should feel earned, not given. + +--- + +## Dimension 1 — Clarity & Specificity + +| Grade | Criteria | +| ----- | -------------------------------------------------------------------------- | +| A | Zero vague terms; every requirement has a specific, measurable description | +| B | 1 minor ambiguity that can be reasonably inferred from context | +| C | 2–3 vague terms but core functionality is determinable | +| D | Multiple ambiguities; an agent would need to make assumptions | +| F | Dominated by vague terms; an agent would be guessing at requirements | + +**Capped at D:** "improve" / "enhance" / "better" without a before/after; "clean up" / "refactor" without scope and success criteria; "optimize" / "faster" without targets; "handle errors" without listing the errors; "similar to X" without the differences; "should work" without defining "work"; "user-friendly" / "intuitive" without specific behaviours. + +**Capped at C:** undefined acronyms or domain terms; pronouns without clear antecedents ("it should update" — what is "it"?). + +--- + +## Dimension 2 — Completeness + +| Grade | Criteria | +| ----- | ----------------------------------------------------------------------- | +| A | All 6 checklist items present; nothing left for the agent to assume | +| B | 5 of 6 present; one minor gap that's inferable | +| C | 4 of 6 present; some requirements need discovery | +| D | 2–3 present; significant exploration required before coding | +| F | Missing most; the agent would be designing the feature, not building it | + +**Checklist:** + +- [ ] **Acceptance criteria** — explicit pass/fail conditions +- [ ] **Edge cases** — empty states, limits, concurrent access, null values +- [ ] **Error scenarios** — what can fail, and for each: throw, show the user a message, or handle silently +- [ ] **Scope boundaries** — what is explicitly NOT included +- [ ] **Affected user roles** — who can and cannot do this +- [ ] **Data requirements** — what data exists, what is created, any schema change + +--- + +## Dimension 3 — Test Plan Quality + +This dimension is critical: an agent cannot verify its own work without explicit test criteria. Tests drive the implementation. + +| Grade | Criteria | +| ----- | -------------------------------------------------------------------------------- | +| A | Complete plan for unit tests (where there is logic) AND E2E; specific assertions | +| B | Plan present with minor gaps; assertions specific and verifiable | +| C | Has scenarios but missing key assertions or a test type | +| D | Mentions "should be tested" without specific scenarios | +| F | No test plan; no way to verify completion | + +### Unit tests (wherever there is logic) + +Required categories: **happy path**; **negative cases** (invalid input, unauthorized access, constraint violations); **edge cases** (boundaries, unusual states); **error handling** (specific error types and messages); **permission checks**. + +### E2E tests (always required for user-facing change) + +- Always test the happy path from a user's perspective, focused on what a real user does. +- Extending an existing spec is often enough; major new functionality gets its own spec. +- Don't duplicate unit-test edge cases in E2E. Do test that errors display correctly to users (not every error type). + +**Every E2E test MUST include a code snippet** showing the exact test: real or planned test ids (new ones listed in a "New Selectors" table with component file and purpose), the full flow (navigation, actions, waits, assertions), real helpers, proper waits (never arbitrary sleeps), and isolation (setup and cleanup when tests share state). A prose description like "verify the dialog appears" is useless: the developer needs to know which element, how to get there, and what the assertion looks like. + +**Assertion quality:** each test has a specific, objectively verifiable assertion; unit tests name the data and expected values. + +**Tooling exemption.** A ticket with **no user-facing app change** (the project's CLI, scripts, agent instructions, local dev tools) has no browser E2E to write. Grade its plan on **unit tests plus scripted checks** (exact commands and the observable result each must produce). + +**Capped at D:** "verify it works correctly"; "test that errors are handled"; "check the UI updates"; logic changes with no unit-test plan; no E2E expectation; E2E in prose only. + +**Capped at C:** a snippet uses test ids that don't exist and aren't listed as new; uses helpers or patterns that don't exist; is incomplete (assertions without setup or navigation); ignores a shared test registry the project uses. + +--- + +## Dimension 4 — Context Sufficiency + +| Grade | Criteria | +| ----- | -------------------------------------------------------------------- | +| A | All 6 checklist items present; the agent can start without exploring | +| B | 5 of 6; minor context inferable from codebase patterns | +| C | 3–4; some exploration needed | +| D | 1–2; extensive exploration required | +| F | No technical context | + +**Checklist:** + +- [ ] **File paths** — exact files to create or modify +- [ ] **Code patterns** — links to existing similar implementations to follow +- [ ] **Schema / types** — data names, field types, validation schemas +- [ ] **APIs** — which functions or endpoints are involved +- [ ] **Dependencies** — related tickets, prerequisites, blockers +- [ ] **Architecture decisions** — why this approach over the alternatives (stops the agent "improving" it) + +--- + +## Dimension 5 — Scope & Vertical Slicing + +**Full-stack vertical slices only.** Every ticket delivers a complete user-facing change: data → backend → frontend → tests. Never split work by layer. + +| Grade | Criteria | +| ----- | ------------------------------------------------------------------------------ | +| A | Fits one focused dev session; full-stack; clear "done" | +| B | Fits one session with several related changes; all full-stack | +| C | Reasonable but missing a layer (e.g. no E2E plan) or slightly too broad | +| D | Too broad; mixes unrelated concerns; unclear where to start | +| F | Epic-sized; multiple features bundled; or split by layer instead of by feature | + +**Grade size by "does it fit one dev session", not by counting changes.** Splitting a ticket that fits one session costs more than it saves. Never recommend a split unless the ticket genuinely cannot be finished in one session — and never in `--fix` mode. + +**Capped at D:** covers one layer only; "the frontend comes in a follow-up"; no E2E expectation; mixes unrelated features. + +**Capped at C:** "while we're at it" additions **unrelated** to the goal; several **unrelated** acceptance criteria; refactoring of untouched code mixed with feature work. + +--- + +## Dimension 6 — Security Considerations + +An agent will not infer security requirements: if the spec doesn't mention permission checks, they won't be added. + +| Grade | Criteria | +| ----- | ---------------------------------------------------------------------------------------------- | +| A | Explicit: permission model, data scoping and input-validation boundaries all defined | +| B | Addressed with 1 minor gap | +| C | Partially addressed; permission model implied but not explicit | +| D | Not addressed despite touching sensitive data or permissions | +| F | Introduces obvious risks with no mitigation | +| N/A | No security implications (purely cosmetic: styling, copy, layout; no data access or endpoints) | + +**Checklist:** permission model (who, which roles); data scoping (users reach only their own data); input validation (where the boundaries are, what is validated); sensitive data (personal or credential data and how it's protected). + +**Capped at D:** a new endpoint with no permission checks mentioned; changed data access with no scoping; personal or credential data with no security notes. + +**Capped at C:** "only authorized users" without saying who; validation mentioned without the rules. + +--- + +### Step 5: Overall grade + +**Weakest link:** overall = the minimum dimension grade (N/A excluded). Passing threshold: B. A ticket graded C or below should be improved before it is built. + +### Step 6: Suggestions + +For any dimension below A, write specific, actionable suggestions framed as questions the ticket MUST answer ("What error message shows when the email is already taken?", not "add more detail about errors"). Prioritise: test plan gaps, then clarity, context, completeness, and scope (only when it can't be finished in one session). Never suggest splitting by layer. + +### Step 7: Report + +```markdown +# Ticket Review: $ARGUMENTS + +## UX Changes (What Users Will See) + +<2–5 plain bullets: what users can now do, what looks different, what workflows change. No technical detail.> + +--- + +## Overall Grade: <GRADE> + +| Dimension | Grade | Summary | +| ------------------------ | --------- | ---------- | +| Clarity & Specificity | <A-F> | <one line> | +| Completeness | <A-F> | <one line> | +| Test Plan Quality | <A-F> | <one line> | +| Context Sufficiency | <A-F> | <one line> | +| Scope & Vertical Slicing | <A-F> | <one line> | +| Security Considerations | <A-F/N/A> | <one line> | + +**Verdict:** <READY TO BUILD / NEEDS REVISION> + +--- + +## Detailed Analysis + +<per dimension: the grade, the checklist status with [x]/[✗]/[n/a], and the issues found, quoting the ticket> + +--- + +## Suggested Fixes + +<only when overall < B> + +### Critical (must fix before building) + +1. … + +### Recommended + +1. … + +### Consider splitting + +<only if it cannot be finished in one dev session; never in --fix mode; otherwise omit> +``` + +**Key insight:** agents compound errors. A vague requirement plus a vague test plan gives code that appears to work but doesn't. Strictness at the spec level prevents costly rework. diff --git a/instructions/planning/split-ticket.md b/instructions/planning/split-ticket.md new file mode 100644 index 0000000..8e30993 --- /dev/null +++ b/instructions/planning/split-ticket.md @@ -0,0 +1,212 @@ +--- +name: split-ticket +description: Split a large ticket into smaller, vertically-sliced child tickets. Each child is E2E testable and executable by an agent on its own. +--- + +# Split Ticket into Children + +Breaks a large ticket down into vertically-sliced child tickets. Each child is testable from the user's perspective (E2E) and carries enough context for an agent to build it on its own. + +## Usage + +``` +/split-ticket PAWNEE-12 +``` + +**Argument:** `$ARGUMENTS` — the ticket id. If it is empty, use the ticket the conversation is about; if there is none, report: "No ticket specified. Usage: /split-ticket <id>". + +## Core Principles + +1. **Vertical slicing** — no frontend-only or backend-only tickets. Each ticket delivers user-visible value. +2. **Dependency tree** — tickets are built one at a time, in number order, but each depends only on the earlier tickets it actually needs — and says why. The tickets form a tree, not a line: a ticket that needs nothing from #2 does not wait on #2. +3. **The parent's E2E tests are final** — the parent ticket's E2E tests are the source of truth. Each child implements the subset of those exact tests that match its scope. When all children are complete, the codebase must contain exactly the parent's E2E tests — no more, no less. Include an "E2E Test File Ownership" table in the proposal showing which test lands in which ticket. +4. **E2E coverage** — all work folds under an E2E test. +5. **Right-sized, not smallest** — each child should fit one focused dev session. Never split work that already fits one session, and never produce children that only make sense together. More tickets means more reviews and more cross-ticket consistency to keep — split only when a piece genuinely cannot be done in one session. **No ticket only changes tests** (a flaky or wrong test, added coverage, a retired spec): fold that work into the ticket whose code it tests. +6. **Agent-executable** — each child has complete context for autonomous execution. +7. **TDD for unit tests** — comprehensive unit tests, written before the implementation. +8. **Happy-path E2E** at minimum; more coverage for core product areas. + +## Instructions + +### Step 1: Fetch the ticket + +```bash +lambda tickets pull <TICKET> +``` + +Read its description (`.lambda/pulled/<TICKET>/description.md`, or the ticket file on a markdown project) and any images beside it. + +### Step 2: Analyse and propose the breakdown + +**Understand the ticket:** what user-facing features it describes, what acceptance criteria it has, and where the natural vertical slices are. + +**Apply the slicing rules:** + +| Good split | Bad split | +| ------------------------- | ---------------------- | +| Feature A end-to-end | Frontend for Feature A | +| Feature B end-to-end | Backend for Feature A | +| Independent enhancement C | "All endpoints" | +| Edge-case handling for A | "All UI components" | + +**Each child MUST:** deliver testable value from the user's perspective, include both frontend and backend work where needed, be independently shippable, and have a clear E2E scenario. + +**Propose the breakdown:** a numbered list, the dependency tree and an E2E ownership table. Usually a thin first ticket, then slices that each depend only on what they need. Every dependency points at a lower-numbered ticket and carries a reason: + +``` +## Proposed Breakdown for <TICKET> + +Parent: <ticket title> + +### Children: + +1. **<Title>** + - Scope: <1-2 sentences> + - E2E Test: <which of the parent's E2E tests this ticket writes> + - Dependencies: <"#N — <reason>" for each earlier ticket it needs, or "None"> + +2. **<Title>** + - Scope: <description> + - E2E Test: <which tests> + - Dependencies: #1 — <why it needs #1's code> + +### Dependency tree +#1 +├── #2 (needs #1: <reason>) +└── #3 (needs #1: <reason>) + +### E2E Test File Ownership + +| E2E file | Tests | Written in | +|---|---|---| +| `existing-spec.spec.ts` | Tests X-Y | #1 | +| `new-feature.spec.ts` | setup + tests 1-3 | #2 | +| `new-feature.spec.ts` | Tests 4-6 | #3 | + +Ready to create these children? +``` + +**STOP and wait for approval before creating anything.** (In a planning pod, the approval is {owner} closing the split-proposal checkpoint.) + +### Step 3: Write each child in full + +After approval, write each child with this template: + +```markdown +## Summary + +<1-2 sentences: what this ticket accomplishes> + +## Acceptance Criteria + +- [ ] <Specific, testable criterion> + +## Test Plan + +### Unit Tests (TDD) + +Write these tests FIRST, then implement: + +- [ ] Test: <description> -> Expected: <result> + +### E2E Tests + +Full test snippets, not prose: + +- [ ] Happy path: <user flow> — snippet with real test ids, navigation and assertions +- [ ] <Additional scenarios for core areas> + +**New selectors** (if any): | test id | Element | File | + +(Tooling children with no user-facing change: unit tests and scripted checks instead.) + +## Security + +- <Data scoping, permissions, validation — or "N/A: <why>"> + +## Implementation Context + +### Files to Modify + +- `path/to/file.ts` - <what changes> + +### Patterns to Follow + +- See `path/to/similar/implementation.ts` + +### Schema Changes (if any) + +- <Data shape changes, or "None"> + +## Scope Boundaries + +NOT included in this ticket: + +- <Explicit exclusion> + +## Dependencies + +- Blocked by: <id — reason, one line per earlier ticket it needs, or "None"> +- Blocks: <id or "None"> +``` + +### Step 4: Create the children + +For each approved child, in build order: + +1. `lambda tickets create --title "<N> - <title>" --parent <PARENT> --status planning --description-stdin < child.md` — titles are **numbered in build order** (`1 - …`, `2 - …`), so every dependency has a lower number than the ticket that needs it. Capture the new id it prints. +2. Once all children exist, set their order to match the numbering: `lambda tickets reorder <PARENT> --order <ID_1> <ID_2> <ID_3>`. + +Dependency links between children are **not** written here: inside a planning pod they need {owner}'s yes and are written at Finish. + +### Step 5: Report + +``` +## Children Created + +Parent: <PARENT> - <parent title> + +Created: +- <ID_1>: <title> +- <ID_2>: <title> + +Dependency tree: +<ID_1> +├── <ID_2> (needs <ID_1>: <reason>) +└── <ID_3> (needs <ID_1>: <reason>) +``` + +## Example + +``` +## Proposed Breakdown for PAWNEE-500 + +Parent: Park usage counts with export + +### Children: + +1. **Visit counts + dashboard** + - Scope: Count visits per park, and a new dashboard page showing them with a date range picker. + - E2E Test: `park-usage.spec.ts` tests 1-3 (Leslie views the dashboard, counts update, the date range filter works) + - Dependencies: None + +2. **CSV export** + - Scope: An Export button on the dashboard downloads the counts as CSV. + - E2E Test: `park-usage.spec.ts` test 4 + - Dependencies: #1 — the export reads the dashboard's counts + +3. **PDF report** + - Scope: A formatted PDF report of the counts. + - E2E Test: `park-usage.spec.ts` test 5 + - Dependencies: #1 — the report renders the dashboard's counts; it needs nothing from the CSV export +``` + +## Anti-patterns to Avoid + +**DON'T create horizontal slices:** "Add all database schemas", "Create all API endpoints", "Build all UI components". + +**DO create vertical slices:** "A user can add a park", "A user can view counts for a date range", "A user can export counts to CSV". + +**DON'T create tickets that aren't E2E testable:** "Refactor the counts service", "Add TypeScript types", "Improve error handling". + +**DO ensure every ticket has a user-visible outcome:** "An error message shows when the export fails", "A loading state shows while the counts load". diff --git a/instructions/planning/ux-sketch.md b/instructions/planning/ux-sketch.md new file mode 100644 index 0000000..2526e07 --- /dev/null +++ b/instructions/planning/ux-sketch.md @@ -0,0 +1,58 @@ +--- +description: Write a UX section (ASCII + short "what changes" notes) at the top of a ticket, or of every child of an epic. +argument-hint: <ticket id | epic id> +--- + +# /ux-sketch $ARGUMENTS + +{owner} reviews plans fastest through their UX: a short, visual summary of every screen that changes makes it obvious where the plan filled a gap with something they would not have chosen. This command writes that summary. + +## Step 1: Targets + +`lambda tickets pull $ARGUMENTS`. If the ticket has children (it is an epic), the targets are **every child**; otherwise the ticket itself. + +## Step 2: Ground the "before" + +For each target, find every screen, dialog, message or email it changes. Read the **current** UI code for each, and the project's docs where they describe it, so the "before" matches what exists today — not what the ticket assumes. + +## Step 3: Write `## UX` at the top of each ticket + +Insert it directly under the title, before everything else. For each surface: + +````markdown +## UX + +### <Surface name> — <changed | new | removed> + +``` +┌ Ron Swanson · Shifts ──────────────────┐ +│ Harvest Festival Oct 3 2 shifts │ +│ Lot 48 cleanup Oct 5 1 shift │ ← new +│ Total 3 shifts │ ← changed (was 2) +└────────────────────────────────────────┘ +``` + +- The cleanup shows up as its own line instead of being hidden. +- The total includes it. +```` + +Rules: + +- Mark every changed element: `← new`, `← changed (was …)`, `← removed`. Show a before/after pair when an existing screen changes a lot. +- 1–3 short notes per surface: **what** changes and **why**. No implementation detail. +- Use realistic fictional data, never real people's names or data. +- Mobile is 375px wide: keep boxes ≤ 44 characters wide when the surface is mobile-first. +- A ticket with no user-facing change gets exactly `## UX — no user-facing change` plus one line saying why. Never leave it out. +- This command writes ASCII only. + +## Step 4: Push and log + +Send each ticket back (`lambda tickets push <id>`). + +**In a planning pod** this command raises **no questions** — the UX is what {owner} reviews at the UX review. Every assumption you had to make to draw a screen (a label, an empty state, an order) is logged so it shows up on the checkpoint: + +```bash +lambda pod decide --autonomous "<assumption>" --why "<why>" --intent-ref "…" +``` + +Outside a pod, list those assumptions at the end of your report. diff --git a/package-lock.json b/package-lock.json index 002e9ee..1b0b91c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,2087 +1,14716 @@ { - "name": "lambda-cli", - "version": "0.1.0", + "name": "@adriancarriger/lambda", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "lambda-cli", - "version": "0.1.0", + "name": "@adriancarriger/lambda", + "version": "0.2.0", "license": "MIT", "dependencies": { + "@lydell/node-pty": "^1.1.0", + "better-sqlite3": "^12.11.1", "chalk": "^4.1.2", - "commander": "^11.1.0" + "commander": "^11.1.0", + "electron-updater": "^6.8.9", + "esbuild": "0.25.4", + "jiti": "^2.6.1", + "playwright": "^1.59.0", + "ws": "^8.18.2", + "zod": "^3.25.76" }, "bin": { "lambda": "bin/lambda" }, "devDependencies": { - "@eslint/js": "^9.39.2", - "@playwright/test": "^1.48.0", - "@types/node": "^20.10.0", - "eslint": "^9.39.2", - "prettier": "^3.4.0", - "tsx": "^4.6.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.55.0" + "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", + "@tiptap/extension-bubble-menu": "3.31.4", + "@tiptap/extension-image": "3.31.4", + "@tiptap/extension-placeholder": "3.31.4", + "@tiptap/extension-table": "3.31.4", + "@tiptap/markdown": "3.31.4", + "@tiptap/pm": "3.31.4", + "@tiptap/react": "3.31.4", + "@tiptap/starter-kit": "3.31.4", + "@types/better-sqlite3": "^7.6.13", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@types/ws": "^8.18.2", + "@typescript-eslint/eslint-plugin": "^8.39.0", + "@typescript-eslint/parser": "^8.39.0", + "@vitejs/plugin-react": "^4.7.0", + "@xterm/xterm": "^5.5.0", + "electron": "44.9.3", + "electron-builder": "^26.15.3", + "eslint": "^8.57.1", + "jest": "^29.7.0", + "kokoro-js": "1.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "ts-jest": "^29.1.2", + "tsup": "^8.0.2", + "tsx": "^4.21.0", + "typescript": "^5.3.3", + "vite": "^5.4.21", + "yaml": "^2.7.1" }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^0.17.0" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" }, - "funding": { - "url": "https://eslint.org/donate" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, "engines": { - "node": ">=18.18.0" + "node": ">=6.9.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { - "node": ">=18.18.0" + "node": ">=6.9.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "license": "MIT" }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.6.tgz", + "integrity": "sha512-/aQLgFDdCSnwChfUwqekSmE1q2dCTzhUy5saIHBb5vyYn5LzWaKPaIz1wM0lDMCZYBoIG4svJNvAJb6vkI8BuA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=22.12.0" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright": "1.58.2" + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" }, "bin": { - "playwright": "cli.js" + "asar": "bin/asar.js" }, "engines": { - "node": ">=18" + "node": ">=10.12.0" } }, - "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/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "20.19.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", - "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", - "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/type-utils": "8.55.0", - "@typescript-eslint/utils": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.55.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">= 6" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">= 4" + "node": "*" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", - "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "bin": { + "electron-fuses": "dist/bin.js" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", - "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.55.0", - "@typescript-eslint/types": "^8.55.0", - "debug": "^4.4.3" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": ">=10" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", - "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0" + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=22.12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "optionalDependencies": { + "undici": "^7.24.4" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", - "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "engines": { + "node": ">= 10.0.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", - "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "engines": { + "node": ">=12.0.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", - "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 8.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", - "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "node_modules/@electron/rebuild": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.1.tgz", + "integrity": "sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.55.0", - "@typescript-eslint/tsconfig-utils": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "bin": { + "electron-rebuild": "lib/cli.js" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "balanced-match": "^1.0.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", - "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=14.14" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", - "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "eslint-visitor-keys": "^4.2.1" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16 || 14 >=14.17" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, "bin": { - "acorn": "bin/acorn" + "electron-windows-sign": "bin/electron-windows-sign.js" }, "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "node": ">=14.14" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=14.14" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "tslib": "^2.4.0" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=7.0.0" + "node": ">=18" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/escape-string-regexp": { + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz", + "integrity": "sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "ignore": "^7.0.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz", + "integrity": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tiptap/core": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz", + "integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz", + "integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz", + "integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.4.tgz", + "integrity": "sha512-Ml/yswuys+3QkOMajfXyAGlUKCxEbT3dMyF8vHwQ5mDk9MDm+dqp10It5CrDBkUT/KkVTa6AJI3kb0TXZJZiAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz", + "integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz", + "integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz", + "integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz", + "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz", + "integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz", + "integrity": "sha512-pKzulkDmolpVt7IP9FSPsC5pKbXQ5pyeqkaAYr8cyOVW+KUObpPRIzAuXvEso8PCjWgwJ1f2qyishOEMw8MbfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz", + "integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz", + "integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz", + "integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz", + "integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.4.tgz", + "integrity": "sha512-vtD+rK73abJnPRKOjU37sQTCa5Auyg451yhqZD/3lZKiFJDBHAZKI7lQARkXBQYK289hBhK6yMIwofoqCBvi3g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz", + "integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz", + "integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz", + "integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz", + "integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz", + "integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz", + "integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz", + "integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.4.tgz", + "integrity": "sha512-3SoJopaOzaA9IZHSk9ZXHiO6ib8ErPh7ssRh3bIaxfD+BqRyXcm4hV4yfFXTqrDCnl5FE+DbBRGmeVAVkDko3g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz", + "integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.4.tgz", + "integrity": "sha512-ai7DLwg/xmhlUas3LgWaPQdPlZ8StliY6Dn+iUH6eyZ/TQqRDY/oeIsKWaHpXq22T5M5dMlZPTg172ZdtwKswQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz", + "integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz", + "integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz", + "integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/markdown": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.31.4.tgz", + "integrity": "sha512-lmR9V1/eQ41BaR2zBgYG/+Liy7K7Ch17ZNxVy8dpUVlNpWxxoHUMXNsCg2uEyXP3NLpZaKiY0+drSu9oK1DbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "marked": "^17.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz", + "integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.4.tgz", + "integrity": "sha512-mBvvXgdPhMinT1NPfwHvFK1dFdMXHVWMSeNn0IPj7/zV0XBmOnASkEOwoIvl8NluKYEtpZBE1yA+6pN2enLa5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "3.31.4", + "@tiptap/extension-floating-menu": "3.31.4" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz", + "integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/extension-blockquote": "3.31.4", + "@tiptap/extension-bold": "3.31.4", + "@tiptap/extension-bullet-list": "3.31.4", + "@tiptap/extension-code": "3.31.4", + "@tiptap/extension-code-block": "3.31.4", + "@tiptap/extension-document": "3.31.4", + "@tiptap/extension-dropcursor": "3.31.4", + "@tiptap/extension-gapcursor": "3.31.4", + "@tiptap/extension-hard-break": "3.31.4", + "@tiptap/extension-heading": "3.31.4", + "@tiptap/extension-horizontal-rule": "3.31.4", + "@tiptap/extension-italic": "3.31.4", + "@tiptap/extension-link": "3.31.4", + "@tiptap/extension-list": "3.31.4", + "@tiptap/extension-list-item": "3.31.4", + "@tiptap/extension-list-keymap": "3.31.4", + "@tiptap/extension-ordered-list": "3.31.4", + "@tiptap/extension-paragraph": "3.31.4", + "@tiptap/extension-strike": "3.31.4", + "@tiptap/extension-text": "3.31.4", + "@tiptap/extension-underline": "3.31.4", + "@tiptap/extensions": "3.31.4", + "@tiptap/pm": "3.31.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", + "integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/type-utils": "8.72.0", + "@typescript-eslint/utils": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.72.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz", + "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz", + "integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.0", + "@typescript-eslint/types": "^8.72.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz", + "integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz", + "integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz", + "integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz", + "integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz", + "integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.72.0", + "@typescript-eslint/tsconfig-utils": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz", + "integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz", + "integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-lib": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.18.0.tgz", + "integrity": "sha512-lx+KkuCSZHcbECf4R3IAcC9VOFk0+cMUwljO63s5y8yqS1Lw0sRGmjPaWsPwP3zv3ohZYbMhUvjXeiQXXZhJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.2.1", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^1.8.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.16.0", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.18.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.18.0", + "electron-builder-squirrel-windows": "26.18.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", + "integrity": "sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.2.tgz", + "integrity": "sha512-V2o8YS2EhrKMO9AumnkK0ynynfFlsajpYupwq7S46H5mhcanwxDkNTw3K67wPIcKTH1ffkA4vhMOtAc7gRmPZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "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/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.18.0.tgz", + "integrity": "sha512-ozCGC54wi2C1RkS2v69y9lQxvoQ1Coh+iT8CmWiyY8y1ZuOKVvuNson2Hd+HEOs9+EZNNsOBbzKgQZ4SNqWd0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.9.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.3.tgz", + "integrity": "sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.18.0.tgz", + "integrity": "sha512-ASgIuQnOq4RuD8jMhS9hI0LS3EMRT1I/M5R6utqifB/BHN80IcCjBdt+DED+NmNPvmBgEtrlExXZitNdA7kXOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.18.0", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.18.0.tgz", + "integrity": "sha512-a2elgTipboXNIsxqS1HOmHMGp4Pd3LJgIZOdIFTOskBSawvxKyZWYWQ1d7KKA4CtINSchYCYEKzhxRqiKZev9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.18.0.tgz", + "integrity": "sha512-MagnqaZSALSWjcdKu5HEVhYUZD/2BDL3q7XzrqWwub6gf/UfD3ItMgmthVDv9w5y9E0gH1C3JWqKaXwf6f4ROg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.16.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.8.11", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.11.tgz", + "integrity": "sha512-12U/mbzaIKQS3Q543aQ7DjyAZMXeVJy1Wpu0qdq5ne4RXpIQDpWYm25zRjgXngAoo6swMrm4C4owGyiIipelsg==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.5.tgz", + "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.4.tgz", + "integrity": "sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bser": "2.1.2" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kokoro-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/kokoro-js/-/kokoro-js-1.2.1.tgz", + "integrity": "sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@huggingface/transformers": "^3.5.1", + "phonemizer": "^1.2.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", + "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/linkifyjs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz", + "integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.40.0.tgz", + "integrity": "sha512-GuvqvtrOk+AC27RidBxBsMzVNkwNl7PjHgc4NXbmdvSVLgIUjpaqcG1FjNRYnt2Yn9Ytq+Ln37XsBaglUcte/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/phonemizer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/phonemizer/-/phonemizer-1.2.1.tgz", + "integrity": "sha512-v0KJ4mi2T4Q7eJQ0W15Xd4G9k4kICSXE8bpDeJ8jisL4RyJhNWsweKTOi88QXFc4r4LZlz5jVL5lCHhkpdT71A==", + "dev": true, + "license": "Apache-2.0" + }, + "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": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkijs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.3.tgz", + "integrity": "sha512-V9kOzaItwDgqlryhXysQsAXUEKi5O7kqWUaP3dXI7MLvkPqWWMb4wVb1QAtmEDCKI+XIAINsZoNKMFF7sZb1eg==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/node-abi": { + "version": "3.101.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz", + "integrity": "sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.5.tgz", + "integrity": "sha512-hv3Qhsn5GyoferiBqL8euGvj3Y8Z+2Ef75NnPLChr5DTWArpPAjK6F96Yc0iM7SBYNDPQbQaA+xEtAR8NuWo9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.6.0.tgz", + "integrity": "sha512-G1srDtan+VqWn+qpmWFj/6j0YsZ5zMU+ZIlGVIW41a5JF2jAI3jzfMRWmSABmEH2Qoq1PmAG/o2mucMMdLOsLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz", + "integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz", + "integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.4.tgz", + "integrity": "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-jest": { + "version": "29.4.15", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz", + "integrity": "sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.10", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <9", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsup/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "node": ">=18" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, - "funding": { - "url": "https://opencollective.com/eslint" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", "dependencies": { - "estraverse": "^5.1.0" + "safe-buffer": "^5.0.1" }, "engines": { - "node": ">=0.10" + "node": "*" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "prelude-ls": "^1.2.1" }, "engines": { - "node": ">=4.0" + "node": ">= 0.8.0" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=4" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, "engines": { - "node": ">=0.10.0" + "node": ">=0.8.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "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/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "dependencies": { + "@types/unist": "^3.0.0" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "@types/unist": "^3.0.0" }, - "engines": { - "node": ">=16.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "@types/unist": "^3.0.0" }, - "engines": { - "node": ">=10" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", "engines": { - "node": ">=16" + "node": ">= 10.0.0" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=14.14" } }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.3" + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=10.12.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=6" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=0.8.19" + "node": ">=12" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=12" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": ">=12" } }, - "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==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=12" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=12" } }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=12" } }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "bin": { - "prettier": "bin/prettier.cjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=14" + "node": ">=12" }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, "bin": { - "semver": "bin/semver.js" + "node-which": "bin/which.js" }, "engines": { - "node": ">=10" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { - "node": ">=18.12" + "node": ">=10.0.0" }, "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=8.0" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, + "license": "ISC", "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "license": "ISC" }, - "node_modules/typescript-eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", - "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.55.0", - "@typescript-eslint/parser": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0" + "license": "ISC", + "bin": { + "yaml": "bin.mjs" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 14.6" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "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/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/yocto-queue": { @@ -2096,6 +14725,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 6d418c7..969a9de 100644 --- a/package.json +++ b/package.json @@ -1,53 +1,110 @@ { - "name": "lambda-cli", - "version": "0.1.0", - "description": "Lambda CLI - Multi-agent development orchestration", - "main": "dist/cli.js", - "bin": { - "lambda": "./bin/lambda" - }, - "scripts": { - "build": "tsc", - "dev": "tsx src/cli.ts", - "clean": "rm -rf dist", - "test": "npx playwright test", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "format": "prettier --write '**/*.{ts,tsx,js,jsx,json,css,md}'", - "typecheck": "tsc --noEmit" - }, - "keywords": [ - "lambda", - "cli", - "multi-agent", - "claude", - "ai", - "tmux", - "orchestration" - ], - "author": "Adrian Carriger", + "name": "@adriancarriger/lambda", + "version": "0.2.0", + "description": "Run a team of coding agents on your own project: planning pods, building pods and the commands they share.", "license": "MIT", + "author": "Adrian Carriger", "repository": { "type": "git", - "url": "https://github.com/adriancarriger/lambda.git" + "url": "git+https://github.com/adriancarriger/lambda.git" }, - "homepage": "https://github.com/adriancarriger/lambda", + "homepage": "https://github.com/adriancarriger/lambda#readme", "bugs": { "url": "https://github.com/adriancarriger/lambda/issues" }, + "bin": { + "lambda": "./bin/lambda" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js" + }, + "./cli": { + "types": "./dist/cli.d.ts", + "require": "./dist/cli.js" + } + }, + "files": [ + "bin", + "commands", + "dist", + "instructions", + "src/building/permission-hook.cjs", + "src/design/system/shims", + "templates", + "tools" + ], + "scripts": { + "build": "tsup && vite build", + "build:desktop": "tsup --config desktop/tsup.config.ts", + "build:mac": "npm run build && npm run build:desktop && bash desktop/tools/fetch-tools.sh && npm install --prefix desktop/kokoro --omit=dev --no-audit --no-fund && bash desktop/tools/package-mac.sh", + "lint": "eslint --cache --max-warnings 0 'src/**/*.{ts,tsx}' 'desktop/*.ts' 'scripts/**/*.ts'", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p desktop/tsconfig.json && tsc --noEmit -p scripts/tsconfig.json", + "test": "jest" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "setupFilesAfterEnv": [ + "<rootDir>/src/jestSetup.ts" + ], + "testMatch": [ + "**/__tests__/**/*.test.ts" + ] + }, "dependencies": { + "@lydell/node-pty": "^1.1.0", + "better-sqlite3": "^12.11.1", "chalk": "^4.1.2", - "commander": "^11.1.0" + "commander": "^11.1.0", + "electron-updater": "^6.8.9", + "esbuild": "0.25.4", + "jiti": "^2.6.1", + "playwright": "^1.59.0", + "ws": "^8.18.2", + "zod": "^3.25.76" + }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0" }, "devDependencies": { - "@eslint/js": "^9.39.2", - "@playwright/test": "^1.48.0", - "@types/node": "^20.10.0", - "eslint": "^9.39.2", - "prettier": "^3.4.0", - "tsx": "^4.6.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.55.0" + "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", + "@tiptap/extension-bubble-menu": "3.31.4", + "@tiptap/extension-image": "3.31.4", + "@tiptap/extension-placeholder": "3.31.4", + "@tiptap/extension-table": "3.31.4", + "@tiptap/markdown": "3.31.4", + "@tiptap/pm": "3.31.4", + "@tiptap/react": "3.31.4", + "@tiptap/starter-kit": "3.31.4", + "@types/better-sqlite3": "^7.6.13", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@types/ws": "^8.18.2", + "@typescript-eslint/eslint-plugin": "^8.39.0", + "@typescript-eslint/parser": "^8.39.0", + "@vitejs/plugin-react": "^4.7.0", + "@xterm/xterm": "^5.5.0", + "electron": "44.9.3", + "electron-builder": "^26.15.3", + "eslint": "^8.57.1", + "jest": "^29.7.0", + "kokoro-js": "1.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "ts-jest": "^29.1.2", + "tsup": "^8.0.2", + "tsx": "^4.21.0", + "typescript": "^5.3.3", + "vite": "^5.4.21", + "yaml": "^2.7.1" }, "engines": { "node": ">=20" diff --git a/scripts/__tests__/exampleCli.test.ts b/scripts/__tests__/exampleCli.test.ts new file mode 100644 index 0000000..06b7a95 --- /dev/null +++ b/scripts/__tests__/exampleCli.test.ts @@ -0,0 +1,108 @@ +import { Command } from 'commander'; + +import { registerAgentCommand } from '../../examples/src/commands/agent'; +import { registerAwaitCommand } from '../../examples/src/commands/await'; +import { registerShipCommand } from '../../examples/src/commands/ship'; +import { registerTmuxCommand } from '../../examples/src/commands/tmux'; +import { + assertPaneName, + assertTicketId, + assertWindowTarget, +} from '../../examples/src/utils/validate'; + +jest.mock('child_process', () => { + const refuse = (): never => { + throw new Error('the demo CLI ran a command'); + }; + + return { execSync: jest.fn(refuse), execFileSync: jest.fn(refuse) }; +}); + +const INJECTION = 'DEMO-1; touch /tmp/leslie'; + +const runDemo = ( + register: (program: Command) => void, + args: string[] +): Promise<unknown> => { + const program = new Command().exitOverride(); + register(program); + + return program.parseAsync(args, { from: 'user' }); +}; + +beforeEach(() => { + jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("the demo CLI's argument validators", () => { + it.each(['DEMO-1', 'PAWNEE-42'])('accepts the ticket id %s', (id) => { + expect(assertTicketId(id)).toBe(id); + }); + + it.each([INJECTION, 'demo-1', 'DEMO-', '$(whoami)', 'DEMO-1 --delete'])( + 'refuses the ticket id %j', + (id) => { + expect(() => assertTicketId(id)).toThrow(/ticket id/); + } + ); + + it.each(['ron', 'dev-server', 'scratchPad'])( + 'accepts the pane %s', + (pane) => { + expect(assertPaneName(pane)).toBe(pane); + } + ); + + it.each(['ron"; touch /tmp/leslie; "', 'ron sam', '$(whoami)', ''])( + 'refuses the pane %j', + (pane) => { + expect(() => assertPaneName(pane)).toThrow(/pane name/); + } + ); + + it('accepts a session:window target and refuses anything else', () => { + expect(assertWindowTarget('lambda:ron')).toBe('lambda:ron'); + expect(() => assertWindowTarget("lambda:ron' ; touch /tmp/leslie")).toThrow( + /window/ + ); + }); +}); + +describe("the demo CLI's entry points", () => { + it('ship refuses a ticket id that is not one, before running anything', async () => { + await expect( + runDemo(registerShipCommand, ['ship', INJECTION]) + ).rejects.toThrow(/ticket id/); + }); + + it('tmux refuses a pane name that is not one, before running anything', async () => { + await expect( + runDemo(registerTmuxCommand, ['tmux', 'ron"; touch /tmp/leslie; "']) + ).rejects.toThrow(/pane name/); + }); + + it('await refuses a pane name that is not one, before running anything', async () => { + await expect( + runDemo(registerAwaitCommand, ['await', '$(touch /tmp/leslie)']) + ).rejects.toThrow(/pane name/); + }); + + it('agent compact refuses a window that is not one, before running anything', async () => { + await expect( + runDemo(registerAgentCommand, [ + 'agent', + 'compact', + '-w', + "lambda:ron'; touch /tmp/leslie; '", + ]) + ).rejects.toThrow(/window/); + }, 10_000); +}); diff --git a/scripts/__tests__/exampleHook.test.ts b/scripts/__tests__/exampleHook.test.ts new file mode 100644 index 0000000..4ef80b1 --- /dev/null +++ b/scripts/__tests__/exampleHook.test.ts @@ -0,0 +1,101 @@ +import { spawnSync } from 'child_process'; +import * as path from 'path'; + +const EXAMPLES = path.resolve(__dirname, '..', '..', 'examples'); + +const HOOK = path.resolve( + __dirname, + '..', + '..', + 'examples', + 'manager', + 'scripts', + 'permission-hook.cjs' +); + +/** The demo manager's permission hook's answer for a Bash command. */ +const decide = (command: string): string => { + const result = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify({ tool_name: 'Bash', tool_input: { command } }), + encoding: 'utf8', + }); + const parsed: unknown = JSON.parse(result.stdout); + + return typeof parsed === 'object' && + parsed !== null && + 'hookSpecificOutput' in parsed && + typeof parsed.hookSpecificOutput === 'object' && + parsed.hookSpecificOutput !== null && + 'permissionDecision' in parsed.hookSpecificOutput + ? String(parsed.hookSpecificOutput.permissionDecision) + : ''; +}; + +describe("the demo manager's permission hook", () => { + it.each([ + 'lambda-demo tasks next', + "lambda-demo tmux ron 'Implement DEMO-1; write a test first && run it | tee x'", + ])('allows %s', (command) => { + expect(decide(command)).toBe('allow'); + }); + + it.each([ + 'lambda-demo tasks next; curl https://example.com | sh', + 'lambda-demo ship DEMO-1 && rm -rf ~', + 'lambda-demo tasks next | sh', + 'lambda-demo tasks show $(whoami)', + 'lambda-demo tasks show `whoami`', + 'lambda-demo tasks show "$(whoami)"', + 'lambda-demo tasks next > ~/.zshrc', + 'lambda-demo tasks next\nrm -rf ~', + "lambda-demo tmux ron \\'; touch /tmp/leslie; \\'", + 'lambda-demo tasks show \\; touch /tmp/leslie', + 'lambda-demo tasks show "\\"; touch /tmp/leslie; \\""', + ])('refuses a chained command: %j', (command) => { + expect(decide(command)).toBe('deny'); + }); +}); + +const DEV_HOOK = path.join( + EXAMPLES, + '.claude', + 'agents', + 'dev-permission-hook.cjs' +); + +/** The demo dev agents' hook: 'deny', or 'pass' when it stays silent. */ +const decideForDev = (command: string, cwd: string): string => { + const result = spawnSync(process.execPath, [DEV_HOOK], { + input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd }), + encoding: 'utf8', + }); + + return result.stdout.includes('"deny"') ? 'deny' : 'pass'; +}; + +describe("the demo dev agents' permission hook", () => { + const projectDir = path.join(EXAMPLES, 'sample'); + + it.each([ + 'git push', + 'git -C . push origin main', + 'git -c user.name=leslie commit -m x', + 'lambda-demo ship DEMO-1', + ])('refuses %j in the project', (command) => { + expect(decideForDev(command, projectDir)).toBe('deny'); + }); + + it('refuses in a directory that merely has "manager" in its path', () => { + expect(decideForDev('git push', '/tmp/project-manager-notes')).toBe('deny'); + }); + + it('stays out of the way in the manager directory', () => { + expect(decideForDev('git push', path.join(EXAMPLES, 'manager'))).toBe( + 'pass' + ); + }); + + it('lets an ordinary command through', () => { + expect(decideForDev('git status', projectDir)).toBe('pass'); + }); +}); diff --git a/scripts/__tests__/fetchTools.test.ts b/scripts/__tests__/fetchTools.test.ts new file mode 100644 index 0000000..11fda5a --- /dev/null +++ b/scripts/__tests__/fetchTools.test.ts @@ -0,0 +1,158 @@ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const PACKAGE = path.resolve(__dirname, '..', '..'); +const SCRIPT = path.join(PACKAGE, 'desktop', 'tools', 'fetch-tools.sh'); + +/** The pinned SHA-256 of each tarball, read from the script itself. */ +const pinned = (name: string): string => { + const match = new RegExp(`^${name}_SHA256="([0-9a-f]+)"$`, 'm').exec( + fs.readFileSync(SCRIPT, 'utf8') + ); + + if (match?.[1] === undefined) { + throw new Error(`fetch-tools.sh pins no ${name}_SHA256`); + } + + return match[1]; +}; + +const stub = (dir: string, name: string, body: string): void => { + fs.writeFileSync(path.join(dir, name), `#!/bin/sh\n${body}\n`, { + mode: 0o755, + }); +}; + +/** + * A copy of fetch-tools.sh in a temp folder (it builds next to itself), + * with curl, shasum, tar, make and otool stubbed: no download, no compile. + * `foreign` makes otool report tmux linking a Homebrew library. + */ +const toolsDir = ( + options: { tamperedTmux?: boolean } = {} +): { dir: string; run: (foreign: boolean) => string } => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-fetch-tools-')); + const tools = path.join(dir, 'tools'); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(tools); + fs.mkdirSync(bin); + fs.copyFileSync(SCRIPT, path.join(tools, 'fetch-tools.sh')); + + // The "tarball" holds its pinned SHA-256, which the shasum stub prints. + stub( + bin, + 'curl', + [ + 'for a; do out="$url"; url="$a"; done', + 'case "$url" in', + ` *libevent*) sha=${pinned('LIBEVENT')} ;;`, + ` *tmux*) sha=${ + options.tamperedTmux === true ? '0'.repeat(64) : pinned('TMUX') + } ;;`, + ` *git*) sha=${pinned('GIT')} ;;`, + 'esac', + 'printf "%s" "$sha" > "$out"', + ].join('\n') + ); + stub(bin, 'shasum', 'echo "$(cat "$3") $3"'); + // `tar -xzf <file> -C <dir>`: a source folder with a configure that passes. + stub( + bin, + 'tar', + [ + 'src="$4/$(basename "$2" .tar.gz)"', + 'mkdir -p "$src"', + 'printf "#!/bin/sh\\n" > "$src/configure"', + 'chmod +x "$src/configure"', + 'printf "#!/bin/sh\\n" > "$src/tmux"', + 'chmod +x "$src/tmux"', + ].join('\n') + ); + // git's `make ... DESTDIR=<dir> install` lays out the install. + stub( + bin, + 'make', + [ + 'for a; do case "$a" in DESTDIR=*) d="${a#DESTDIR=}" ;; esac; done', + '[ -z "$d" ] && exit 0', + 'mkdir -p "$d/bin" "$d/libexec/git-core" "$d/share/git-core"', + 'printf "#!/bin/sh\\n" > "$d/bin/git"', + 'chmod +x "$d/bin/git"', + ].join('\n') + ); + + return { + dir, + run: (foreign) => { + stub( + bin, + 'otool', + [ + 'echo "$2:"', + 'echo " /usr/lib/libSystem.B.dylib (compatibility version 1.0.0)"', + foreign + ? 'case "$2" in *tmux) echo " /opt/homebrew/lib/libevent.dylib (compatibility version 1.0.0)" ;; esac' + : '', + ].join('\n') + ); + const result = spawnSync('bash', [path.join(tools, 'fetch-tools.sh')], { + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ''}` }, + encoding: 'utf8', + }); + + return `${result.stdout}${result.stderr}exit ${String(result.status)}`; + }, + }; +}; + +describe('fetch-tools.sh', () => { + const made: string[] = []; + + afterEach(() => { + made + .splice(0) + .forEach((dir) => fs.rmSync(dir, { recursive: true, force: true })); + }); + + it('builds tmux and git into out/ when every binary links only system libraries', () => { + const tools = toolsDir(); + made.splice(made.length, 0, tools.dir); + + expect(tools.run(false)).toMatch(/tools: tmux .* in .*exit 0$/s); + expect(fs.existsSync(path.join(tools.dir, 'tools/out/bin/tmux'))).toBe( + true + ); + expect(fs.existsSync(path.join(tools.dir, 'tools/out/bin/git'))).toBe(true); + }); + + it('leaves no binaries in out/ when the link check fails, so the next run builds again', () => { + const tools = toolsDir(); + made.splice(made.length, 0, tools.dir); + + expect(tools.run(true)).toMatch(/links outside \/usr\/lib.*exit 1$/s); + expect(fs.existsSync(path.join(tools.dir, 'tools/out/bin/tmux'))).toBe( + false + ); + expect(tools.run(true)).not.toContain('already built'); + }); + + it('stops on a tarball whose SHA-256 is not the pinned one, and deletes it', () => { + const tools = toolsDir({ tamperedTmux: true }); + made.splice(made.length, 0, tools.dir); + + expect(tools.run(false)).toMatch( + new RegExp( + `tmux-3\\.5a\\.tar\\.gz has SHA-256 0{64}, expected ${pinned( + 'TMUX' + )}.*exit 1$`, + 's' + ) + ); + expect( + fs.existsSync(path.join(tools.dir, 'tools/.work/tmux-3.5a.tar.gz')) + ).toBe(false); + expect(fs.existsSync(path.join(tools.dir, 'tools/out'))).toBe(false); + }); +}); diff --git a/scripts/__tests__/packageMac.test.ts b/scripts/__tests__/packageMac.test.ts new file mode 100644 index 0000000..ea4b758 --- /dev/null +++ b/scripts/__tests__/packageMac.test.ts @@ -0,0 +1,167 @@ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const PACKAGE = path.resolve(__dirname, '..', '..'); +const SCRIPT = path.join(PACKAGE, 'desktop', 'tools', 'package-mac.sh'); + +const BUILDER = + 'electron-builder --config desktop/electron-builder.yml --mac --arm64 --publish never'; +const AD_HOC = `${BUILDER} -c.mac.identity=-`; +const VERIFY = + 'codesign --verify --deep --strict desktop/release/mac-arm64/Lambda.app'; + +/** Stub electron-builder, codesign and the rebuild step; each notes when it ran. */ +const run = ( + builderStatus: number, + options: { + rebuildStatus?: number; + verifyStatus?: number; + env?: NodeJS.ProcessEnv; + /** A file the rebuild step reports on, if it still exists then. */ + keyFile?: string; + } = {} +) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-package-mac-')); + const log = path.join(dir, 'log'); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'electron-builder'), + `#!/bin/sh\necho "electron-builder $*" >> "${log}"\nexit ${builderStatus}\n`, + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(bin, 'codesign'), + `#!/bin/sh\necho "codesign $*" >> "${log}"\nexit ${ + options.verifyStatus ?? 0 + }\n`, + { mode: 0o755 } + ); + const rebuild = path.join(dir, 'rebuild.sh'); + const keyFile = options.keyFile ?? ''; + fs.writeFileSync( + rebuild, + `echo rebuild >> "${log}"\necho "\${CSC_LINK:-}\${CSC_KEY_PASSWORD:-}\${APPLE_API_KEY:-}\${APPLE_API_KEY_ID:-}\${APPLE_API_ISSUER:-}" > "${log}.secrets"\nif [ -n "${keyFile}" ] && [ -e "${keyFile}" ]; then echo present > "${log}.key"; fi\nexit ${ + options.rebuildStatus ?? 0 + }\n` + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + LAMBDA_REBUILD_FOR_NODE: rebuild, + ...(options.env ?? {}), + }; + + if (options.env?.CSC_LINK === undefined) { + delete env.CSC_LINK; + } + + const result = spawnSync('bash', [SCRIPT], { + cwd: PACKAGE, + env, + encoding: 'utf8', + }); + + return { + status: result.status, + keySeenByRebuild: fs.existsSync(`${log}.key`), + rebuildSecrets: fs.existsSync(`${log}.secrets`) + ? fs.readFileSync(`${log}.secrets`, 'utf8').trim() + : undefined, + log: fs.existsSync(log) + ? fs.readFileSync(log, 'utf8').trim().split('\n') + : [], + }; +}; + +describe("build:mac's last step", () => { + it('ad-hoc signs a build with no Developer ID, verifies it, then rebuilds better-sqlite3 for Node', () => { + expect(run(0)).toMatchObject({ + status: 0, + log: [AD_HOC, VERIFY, 'rebuild'], + }); + }); + + it('leaves signing to electron-builder when a Developer ID certificate is given', () => { + expect(run(0, { env: { CSC_LINK: 'certificate.p12' } }).log).toEqual([ + BUILDER, + VERIFY, + 'rebuild', + ]); + }); + + // The rebuild runs better-sqlite3's install step, which downloads. + it('rebuilds for Node with none of the Apple secrets in its environment', () => { + const signed = run(0, { + env: { + CSC_LINK: 'certificate.p12', + CSC_KEY_PASSWORD: 'pawnee', + // The script deletes this file: a path that can't exist. + APPLE_API_KEY: '/nonexistent-lambda-test/key.p8', + APPLE_API_KEY_ID: 'KEY', + APPLE_API_ISSUER: 'ISSUER', + }, + }); + + expect(signed.log).toContain('rebuild'); + expect(signed.rebuildSecrets).toBe(''); + }); + + it('deletes the decoded notarisation key before the rebuild runs', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-p8-')); + const key = path.join(dir, 'apple-api-key.p8'); + fs.writeFileSync(key, 'not a real key'); + + try { + const signed = run(0, { + env: { CSC_LINK: 'certificate.p12', APPLE_API_KEY: key }, + keyFile: key, + }); + + expect(signed.log).toContain('rebuild'); + expect(signed.keySeenByRebuild).toBe(false); + expect(fs.existsSync(key)).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails when the built app does not verify, and still rebuilds for Node', () => { + expect(run(0, { verifyStatus: 1 })).toMatchObject({ + status: 1, + log: [AD_HOC, VERIFY, 'rebuild'], + }); + }); + + it('still rebuilds for Node when electron-builder fails, and fails with it', () => { + expect(run(3)).toMatchObject({ + status: 3, + log: [AD_HOC, 'rebuild'], + }); + }); + + it('fails when the rebuild for Node fails', () => { + expect(run(0, { rebuildStatus: 1 }).status).toBe(1); + }); + + it('is how build:mac ends', () => { + const pkg: unknown = JSON.parse( + fs.readFileSync(path.join(PACKAGE, 'package.json'), 'utf8') + ); + const scripts = + typeof pkg === 'object' && pkg !== null && 'scripts' in pkg + ? pkg.scripts + : undefined; + const buildMac = + typeof scripts === 'object' && scripts !== null && 'build:mac' in scripts + ? String(scripts['build:mac']) + : ''; + + expect(buildMac.endsWith('&& bash desktop/tools/package-mac.sh')).toBe( + true + ); + expect(buildMac).not.toContain('electron-builder'); + }); +}); diff --git a/scripts/__tests__/shouldPublish.test.ts b/scripts/__tests__/shouldPublish.test.ts new file mode 100644 index 0000000..aad4576 --- /dev/null +++ b/scripts/__tests__/shouldPublish.test.ts @@ -0,0 +1,33 @@ +import { NOT_ON_NPM, parseNpmView, shouldPublish } from '../shouldPublish'; + +describe('whether a push publishes', () => { + it('publishes only a version npm does not have', () => { + expect(shouldPublish('0.3.0', ['0.1.0', '0.2.0'])).toBe(true); + expect(shouldPublish('0.2.0', ['0.1.0', '0.2.0'])).toBe(false); + }); + + it('publishes when the package is not on npm at all (E404)', () => { + const versions = parseNpmView( + 1, + '', + 'npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/x' + ); + + expect(versions).toBe(NOT_ON_NPM); + expect(shouldPublish('0.1.0', versions)).toBe(true); + }); + + it('reads one version (a string) or many (an array)', () => { + expect(parseNpmView(0, '"0.1.0"\n', '')).toEqual(['0.1.0']); + expect(parseNpmView(0, '["0.1.0","0.2.0"]\n', '')).toEqual([ + '0.1.0', + '0.2.0', + ]); + }); + + it('fails loudly on any other npm failure', () => { + expect(() => parseNpmView(1, '', 'npm error code ETIMEDOUT')).toThrow( + 'npm view failed (1): npm error code ETIMEDOUT' + ); + }); +}); diff --git a/scripts/__tests__/workflows.test.ts b/scripts/__tests__/workflows.test.ts new file mode 100644 index 0000000..3bd9eb3 --- /dev/null +++ b/scripts/__tests__/workflows.test.ts @@ -0,0 +1,327 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { parse } from 'yaml'; + +const PACKAGE = path.resolve(__dirname, '..', '..'); + +const read = (file: string): string => + fs.readFileSync(path.join(PACKAGE, file), 'utf8'); + +/** A YAML file as plain data; the shape each test reads is checked there. */ +const yamlOf = (file: string): Record<string, unknown> => { + const parsed: unknown = parse(read(file)); + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(`${file} isn't a YAML map`); + } + + return Object.fromEntries(Object.entries(parsed)); +}; + +const field = (value: unknown, ...keys: string[]): unknown => + keys.reduce<unknown>( + (at, key) => + typeof at === 'object' && at !== null ? Reflect.get(at, key) : undefined, + value + ); + +/** A job's `run:` lines, in order. */ +const runsOf = (workflow: Record<string, unknown>, job: string): string[] => { + const steps = field(workflow, 'jobs', job, 'steps'); + + return Array.isArray(steps) + ? steps + .map((step: unknown) => field(step, 'run')) + .filter((run): run is string => typeof run === 'string') + : []; +}; + +/** A job's steps, as plain data. */ +const stepsOf = (workflow: Record<string, unknown>, job: string): unknown[] => { + const steps = field(workflow, 'jobs', job, 'steps'); + + return Array.isArray(steps) ? steps : []; +}; + +/** Every job's name. */ +const jobsOf = (workflow: Record<string, unknown>): string[] => { + const jobs = field(workflow, 'jobs'); + + return typeof jobs === 'object' && jobs !== null ? Object.keys(jobs) : []; +}; + +/** Index of the first `run:` containing `text`; -1 when none does. */ +const stepIndex = (runs: string[], text: string): number => + runs.findIndex((run) => run.includes(text)); + +describe('the release workflow', () => { + const release = yamlOf('.github/workflows/release.yml'); + + it('publishes to npm only after the Mac app has built, so a failed build leaves nothing half-released', () => { + const build = runsOf(release, 'build'); + + expect(stepIndex(build, 'desktop/tools/package-mac.sh')).toBeGreaterThan( + -1 + ); + expect(field(release, 'jobs', 'publish', 'needs')).toEqual( + expect.arrayContaining(['build']) + ); + }); + + // Environment secrets reach only jobs that name the environment: without + // it the Apple secrets are empty and every release ships ad-hoc signed. + it('builds and tests in the release environment, with no id-token, and hands on only its artifacts', () => { + const runs = runsOf(release, 'build'); + + expect(field(release, 'jobs', 'build', 'environment')).toBe('release'); + expect(field(release, 'jobs', 'build', 'permissions')).toEqual({ + contents: 'read', + }); + expect(stepIndex(runs, 'npm test')).toBeGreaterThan(-1); + expect(stepIndex(runs, 'npm pack')).toBeGreaterThan(-1); + expect( + stepsOf(release, 'build').filter((step) => + String(field(step, 'uses')).startsWith('actions/upload-artifact@') + ) + ).toHaveLength(2); + }); + + // The step's only download (package-mac.sh's rebuild for Node) runs after + // the key file is deleted, with the secrets unset (packageMac.test.ts). + it('gives the Apple secrets only to the packaging step, which installs nothing itself', () => { + const secretSteps = jobsOf(release).flatMap((job) => { + expect( + JSON.stringify(field(release, 'jobs', job, 'env') ?? {}) + ).not.toContain('secrets.'); + + return stepsOf(release, job).filter((step) => + JSON.stringify(field(step, 'env') ?? {}).includes('secrets.APPLE') + ); + }); + + expect(secretSteps).toHaveLength(1); + const run = String(field(secretSteps[0], 'run')); + + expect(run).toContain('desktop/tools/package-mac.sh'); + expect(run).not.toMatch(/npm (ci|install)|fetch-tools\.sh|build:desktop/); + // Native modules are rebuilt for Electron in an earlier step, with no + // secrets, and the decoded notarisation key doesn't outlive the step. + expect(run).toContain('-c.npmRebuild=false'); + expect(run).toMatch(/trap 'rm -f "\$\{APPLE_API_KEY\}"' EXIT/); + const runs = runsOf(release, 'build'); + const rebuild = stepIndex(runs, 'electron-builder install-app-deps'); + + expect(rebuild).toBeGreaterThan(-1); + expect(rebuild).toBeLessThan( + stepIndex(runs, 'desktop/tools/package-mac.sh') + ); + }); + + it('only downloads and publishes in the job that can mint an id-token', () => { + const runs = runsOf(release, 'publish').join('\n'); + + expect(runs).not.toMatch(/npm (ci|test|run|pack)|npm install (?!-g npm)/); + expect( + stepsOf(release, 'publish').some((step) => + String(field(step, 'uses')).startsWith('actions/download-artifact@') + ) + ).toBe(true); + expect( + stepsOf(release, 'publish').some((step) => + String(field(step, 'uses')).startsWith('actions/checkout@') + ) + ).toBe(false); + expect(runs).toMatch( + /npm publish "\$\{TARBALL\}" --provenance --access public --ignore-scripts/ + ); + }); + + it('drafts the Release before npm publish and publishes it after, so a failure in between can be rerun', () => { + const runs = runsOf(release, 'publish'); + const draft = runs.findIndex( + (run) => run.includes('gh release create') && run.includes('--draft') + ); + const publish = stepIndex(runs, 'npm publish'); + const undraft = stepIndex(runs, 'gh release edit'); + + expect(draft).toBeGreaterThan(-1); + expect(publish).toBeGreaterThan(draft); + expect(undraft).toBeGreaterThan(publish); + expect(runs[undraft]).toContain('--draft=false'); + // A rerun finds the draft and the npm version already there. + expect(runs[draft]).toContain('gh release view'); + expect(runs[publish]).toContain('npm view'); + }); + + it.each(['release.yml', 'ci.yml'])( + '%s pins every action to a full commit SHA', + (file) => { + const workflow = yamlOf(`.github/workflows/${file}`); + const uses = jobsOf(workflow).flatMap((job) => + stepsOf(workflow, job) + .map((step) => field(step, 'uses')) + .filter((use): use is string => typeof use === 'string') + ); + + expect(uses.length).toBeGreaterThan(0); + uses.forEach((use) => + expect(use).toMatch(/^[\w-]+\/[\w-]+@[0-9a-f]{40}$/) + ); + } + ); + + it('builds through package-mac.sh, which ad-hoc signs and verifies, never switching signing off', () => { + const text = read('.github/workflows/release.yml'); + + expect(text).not.toContain('CSC_IDENTITY_AUTO_DISCOVERY'); + expect(text).not.toContain('npx electron-builder'); + }); + + it('installs from the lockfiles', () => { + const runs = runsOf(release, 'build'); + + expect(runs).toEqual( + expect.arrayContaining([ + 'npm ci --no-audit --no-fund', + expect.stringContaining( + 'npm ci --prefix desktop/kokoro --omit=dev --no-audit --no-fund' + ), + ]) + ); + expect(runs.join('\n')).not.toMatch(/npm install (?!-g npm)/); + expect(fs.existsSync(path.join(PACKAGE, 'package-lock.json'))).toBe(true); + expect( + fs.existsSync( + path.join(PACKAGE, 'desktop', 'kokoro', 'package-lock.json') + ) + ).toBe(true); + }); + + it('publishes only from the release environment, only when the version is new', () => { + expect(field(release, 'jobs', 'publish', 'environment')).toBe('release'); + expect(field(release, 'jobs', 'publish', 'permissions', 'id-token')).toBe( + 'write' + ); + expect(field(release, 'jobs', 'publish', 'needs')).toEqual([ + 'version', + 'build', + ]); + expect(field(release, 'jobs', 'publish', 'if')).toBe( + "needs.version.outputs.publish == 'true'" + ); + }); + + it('asks no approval for the version check: it has no environment', () => { + expect(field(release, 'jobs', 'version', 'environment')).toBeUndefined(); + expect(field(release, 'jobs', 'version', 'runs-on')).toBe('ubuntu-latest'); + }); + + it('carries no npm token, anywhere', () => { + const text = [ + read('.github/workflows/release.yml'), + read('.github/workflows/ci.yml'), + ].join('\n'); + + expect(text).not.toContain('NPM_TOKEN'); + expect(text).not.toContain('NODE_AUTH_TOKEN'); + }); + + it('publishes with provenance on npm 11.5.1 or later', () => { + const text = read('.github/workflows/release.yml'); + + expect(text).toContain('npm install -g npm@^11.5.1'); + expect(text).toContain('--provenance --access public'); + }); +}); + +describe('the CI workflow', () => { + it('builds, lints, typechecks and tests on push and pull request', () => { + const ci = yamlOf('.github/workflows/ci.yml'); + const steps = field(ci, 'jobs', 'check', 'steps'); + const runs = Array.isArray(steps) + ? steps.map((step: unknown) => field(step, 'run')).filter(Boolean) + : []; + + const on = field(ci, 'on'); + + expect( + Object.keys(typeof on === 'object' && on !== null ? on : {}).sort() + ).toEqual(['pull_request', 'push']); + expect(runs).toEqual( + expect.arrayContaining([ + 'npm run build', + 'npm run lint', + 'npm run typecheck', + 'npm test', + ]) + ); + }); + + // Some tests start a real Chromium, and some a real tmux server: both + // workflows need them before `npm test`, or every run is red. + it.each([ + ['ci.yml', 'check'], + ['release.yml', 'build'], + ])('%s installs Chromium and tmux before npm test', (file, job) => { + const runs = runsOf(yamlOf(`.github/workflows/${file}`), job); + const test = stepIndex(runs, 'npm test'); + const chromium = stepIndex(runs, 'playwright install --with-deps chromium'); + const tmux = runs.findIndex((run) => + /\b(apt-get install -y|brew install) tmux\b/.test(run) + ); + + expect(chromium).toBeGreaterThan(-1); + expect(chromium).toBeLessThan(test); + expect(tmux).toBeGreaterThan(-1); + expect(tmux).toBeLessThan(test); + }); +}); + +describe('electron-builder.yml', () => { + const builder = yamlOf('desktop/electron-builder.yml'); + + it('builds a dmg and a zip for arm64', () => { + const targets = field(builder, 'mac', 'target'); + + expect( + Array.isArray(targets) + ? targets.map((target: unknown) => ({ + target: field(target, 'target'), + arch: field(target, 'arch'), + })) + : [] + ).toEqual([ + { target: 'dmg', arch: ['arm64'] }, + { target: 'zip', arch: ['arm64'] }, + ]); + }); + + // Everything nested in the app (Electron's helpers, the pty's + // spawn-helper, tmux, git) is signed with the inherit file: only what the + // helpers need, never the main process's unsigned executable memory. + it('signs what the app carries with a narrower inherit file than the app', () => { + const inherit = field(builder, 'mac', 'entitlementsInherit'); + + expect(inherit).toBe('desktop/build/entitlements.mac.inherit.plist'); + expect(inherit).not.toBe(field(builder, 'mac', 'entitlements')); + + const keys = [ + ...read(String(inherit)).matchAll(/<key>([^<]+)<\/key>/g), + ].map((match) => match[1]); + + expect(keys).toEqual([ + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.disable-library-validation', + ]); + }); + + it("unpacks Kokoro's worker, the pty and the tools from app.asar", () => { + expect(builder.asarUnpack).toEqual( + expect.arrayContaining([ + 'dist/kokoroWorker.js', + 'node_modules/@lydell/node-pty/**', + 'tools/**', + ]) + ); + }); +}); diff --git a/scripts/shouldPublish.ts b/scripts/shouldPublish.ts new file mode 100644 index 0000000..6357316 --- /dev/null +++ b/scripts/shouldPublish.ts @@ -0,0 +1,93 @@ +/** + * Should this push to `main` publish? Only when package.json's version isn't + * on npm yet, so a push that doesn't bump the version sends no approval + * request. Run by release.yml's `version` job (`npx jiti + * scripts/shouldPublish.ts`), which writes `publish=true|false` to the job's + * outputs. + */ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** npm's answer for a package it has never seen. */ +export const NOT_ON_NPM = 'not-on-npm'; + +export const shouldPublish = ( + localVersion: string, + npmVersions: string[] | typeof NOT_ON_NPM +): boolean => npmVersions === NOT_ON_NPM || !npmVersions.includes(localVersion); + +/** `npm view <name> versions --json`, read; E404 means it isn't there at all. */ +export const parseNpmView = ( + status: number | null, + stdout: string, + stderr: string +): string[] | typeof NOT_ON_NPM => { + if (status !== 0) { + if (`${stdout}\n${stderr}`.includes('E404')) { + return NOT_ON_NPM; + } + + throw new Error(`npm view failed (${String(status)}): ${stderr.trim()}`); + } + + const parsed: unknown = JSON.parse(stdout); + + if (typeof parsed === 'string') { + return [parsed]; + } + + if (!Array.isArray(parsed)) { + throw new Error(`npm view answered something unexpected: ${stdout.trim()}`); + } + + return parsed.filter( + (version): version is string => typeof version === 'string' + ); +}; + +const readPackage = (): { name: string; version: string } => { + const parsed: unknown = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') + ); + + if ( + typeof parsed !== 'object' || + parsed === null || + !('name' in parsed) || + !('version' in parsed) || + typeof parsed.name !== 'string' || + typeof parsed.version !== 'string' + ) { + throw new Error('package.json has no name or version.'); + } + + return { name: parsed.name, version: parsed.version }; +}; + +const main = (): void => { + const { name, version } = readPackage(); + const view = spawnSync('npm', ['view', name, 'versions', '--json'], { + encoding: 'utf8', + }); + const publish = shouldPublish( + version, + parseNpmView(view.status, view.stdout, view.stderr) + ); + const line = `publish=${publish ? 'true' : 'false'}`; + const output = process.env.GITHUB_OUTPUT; + + if (output !== undefined && output !== '') { + fs.appendFileSync(output, `${line}\nversion=${version}\n`); + } + + console.log( + publish + ? `version ${version}: will publish` + : `version ${version} is already on npm: won't publish` + ); +}; + +if (process.argv[1]?.endsWith('shouldPublish.ts') === true) { + main(); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..3c5c07c --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "lib": ["ES2022"], + "types": ["jest", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..23f6a38 --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,73 @@ +import { type Command } from 'commander'; + +import { createProgram } from '../cli'; + +const BUILT_INS = { + init: 'Write lambda.json for this codebase', + doctor: 'Show where each command comes from', + tmux: "Send to or read an agent's window", + await: "Wait until an agent's window is done", + agent: 'Compact, clear or inspect an agent', + e2e: 'Run Playwright tests (traces kept on failure)', + trace: "Read a failed test's trace", + tickets: "Read and change the codebase's tickets", + planning: "Show this codebase's planning process", + building: "Show this codebase's building process", + pod: 'Run planning pods: a planning manager and a dev', + knowledge: 'Keep lasting judgements and copy rules', + start: "Start this codebase's services and Lambda", + stop: "Stop this codebase's services and pods", + open: 'Open Lambda in your browser, signed in', + join: 'Make this computer part of your Lambda', + leave: 'Take this computer out of your Lambda', + status: 'What this computer is doing for Lambda', + event: 'Record an event for the Analytics page', + account: 'The logins that count as you', + verdict: "Record or read a ticket's check-ticket verdict", + ship: 'Ship a ticket behind its verdict', +}; + +/** Parse in-process, capturing stdout instead of exiting. */ +const output = async (args: string[]): Promise<string> => { + const written: string[] = []; + const program: Command = createProgram(); + program.exitOverride(); + program.configureOutput({ + writeOut: (text) => { + written.splice(written.length, 0, text); + }, + writeErr: (text) => { + written.splice(written.length, 0, text); + }, + }); + + await program.parseAsync(['node', 'lambda', ...args]).catch(() => undefined); + + return written.join(''); +}; + +describe('the lambda program', () => { + it('prints its version', async () => { + expect((await output(['--version'])).trim()).toBe('0.2.0'); + }); + + it('lists the built-ins and where anything else goes', async () => { + const help = await output(['--help']); + + Object.entries(BUILT_INS).forEach(([name, description]) => + expect(help).toContain(` ${name.padEnd(9)} ${description}`) + ); + expect(help).toContain( + "Anything else runs your project's CLI (see lambda doctor)." + ); + }); + + it('lists ship and verdict, once reserved and now built in', async () => { + const commandLines = (await output(['--help'])) + .split('\n') + .filter((line) => /^ {2}\S/.test(line)) + .map((line) => line.trim().split(/\s+/)[0]); + + expect(commandLines).toEqual(expect.arrayContaining(['ship', 'verdict'])); + }); +}); diff --git a/src/__tests__/clientLint.test.ts b/src/__tests__/clientLint.test.ts new file mode 100644 index 0000000..b23c681 --- /dev/null +++ b/src/__tests__/clientLint.test.ts @@ -0,0 +1,88 @@ +import * as path from 'path'; + +/** What the test needs of ESLint's API (the package ships no types). */ +interface ESLintApi { + ESLint: new (options: { cwd: string }) => { + lintText: ( + code: string, + options: { filePath: string } + ) => Promise<{ messages: { ruleId: string | null; severity: number }[] }[]>; + }; +} + +const { ESLint } = jest.requireActual<ESLintApi>('eslint'); + +const root = path.resolve(__dirname, '..', '..'); + +/** + * The rules a line breaks, linted for real as if it were in `file` (an + * existing file, so the TypeScript project knows it; only the text is new). + */ +const brokenBy = async (code: string, file: string): Promise<string[]> => { + const results = await new ESLint({ cwd: root }).lintText(`${code}\n`, { + filePath: path.join(root, file), + }); + + return ( + results + .flatMap((result) => result.messages) + .filter((message) => message.severity === 2) + .map((message) => message.ruleId ?? 'parse error') + // The core rule and typescript-eslint's are one rule here. + .map((rule) => rule.replace('@typescript-eslint/', '')) + ); +}; + +const CLIENT = 'src/app/client/chime.ts'; + +// Built from parts so packageRules' own "imports nothing from the monorepo" +// scan doesn't take this fixture for a real import. +const MONOREPO_IMPORT = `import { theme } from '${'@re'}po/config';`; + +describe('the app’s client, which runs in the browser', () => { + // Typed linting builds the project once: allow for a cold start. + jest.setTimeout(60_000); + + it.each([ + ["import * as fs from 'fs';", 'no-restricted-imports'], + ["import { readFileSync } from 'node:fs';", 'no-restricted-imports'], + [MONOREPO_IMPORT, 'no-restricted-imports'], + ['export const port = process.env.PORT;', 'no-restricted-globals'], + ['export const here = __dirname;', 'no-restricted-globals'], + ])('refuses %s', async (code, rule) => { + expect(await brokenBy(code, CLIENT)).toContain(rule); + }); + + it.each([ + "import { podFileRoute } from '../server/podFile';", + "import { createRelay } from '../../mothership/relay';", + "import { startRunner } from '../../runner/runtime';", + "import { openStore } from '../../pod-store';", + ])('refuses server code: %s', async (code) => { + expect(await brokenBy(code, CLIENT)).toContain('no-restricted-imports'); + }); + + it('lets it share the server’s types', async () => { + expect( + await brokenBy( + "import type { Relay } from '../../mothership/relay';\nimport type { Pod } from '../../pod-store';\nexport type Both = [Relay, Pod];", + CLIENT + ) + ).toEqual([]); + }); + + it('leaves the server side its Node', async () => { + expect( + await brokenBy( + "import * as fs from 'fs';\nexport const port = process.env.PORT ?? fs.constants.F_OK;", + 'src/mothership/http.ts' + ) + ).toEqual([]); + }); + + it('keeps the server standalone too', async () => { + expect(await brokenBy(MONOREPO_IMPORT, 'src/mothership/http.ts')).toContain( + 'no-restricted-imports' + ); + }); +}); diff --git a/src/__tests__/dispatch.test.ts b/src/__tests__/dispatch.test.ts new file mode 100644 index 0000000..b0bec36 --- /dev/null +++ b/src/__tests__/dispatch.test.ts @@ -0,0 +1,207 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { run } from '../cli'; + +const write = (file: string, text: string, mode = 0o644): void => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + fs.chmodSync(file, mode); +}; + +/** A project whose CLI writes its argv to out.json and exits 3. */ +const recordingProject = (): { root: string; out: string } => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-dispatch-')) + ); + const out = path.join(root, 'out.json'); + write(path.join(root, 'lambda.json'), JSON.stringify({ cli: './cli.js' })); + write( + path.join(root, 'cli.js'), + `#!/usr/bin/env node +require('fs').writeFileSync(${JSON.stringify( + out + )}, JSON.stringify(process.argv.slice(2))); +process.exit(3); +`, + 0o755 + ); + + return { root, out }; +}; + +describe('dispatch before commander parses', () => { + const savedCwd = process.cwd(); + const savedEnv = { ...process.env }; + const logged: string[] = []; + const errors: string[] = []; + + beforeEach(() => { + logged.length = 0; + errors.length = 0; + delete process.env.LAMBDA_FORWARDED; + process.env.LAMBDA_HOME = fs.mkdtempSync( + path.join(os.tmpdir(), 'lambda-home-') + ); + jest.spyOn(console, 'log').mockImplementation((line: string) => { + logged.splice(logged.length, 0, line); + }); + jest.spyOn(console, 'error').mockImplementation((line: string) => { + errors.splice(errors.length, 0, line); + }); + }); + + afterEach(() => { + process.chdir(savedCwd); + process.env = { ...savedEnv }; + process.exitCode = undefined; + jest.restoreAllMocks(); + }); + + it.each([[['help', 'log']], [['-x', 'log']]])( + 'hands %j to the codebase CLI untouched', + async (args) => { + const { root, out } = recordingProject(); + process.chdir(root); + + expect(await run(['node', 'lambda', ...args])).toBe(3); + expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual(args); + } + ); + + it('still runs doctor over a refused doctor drop-in, and lists it', async () => { + const { root } = recordingProject(); + write( + path.join(root, '.lambda', 'commands', 'doctor.ts'), + "throw new Error('the doctor drop-in was loaded');\n" + ); + process.chdir(root); + + expect(await run(['node', 'lambda', 'doctor'])).toBe(0); + expect(logged).toContain( + ' doctor refused drop-in: .lambda/commands/doctor.ts' + ); + expect([...logged, ...errors].join('\n')).not.toContain( + 'the doctor drop-in was loaded' + ); + }); + + it('refuses a core-name drop-in without loading it', async () => { + const { root, out } = recordingProject(); + write( + path.join(root, '.lambda', 'commands', 'pod.ts'), + "throw new Error('the pod drop-in was loaded');\n" + ); + process.chdir(root); + + expect(await run(['node', 'lambda', 'pod'])).toBe(1); + expect(errors).toEqual([ + `Refused drop-in ${path.join( + '.lambda', + 'commands', + 'pod.ts' + )}: pod is a core command and can't be replaced.`, + ]); + expect([...errors, ...logged].join('\n')).not.toContain( + 'the pod drop-in was loaded' + ); + expect(fs.existsSync(out)).toBe(false); + }); + + it('refuses a verdict drop-in: the ship gate reads only the real verdict', async () => { + const { root, out } = recordingProject(); + write( + path.join(root, '.lambda', 'commands', 'verdict.ts'), + "throw new Error('the verdict drop-in was loaded');\n" + ); + process.chdir(root); + + expect(await run(['node', 'lambda', 'verdict', 'record', 'PAWNEE-1'])).toBe( + 1 + ); + expect(errors).toEqual([ + `Refused drop-in ${path.join( + '.lambda', + 'commands', + 'verdict.ts' + )}: verdict is a core command and can't be replaced.`, + ]); + expect([...errors, ...logged].join('\n')).not.toContain( + 'the verdict drop-in was loaded' + ); + expect(fs.existsSync(out)).toBe(false); + }); + + it.each(['planning', 'building'])( + 'refuses a %s drop-in: the built-in always runs', + async (name) => { + const { root, out } = recordingProject(); + write( + path.join(root, '.lambda', 'commands', `${name}.ts`), + `throw new Error('the ${name} drop-in was loaded');\n` + ); + process.chdir(root); + + expect(await run(['node', 'lambda', name, 'steps'])).toBe(1); + expect(errors).toEqual([ + `Refused drop-in ${path.join( + '.lambda', + 'commands', + `${name}.ts` + )}: ${name} is a core command and can't be replaced.`, + ]); + expect([...errors, ...logged].join('\n')).not.toContain( + `the ${name} drop-in was loaded` + ); + expect(fs.existsSync(out)).toBe(false); + } + ); + + it('exits 1 on an invalid lambda.json, naming the file on stderr', async () => { + const { root, out } = recordingProject(); + write( + path.join(root, 'lambda.json'), + '{ "tickets": { "source": "trello" } }' + ); + process.chdir(root); + + expect(await run(['node', 'lambda', 'log'])).toBe(1); + expect(errors).toEqual([ + expect.stringMatching( + /^lambda\.json: tickets\.source "trello" isn't one of / + ), + ]); + expect(fs.existsSync(out)).toBe(false); + }); + + it('exits 1 on a lambda.json that is not JSON', async () => { + const { root } = recordingProject(); + write(path.join(root, 'lambda.json'), '{ "cli": '); + process.chdir(root); + + expect(await run(['node', 'lambda', 'e2e'])).toBe(1); + expect(errors).toEqual([ + expect.stringMatching(/^Invalid JSON in .*lambda\.json: /), + ]); + }); + + it('exits 1 on an invalid machine file, naming the file on stderr', async () => { + const { root, out } = recordingProject(); + const home = process.env.LAMBDA_HOME ?? ''; + write( + path.join(home, 'config.json'), + JSON.stringify({ owner: { name: 7 } }) + ); + process.chdir(root); + + expect(await run(['node', 'lambda', 'log'])).toBe(1); + expect(errors).toEqual([ + `${path.join( + home, + 'config.json' + )}: owner.name expected string, received number`, + ]); + expect(fs.existsSync(out)).toBe(false); + }); +}); diff --git a/src/__tests__/machineHome.ts b/src/__tests__/machineHome.ts new file mode 100644 index 0000000..685c332 --- /dev/null +++ b/src/__tests__/machineHome.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { MachineConfig } from '../config/machineConfig'; + +const made: string[] = []; + +// Each test file that imports this removes the homes its tests made. +afterEach(() => + made + .splice(0) + .forEach((home) => fs.rmSync(home, { recursive: true, force: true })) +); + +/** A LAMBDA_HOME of its own holding `config`, as an env for one test. */ +export const machineHome = (config: MachineConfig): { LAMBDA_HOME: string } => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-machine-')); + made.splice(made.length, 0, home); + fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify(config), { + mode: 0o600, + }); + + return { LAMBDA_HOME: home }; +}; + +/** A real folder, since setup skips a project whose folder is gone. */ +const pawnee = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-pawnee-')); + +afterAll(() => fs.rmSync(pawnee, { recursive: true, force: true })); + +/** First run finished: Claude connected, one project with its tickets. */ +export const SETUP_DONE: MachineConfig = { + firstRun: { + claude: true, + projects: { [pawnee]: { project: true, tickets: true } }, + }, +}; diff --git a/src/__tests__/no-github.test.ts b/src/__tests__/no-github.test.ts new file mode 100644 index 0000000..0bb21bc --- /dev/null +++ b/src/__tests__/no-github.test.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Lambda never reads GitHub: no file in the mothership, the runner or events + * names its API or runs `gh`. + */ +const GITHUB = /api\.github\.com|['"`]gh['"`\s]/; + +const SRC = path.resolve(__dirname, '..'); +const DIRS = ['mothership', 'runner', 'events']; + +const filesUnder = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + + return entry.isDirectory() ? filesUnder(full) : [full]; + }); + +describe('no GitHub', () => { + it('matches what it should, and not a word that ends in gh', () => { + expect(GITHUB.test("execFile('gh', ['pr'])")).toBe(true); + expect(GITHUB.test('`gh pr view`')).toBe(true); + expect(GITHUB.test('fetch("https://api.github.com/repos")')).toBe(true); + expect(GITHUB.test('enough time')).toBe(false); + }); + + it('appears nowhere in the mothership, the runner or events', () => { + const hits = DIRS.flatMap((dir) => filesUnder(path.join(SRC, dir))).filter( + (file) => GITHUB.test(fs.readFileSync(file, 'utf8')) + ); + + expect(hits).toEqual([]); + }); +}); diff --git a/src/__tests__/packageRules.test.ts b/src/__tests__/packageRules.test.ts new file mode 100644 index 0000000..d39cac0 --- /dev/null +++ b/src/__tests__/packageRules.test.ts @@ -0,0 +1,121 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { VERSION } from '../cli'; + +const root = path.resolve(__dirname, '..', '..'); + +const readJson = (file: string): Record<string, unknown> => + JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); + +const record = (value: unknown): Record<string, string> => + typeof value === 'object' && value !== null + ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, String(v)])) + : {}; + +const sourceFiles = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return sourceFiles(full); + } + + return entry.name.endsWith('.ts') ? [full] : []; + }); + +describe('package rules', () => { + const pkg = readJson('package.json'); + const scripts = record(pkg.scripts); + + it('is the published Lambda package', () => { + expect(pkg.name).toBe('@adriancarriger/lambda'); + expect(pkg.version).toBe('0.2.0'); + expect(VERSION).toBe(pkg.version); + expect(pkg.license).toBe('MIT'); + expect(pkg.bin).toEqual({ lambda: './bin/lambda' }); + expect(scripts).toMatchObject({ + // The CLI and services (tsup), then the app's client (Vite). + build: 'tsup && vite build', + lint: "eslint --cache --max-warnings 0 'src/**/*.{ts,tsx}' 'desktop/*.ts' 'scripts/**/*.ts'", + // The client (DOM), then the Node code on its own, without DOM globals, + // then the Mac app's main process and the release scripts. + typecheck: + 'tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p desktop/tsconfig.json && tsc --noEmit -p scripts/tsconfig.json', + test: 'jest', + }); + }); + + it('names the public repo, which npm checks a provenance publish against', () => { + // release.yml publishes with --provenance from adriancarriger/lambda; + // npm refuses it when repository.url names any other repo, or none. + expect(record(pkg.repository)).toEqual({ + type: 'git', + url: 'git+https://github.com/adriancarriger/lambda.git', + }); + expect(pkg.homepage).toBe( + 'https://github.com/adriancarriger/lambda#readme' + ); + expect(record(pkg.bugs)).toEqual({ + url: 'https://github.com/adriancarriger/lambda/issues', + }); + // The author is the LICENSE's copyright holder. + expect(fs.readFileSync(path.join(root, 'LICENSE'), 'utf8')).toContain( + `Copyright (c) 2026 ${String(pkg.author)}\n` + ); + }); + + it("keeps the original demo in examples/, out of what npm ships", () => { + expect(fs.existsSync(path.join(root, 'examples', 'README.md'))).toBe(true); + expect(fs.existsSync(path.join(root, 'examples', 'package.json'))).toBe( + true + ); + expect(Array.isArray(pkg.files) ? pkg.files : []).not.toContain('examples'); + }); + + it('depends on nothing in the monorepo', () => { + const versions = [ + pkg.dependencies, + pkg.devDependencies, + pkg.peerDependencies, + ].flatMap((deps) => Object.entries(record(deps))); + + expect( + versions.filter( + ([name, version]) => + version.startsWith('workspace:') || name.startsWith('@repo/') + ) + ).toEqual([]); + }); + + it('never links itself globally', () => { + expect( + Object.values(scripts).filter((script) => script.includes('link')) + ).toEqual([]); + }); + + it('imports nothing from the monorepo', () => { + const hits = sourceFiles(path.join(root, 'src')).filter((file) => + /from '@repo\//.test(fs.readFileSync(file, 'utf8')) + ); + + expect(hits).toEqual([]); + }); + + it('has a standalone tsconfig over src only', () => { + const tsconfig = readJson('tsconfig.json'); + + expect(tsconfig).not.toHaveProperty('extends'); + expect(record(tsconfig.compilerOptions).rootDir).toBe('src'); + expect(tsconfig.include).toEqual(['src']); + }); + + it('ships an await-pane server that runs lambda await', () => { + const server = fs.readFileSync( + path.join(root, 'tools', 'await-pane-server.cjs'), + 'utf8' + ); + + expect(server).toContain("execFileSync('lambda'"); + }); +}); diff --git a/src/__tests__/required.ts b/src/__tests__/required.ts new file mode 100644 index 0000000..e656fac --- /dev/null +++ b/src/__tests__/required.ts @@ -0,0 +1,12 @@ +/** + * A value a `beforeEach` sets for each test. Tests keep such values on a + * holder object (no `let`) and read them through this, which fails loudly if + * one is read before the hook ran. + */ +export const required = <T>(value: T | undefined, name: string): T => { + if (value === undefined) { + throw new Error(`${name} is set in beforeEach; read it inside a test.`); + } + + return value; +}; diff --git a/src/__tests__/runCli.ts b/src/__tests__/runCli.ts new file mode 100644 index 0000000..b79182c --- /dev/null +++ b/src/__tests__/runCli.ts @@ -0,0 +1,41 @@ +import { run } from '../cli'; + +/** + * `lambda <args>` in-process, as `bin/lambda` runs it, with some environment + * set for the run: its exit code and what it printed (stdout and stderr). + */ +export const runCli = async ( + args: string[], + env: Record<string, string> +): Promise<{ code: number; out: string; err: string }> => { + const saved = Object.fromEntries( + Object.keys(env).map((key) => [key, process.env[key]]) + ); + const printed: { out: string[]; err: string[] } = { out: [], err: [] }; + const log = jest.spyOn(console, 'log').mockImplementation((line: unknown) => { + printed.out = [...printed.out, String(line)]; + }); + const error = jest + .spyOn(console, 'error') + .mockImplementation((line: unknown) => { + printed.err = [...printed.err, String(line)]; + }); + Object.assign(process.env, env); + + try { + const code = await run(['node', 'lambda', ...args]); + + return { code, out: printed.out.join('\n'), err: printed.err.join('\n') }; + } finally { + log.mockRestore(); + error.mockRestore(); + Object.entries(saved).forEach(([key, value]) => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); + process.exitCode = undefined; + } +}; diff --git a/src/app/__tests__/analyticsPage.test.ts b/src/app/__tests__/analyticsPage.test.ts new file mode 100644 index 0000000..2832e36 --- /dev/null +++ b/src/app/__tests__/analyticsPage.test.ts @@ -0,0 +1,19 @@ +import { minutes } from '../client/AnalyticsPage'; + +describe('Analytics’ times', () => { + it('says under a minute rather than rounding a short gap up to one', () => { + expect(minutes(0)).toBe('<1 min'); + expect(minutes(25_000)).toBe('<1 min'); + expect(minutes(59_999)).toBe('<1 min'); + }); + + it('rounds a minute or more to whole minutes', () => { + expect(minutes(60_000)).toBe('1 min'); + expect(minutes(150_000)).toBe('3 min'); + expect(minutes(47 * 60_000)).toBe('47 min'); + }); + + it('shows a dash when there is nothing to time (no failed attempt)', () => { + expect(minutes(null)).toBe('—'); + }); +}); diff --git a/src/app/__tests__/autosave.test.ts b/src/app/__tests__/autosave.test.ts new file mode 100644 index 0000000..b4c9878 --- /dev/null +++ b/src/app/__tests__/autosave.test.ts @@ -0,0 +1,123 @@ +import { + AUTOSAVE_PAUSE_MS, + createAutosave, + tidyMarkdown, +} from '../shared/autosave'; + +/** A description saves itself as you type, the way Linear's does. */ + +const setUp = (initial = 'Old words.', fail = false) => { + const timers: Array<{ run: () => void; at: number; live: boolean }> = []; + const clock = { now: 0 }; + const saves: string[] = []; + const pending: Array<() => void> = []; + const autosave = createAutosave( + initial, + (text) => + new Promise<void>((resolve, reject) => { + saves.splice(saves.length, 0, text); + pending.splice(pending.length, 0, () => + fail ? reject(new Error('offline')) : resolve() + ); + }), + { + set: (run, ms) => { + const timer = { run, at: clock.now + ms, live: true }; + timers.splice(timers.length, 0, timer); + + return timer; + }, + clear: (handle) => { + const timer = timers.find((one) => one === handle); + + if (timer !== undefined) { + timer.live = false; + } + }, + } + ); + const pass = (ms: number): void => { + clock.now += ms; + timers + .filter((timer) => timer.live && timer.at <= clock.now) + .forEach((timer) => { + timer.live = false; + timer.run(); + }); + }; + const settle = async (): Promise<void> => { + pending.splice(0).forEach((done) => done()); + await new Promise((resolve) => setImmediate(resolve)); + }; + + return { autosave, saves, pass, settle }; +}; + +describe('saving a description as you type', () => { + it('saves once typing pauses, with the newest text', () => { + const { autosave, saves, pass } = setUp(); + autosave.changed('O'); + pass(300); + autosave.changed('One'); + pass(AUTOSAVE_PAUSE_MS - 1); + + expect(saves).toEqual([]); + + pass(1); + + expect(saves).toEqual(['One']); + }); + + it('saves at once on leaving the box, and nothing when nothing changed', async () => { + const { autosave, saves, pass, settle } = setUp(); + autosave.changed('Old words.'); + autosave.flush(); + pass(AUTOSAVE_PAUSE_MS); + + expect(saves).toEqual([]); + + autosave.changed('New words.'); + autosave.flush(); + await settle(); + + expect(saves).toEqual(['New words.']); + expect(autosave.busy()).toBe(false); + }); + + it('sends what was typed during a save once that save is back', async () => { + const { autosave, saves, pass, settle } = setUp(); + autosave.changed('First'); + pass(AUTOSAVE_PAUSE_MS); + autosave.changed('Second'); + autosave.flush(); + + // One save at a time. + expect(saves).toEqual(['First']); + + await settle(); + + expect(saves).toEqual(['First', 'Second']); + }); + + it('keeps a failed save to try again, and reads as busy meanwhile', async () => { + const { autosave, saves, pass, settle } = setUp('Old words.', true); + autosave.changed('New words.'); + pass(AUTOSAVE_PAUSE_MS); + await settle(); + + expect(saves).toEqual(['New words.']); + expect(autosave.busy()).toBe(true); + }); +}); + +describe("the editor's Markdown, tidied", () => { + it('keeps bare links and emails bare, and one blank line between blocks', () => { + expect( + tidyMarkdown( + 'Write [parks@pawnee.gov](mailto:parks@pawnee.gov) or see [https://pawnee.gov](https://pawnee.gov) and [the site](https://pawnee.gov).\n\n\n| a |\n' + ) + ).toBe( + 'Write parks@pawnee.gov or see https://pawnee.gov and [the site](https://pawnee.gov).\n\n| a |' + ); + }); +}); diff --git a/src/app/__tests__/buildTimeline.test.ts b/src/app/__tests__/buildTimeline.test.ts new file mode 100644 index 0000000..7300b99 --- /dev/null +++ b/src/app/__tests__/buildTimeline.test.ts @@ -0,0 +1,609 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { buildingPodStore } from '../../building/__tests__/fakes'; +import { + copyRoundToPod, + readVisualRounds, +} from '../../building/visualPictures'; +import { writePng } from '../../design/__tests__/pngFixture'; +import { VisualPairs } from '../client/BuildingPodScreen'; +import { + shippedTicketDurations, + thisTicketMs, + ticketTimeline, + typicalTicketMs, + wholeRun, +} from '../shared/buildTimeline'; +import { type TimelineEvent, visualRail } from '../shared/building'; + +// react-markdown is ESM-only, and the terminal pulls in xterm's CSS. +jest.mock('../client/Message', () => ({ Markdown: () => null })); +jest.mock('../client/TerminalView', () => ({ TerminalView: () => null })); + +const MIN = 60_000; + +const STEPS = [ + { id: 'pick-up', label: 'Pick up the next ticket' }, + { id: 'build', label: 'Build it' }, + { id: 'self-review', label: 'Review the change' }, + { id: 'check-ticket', label: 'Check against the ticket' }, + { id: 'test', label: 'Test it' }, + { id: 'ship', label: 'Ship it' }, +]; + +const event = ( + type: string, + minute: number, + extra: Partial<TimelineEvent> = {} +): TimelineEvent => ({ + type, + name: null, + ticket: '3', + attempt: null, + pod: 'pawnee-build-a1b2', + result: null, + at: minute * MIN, + ...extra, +}); + +const step = ( + name: string, + from: number, + to: number | null, + attempt = 1, + ticket = '3' +): TimelineEvent[] => [ + event('stage.start', from, { name, attempt, ticket }), + ...(to === null ? [] : [event('stage.end', to, { name, attempt, ticket })]), +]; + +/** One ticket, pick-up to ship, as the ticket's Test Plan fixes it. */ +const PICKUP_TIMES = [ + ...step('pick-up', 0, 1), + ...step('build', 1, 13), + ...step('self-review', 13, 16), + ...step('check-ticket', 16, 18), + ...step('test', 18, 22, 1), + ...step('test', 25, 32, 2), + ...step('ship', 32, 36), + event('ship.start', 32), + event('stage.start', 32, { name: 'security-review' }), + event('attempt.start', 32, { attempt: 1 }), + event('stage.end', 34, { name: 'security-review' }), + event('attempt.end', 34, { attempt: 1, result: 'fail' }), + event('attempt.start', 35, { attempt: 2 }), + event('attempt.end', 36, { attempt: 2, result: 'pass' }), + event('ship.end', 36), +]; + +/** A shipped ticket of `minutes`, pick-up start to ship done. */ +const shippedTicket = (ticket: string, minutes: number): TimelineEvent[] => [ + ...step('pick-up', 0, 1, 1, ticket), + ...step('ship', minutes - 1, minutes, 1, ticket), +]; + +describe('the Workflow rail’s timeline', () => { + it('shows a step done that ended without a reported start, as Ship did', () => { + const events = [ + ...step('pick-up', 0, 1), + ...step('build', 1, 2), + // `lambda ship` closed it; the Engineering manager never said it began. + event('stage.end', 3, { name: 'ship', attempt: 1 }), + ]; + const of = (id: string) => + ticketTimeline(events, '3', STEPS).steps.find((one) => one.id === id); + + expect(of('ship')).toEqual( + expect.objectContaining({ status: 'done', ms: 0 }) + ); + expect(of('test')?.status).toBe('todo'); + }); + + it('times each step of the current ticket, retries summed', () => { + const timeline = ticketTimeline(PICKUP_TIMES, '3', STEPS); + const of = (id: string) => timeline.steps.find((one) => one.id === id); + + expect(of('build')?.ms).toBe(12 * MIN); + expect(of('test')?.ms).toBe(11 * MIN); + expect(of('test')?.label).toBe('Test · attempt 2'); + expect(of('build')?.label).toBe('Build'); + }); + + it('opens Ship into its stages, in the order first reported', () => { + expect(ticketTimeline(PICKUP_TIMES, '3', STEPS).shipStages).toEqual([ + 'Attempt 2', + 'Security review 2m', + 'Fixing 1m', + ]); + }); + + it('opens Ship into the latest ship run, not a failed earlier one', () => { + const reshipped = [ + ...step('pick-up', 0, 1), + event('ship.start', 10), + event('stage.start', 10, { name: 'security-review' }), + event('stage.end', 12, { name: 'security-review' }), + event('ship.end', 12, { result: 'fail' }), + event('ship.start', 20), + event('stage.start', 20, { name: 'deploy' }), + event('stage.end', 25, { name: 'deploy' }), + event('ship.end', 25, { result: 'pass' }), + ]; + + expect(ticketTimeline(reshipped, '3', STEPS).shipStages).toEqual([ + 'Deploy 5m', + ]); + }); + + it('runs a ticket from its pick-up start to its ship done', () => { + expect(ticketTimeline(PICKUP_TIMES, '3', STEPS).totalMs).toBe(36 * MIN); + }); + + it('runs a ticket blocked and taken again from its last pick-up, not its first', () => { + const DAY = 24 * 60; + const retaken = [ + ...step('pick-up', 0, 1), + ...step('build', 1, 10), + // Blocked on day 1, set back to Ready, taken again on day 3. + ...step('pick-up', 2 * DAY, 2 * DAY + 1), + ...step('build', 2 * DAY + 1, 2 * DAY + 12), + ...step('ship', 2 * DAY + 12, 2 * DAY + 20), + ]; + + expect(ticketTimeline(retaken, '3', STEPS).totalMs).toBe(20 * MIN); + expect(shippedTicketDurations(retaken)).toEqual([20 * MIN]); + }); + + it('marks the active step while a ticket is still going', () => { + const going = [...step('pick-up', 0, 1), ...step('build', 1, null)]; + const timeline = ticketTimeline(going, '3', STEPS, 5 * MIN); + + expect(timeline.steps.map((one) => [one.id, one.status, one.ms])).toEqual([ + ['pick-up', 'done', 1 * MIN], + ['build', 'active', 4 * MIN], + ['self-review', 'todo', 0], + ['check-ticket', 'todo', 0], + ['test', 'todo', 0], + ['ship', 'todo', 0], + ]); + expect(timeline.totalMs).toBeNull(); + }); +}); + +describe('This ticket', () => { + const run = { + startedAt: new Date(0).toISOString(), + }; + + it('is the pick-up-to-ship time once it has shipped', () => { + expect(thisTicketMs(run, 36 * MIN, 500 * MIN)).toBe(36 * MIN); + }); + + it('counts up to now while it is going', () => { + expect(thisTicketMs(run, null, 30 * MIN)).toBe(30 * MIN); + }); + + it('stops at the moment a ticket was blocked', () => { + const blocked = { + ...run, + blocked: { at: new Date(30 * MIN).toISOString() }, + }; + + expect(thisTicketMs(blocked, null, 250 * MIN)).toBe(30 * MIN); + }); +}); + +describe('Typical ticket', () => { + it('is not shown with one shipped ticket', () => { + expect( + typicalTicketMs(shippedTicketDurations(shippedTicket('1', 18))) + ).toBeNull(); + }); + + it('is the median of the shipped tickets, the mean of the middle two for an even count', () => { + const four = [ + ...shippedTicket('1', 18), + ...shippedTicket('2', 33), + ...shippedTicket('3', 24), + ...shippedTicket('4', 40), + ]; + const three = [ + ...shippedTicket('1', 18), + ...shippedTicket('2', 33), + ...shippedTicket('3', 24), + ]; + + expect(typicalTicketMs(shippedTicketDurations(four))).toBe(28.5 * MIN); + expect(typicalTicketMs(shippedTicketDurations(three))).toBe(24 * MIN); + }); + + it('counts only shipped tickets', () => { + expect( + shippedTicketDurations([ + ...shippedTicket('1', 18), + ...step('pick-up', 0, 1, 1, '2'), + ]) + ).toEqual([18 * MIN]); + }); +}); + +describe('Whole run', () => { + it('shows each step’s share of the time across every ticket so far', () => { + const events = [ + ...step('build', 0, 10, 1, '1'), + ...step('test', 10, 15, 1, '1'), + ...step('build', 20, 40, 1, '2'), + ...step('test', 40, 55, 1, '2'), + ]; + const run = wholeRun(events, ['1', '2'], STEPS); + + expect(run.totalMs).toBe(50 * MIN); + expect( + run.steps + .filter((one) => one.ms > 0) + .map((one) => [one.label, one.percent]) + ).toEqual([ + ['Build', 60], + ['Test', 40], + ]); + }); + + it('leaves out tickets this pod never took', () => { + const events = [ + ...step('build', 0, 10, 1, '1'), + ...step('build', 0, 30, 1, '9'), + ]; + + expect(wholeRun(events, ['1'], STEPS).totalMs).toBe(10 * MIN); + }); +}); + +describe("the default ship's own parts", () => { + // The test step, then Ship: `lambda ship` runs the codebase's tests (a + // `test` stage) and commits, inside its ship.start … ship.end. + const withDefaultShip = (shipEnded: boolean): TimelineEvent[] => [ + ...step('pick-up', 0, 1), + ...step('test', 1, 6), + ...step('ship', 10, shipEnded ? 20 : null), + event('ship.start', 11, { attempt: 1 }), + event('stage.start', 11, { name: 'test', attempt: 1 }), + event('stage.end', 15, { name: 'test', attempt: 1 }), + event('stage.start', 15, { name: 'commit', attempt: 1 }), + event('stage.end', 16, { name: 'commit', attempt: 1 }), + ...(shipEnded + ? [event('ship.end', 17, { attempt: 1, result: 'pass' })] + : []), + ]; + + it("count toward Ship, not the Test step's time or attempts", () => { + const timeline = ticketTimeline( + withDefaultShip(true), + '3', + STEPS, + 30 * MIN + ); + const test = timeline.steps.find((one) => one.id === 'test'); + + expect(test).toMatchObject({ label: 'Test', ms: 5 * MIN, attempt: 1 }); + expect(timeline.shipStages).toEqual(['Test 4m', 'Commit 1m']); + }); + + it("don't make Test look active while Ship is running", () => { + const timeline = ticketTimeline( + withDefaultShip(false), + '3', + STEPS, + 15 * MIN + ); + + expect(timeline.steps.find((one) => one.id === 'test')?.status).toBe( + 'done' + ); + expect(timeline.steps.find((one) => one.id === 'ship')?.status).toBe( + 'active' + ); + }); + + it('end at the next ship or pick-up when a ship run never reported its end', () => { + // The ship run died before its ship.end; the ticket was taken again. + const crashed: TimelineEvent[] = [ + ...step('pick-up', 0, 1), + ...step('ship', 10, 12), + event('ship.start', 11, { attempt: 1 }), + event('stage.start', 11, { name: 'test', attempt: 1 }), + ...step('pick-up', 40, 41, 2), + ...step('test', 41, 47, 2), + ]; + const timeline = ticketTimeline(crashed, '3', STEPS, 50 * MIN); + + expect(timeline.steps.find((one) => one.id === 'test')?.ms).toBe(6 * MIN); + expect( + wholeRun(crashed, ['3'], STEPS, 50 * MIN).steps.find( + (one) => one.id === 'test' + )?.ms + ).toBe(6 * MIN); + }); + + it('end with the Ship step when a ship run never reported its end', () => { + // The ship run died; the Ship step ended and the ticket went back to Test. + const failed: TimelineEvent[] = [ + ...step('pick-up', 0, 1), + ...step('ship', 10, 15), + event('ship.start', 11, { attempt: 1 }), + ...step('test', 12, 13, 1), + ...step('test', 20, 25, 2), + ]; + const timeline = ticketTimeline(failed, '3', STEPS, 30 * MIN); + + expect(timeline.steps.find((one) => one.id === 'test')).toMatchObject({ + ms: 5 * MIN, + status: 'done', + }); + expect(timeline.shipStages).toEqual(['Test 1m']); + }); + + it('end at the next ship.start when a ship run never reported its end', () => { + const retried: TimelineEvent[] = [ + ...step('pick-up', 0, 1), + ...step('ship', 10, null), + event('ship.start', 11, { attempt: 1 }), + ...step('test', 12, 13, 1), + event('ship.start', 20, { attempt: 2 }), + ...step('test', 21, 25, 2), + ]; + const timeline = ticketTimeline(retried, '3', STEPS, 40 * MIN); + + expect(timeline.steps.find((one) => one.id === 'test')?.ms).toBe(0); + expect(timeline.shipStages).toEqual(['Test 4m']); + }); + + it("aren't counted as Test in the whole run", () => { + const run = wholeRun(withDefaultShip(true), ['3'], STEPS, 30 * MIN); + + expect(run.steps.find((one) => one.id === 'test')?.ms).toBe(5 * MIN); + }); +}); + +describe('a run of the ticket, not every event with its id', () => { + const at = (minute: number) => new Date(minute * MIN).toISOString(); + // Run 1 stalled mid-Test (no stage.end) and went blocked at minute 10; + // set back to Ready, run 2 took it at minute 100. + const retaken: TimelineEvent[] = [ + ...step('pick-up', 0, 1), + ...step('test', 5, null, 1), + ...step('pick-up', 100, 100), + ...step('test', 102, null, 1), + ]; + const run1 = { id: '3', startedAt: at(0), blocked: { at: at(10) } }; + const run2 = { id: '3', startedAt: at(100) }; + + it('starts a re-taken ticket’s attempts again, with only the new run’s time', () => { + const test = ticketTimeline(retaken, '3', STEPS, 110 * MIN, { + run: run2, + }).steps.find((one) => one.id === 'test'); + + expect(test).toMatchObject({ label: 'Test', attempt: 1, ms: 8 * MIN }); + }); + + it('stops a blocked run’s open step at the moment it was blocked', () => { + const test = ticketTimeline(retaken, '3', STEPS, 250 * MIN, { + run: run1, + }).steps.find((one) => one.id === 'test'); + + expect(test?.ms).toBe(5 * MIN); + }); + + it('never shows a blocked run’s open step as active', () => { + const blockedOnly = [...step('pick-up', 0, 1), ...step('test', 5, null, 1)]; + const test = ticketTimeline(blockedOnly, '3', STEPS, 250 * MIN, { + run: run1, + }).steps.find((one) => one.id === 'test'); + + expect(test?.status).toBe('done'); + }); + + it('ends a step that never reported its end when the next step started', () => { + const skippedEnd = [ + ...step('pick-up', 0, 1), + ...step('self-review', 5, null), + ...step('test', 8, 12), + ]; + const review = ticketTimeline(skippedEnd, '3', STEPS, 20 * MIN).steps.find( + (one) => one.id === 'self-review' + ); + + expect(review).toMatchObject({ ms: 3 * MIN, status: 'done' }); + }); + + it('leaves out another pod’s events for the same ticket', () => { + const shared = [ + ...step('build', 0, 10), + event('stage.start', 0, { name: 'build', pod: 'eagleton-build-z9' }), + event('stage.end', 30, { name: 'build', pod: 'eagleton-build-z9' }), + ]; + const scope = { pod: 'pawnee-build-a1b2' }; + + expect( + ticketTimeline(shared, '3', STEPS, 40 * MIN, scope).steps.find( + (one) => one.id === 'build' + )?.ms + ).toBe(10 * MIN); + expect(wholeRun(shared, ['3'], STEPS, 40 * MIN, scope).totalMs).toBe( + 10 * MIN + ); + }); + + it('counts each run once in the whole run, a blocked one up to its block', () => { + const run = wholeRun(retaken, ['3'], STEPS, 110 * MIN, { + runs: [run1, run2], + }); + + // Run 1: pick-up 1m + test 5m; run 2: pick-up 0m + test 8m. + expect(run.steps.find((one) => one.id === 'test')?.ms).toBe(13 * MIN); + expect(run.totalMs).toBe(14 * MIN); + }); +}); + +describe('shipped ticket durations across many tickets', () => { + it('times each ticket on its own, interleaved and re-taken alike', () => { + const DAY = 24 * 60; + const events = [ + ...shippedTicket('1', 18), + ...step('pick-up', 5, 6, 1, '2'), + ...step('ship', 30, 33, 1, '2'), + ...step('pick-up', 0, 1, 1, '4'), + ...step('pick-up', DAY, DAY + 1, 1, '4'), + ...step('ship', DAY + 10, DAY + 12, 1, '4'), + ...step('pick-up', 40, 41, 1, '5'), + ]; + + expect(shippedTicketDurations(events)).toEqual([ + 18 * MIN, + 28 * MIN, + 12 * MIN, + ]); + }); +}); + +describe("the Workflow rail's Visual check", () => { + const T = 'PAWNEE-5'; + + /** Rounds 1 and 2 recorded: trail-closed still off, trail-open matching. */ + const recorded = () => { + const { store, root, podId } = buildingPodStore(); + ['trail-closed', 'trail-open'].forEach((id) => { + [ + path.join(root, '.lambda', 'pulled', T, `${id}.png`), + path.join(root, 'shots', `${id}.png`), + ].forEach((file) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + writePng(file, 4, 4); + }); + }); + const picture = ( + id: string, + issues: { severity: 'medium'; where: string; what: string }[] + ) => ({ + scenarioId: id, + situation: `Trail map — ${id}`, + expected: `.lambda/pulled/${T}/${id}.png`, + actual: `shots/${id}.png`, + issues, + }); + [1, 2].forEach((round) => + copyRoundToPod({ + store, + podId, + projectRoot: root, + ticketId: T, + record: { + round, + at: '2026-10-23T10:00:00.000Z', + pictures: [ + picture('trail-closed', [ + { + severity: 'medium', + where: 'Legend', + what: 'below the map, should be beside it', + }, + ]), + picture('trail-open', []), + ], + decision: { stop: false, reason: null }, + }, + }) + ); + + return { store, podId }; + }; + + it('copies each recorded round into the pod, its manifest listing both pictures', () => { + const { store, podId } = recorded(); + const manifest: unknown = JSON.parse( + fs.readFileSync( + store.file(podId, 'visual', T, '1', 'manifest.json'), + 'utf8' + ) + ); + + expect(manifest).toEqual([ + expect.objectContaining({ + scenarioId: 'trail-closed', + expected: 'expected-trail-closed.png', + actual: 'actual-trail-closed.png', + }), + expect.objectContaining({ scenarioId: 'trail-open', issues: [] }), + ]); + expect( + fs.existsSync( + store.file(podId, 'visual', T, '1', 'actual-trail-open.png') + ) + ).toBe(true); + }); + + it('shows "Visual check · round 2", one pair per picture per round, ✗ on the one with an issue', () => { + const { store, podId } = recorded(); + const rail = visualRail(readVisualRounds(store, podId), { + id: T, + startedAt: '2026-10-23T09:00:00.000Z', + }); + + expect(rail.label).toBe('Visual check · round 2'); + + const html = renderToStaticMarkup( + createElement(VisualPairs, { + podId, + ticketId: T, + rounds: rail.rounds.slice(-1), + onPair: () => undefined, + }) + ); + + expect(html.match(/class="build-pair[ "]/g)).toHaveLength(2); + expect(html.match(/build-pair--missed/g)).toHaveLength(1); + expect(html.match(/✗/g)).toHaveLength(1); + expect(html).toContain( + `/api/pod/${podId}/visual/${T}/2/expected-trail-closed.png` + ); + }); + + it('leaves out an earlier run’s rounds', () => { + const { store, podId } = recorded(); + + expect( + visualRail(readVisualRounds(store, podId), { + id: T, + startedAt: '2999-01-01T00:00:00.000Z', + }) + ).toEqual({ label: null, rounds: [] }); + }); + + it('clears the last loop’s rounds when a new loop records its round 1', () => { + const { store, podId } = recorded(); + const [first] = readVisualRounds(store, podId)[T] ?? []; + + if (first === undefined) { + throw new Error('round 1 was not copied'); + } + + copyRoundToPod({ + store, + podId, + projectRoot: path.dirname(path.dirname(store.root)), + ticketId: T, + record: { + round: 1, + at: '2026-10-23T12:00:00.000Z', + pictures: [], + decision: { stop: true, reason: 'only-low' }, + }, + }); + + expect( + readVisualRounds(store, podId)[T]?.map(({ round }) => round) + ).toEqual([1]); + }); +}); diff --git a/src/app/__tests__/buildingPodScreen.test.ts b/src/app/__tests__/buildingPodScreen.test.ts new file mode 100644 index 0000000..b47563b --- /dev/null +++ b/src/app/__tests__/buildingPodScreen.test.ts @@ -0,0 +1,169 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import type { ThreadMessage } from '../../pod-store'; +import { + ChatLine, + VisualPairViewer, + VisualPairs, +} from '../client/BuildingPodScreen'; +import type { + VisualIssueView, + VisualPictureView, + VisualRoundView, +} from '../shared/building'; + +/** + * A building pod's chat lines. react-markdown is ESM-only, which jest can't + * load, so Markdown is a stand-in that shows what it was given; the terminal + * pulls in xterm's CSS. + */ +jest.mock('../client/Message', () => ({ + Markdown: ({ text }: { text: string }) => + jest + .requireActual<typeof import('react')>('react') + .createElement('div', { className: 'md', 'data-markdown': text }), +})); +jest.mock('../client/TerminalView', () => ({ TerminalView: () => null })); + +const BLOCKED = [ + '**What:** Upload park photos to the shared album', + '**Problem:** The photo storage key is missing', + '**Options:** 1) add the key 2) skip photos for now', + '**Recommendation:** add the key; this Lambda moved on', +].join('\n'); + +const message = (extra: Partial<ThreadMessage> = {}): ThreadMessage => ({ + id: 'm1', + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: BLOCKED, + createdAt: '2026-10-23T10:00:00.000Z', + delivery: 'delivered', + ...extra, +}); + +const render = (props: Parameters<typeof ChatLine>[0]): string => + renderToStaticMarkup(createElement(ChatLine, props)); + +describe("an Engineering manager's bubble", () => { + it('renders its text as Markdown, credited to Lambda whatever its role', () => { + const html = render({ message: message() }); + + expect(html).toContain('class="build-bubble"'); + expect(html).toContain('<strong>Lambda:</strong>'); + expect(html).not.toContain('Engineering manager'); + expect(html).toContain(`data-markdown="${BLOCKED}"`); + }); + + it('has an answer box when it is an item waiting on the owner', () => { + const html = render({ message: message(), onAnswer: async () => {} }); + + expect(html).toContain('<textarea'); + expect(html).toContain('Answer this item…'); + }); + + it('has no answer box when nothing waits on it', () => { + expect(render({ message: message() })).not.toContain('<textarea'); + }); + + it("shows a visual check's pictures on its Blocked item", () => { + const html = render({ + message: message({ + pictures: ['composite-trail-closed.png'], + picturesAt: { ticketId: 'PAWNEE-5', round: 7 }, + }), + }); + + expect(html).toContain('class="build-bubble__pictures"'); + expect(html).toContain( + 'src="/api/pod/build-a1b2/visual/PAWNEE-5/7/composite-trail-closed.png"' + ); + }); +}); + +describe("the Visual check row's pictures", () => { + const picture = (issues: VisualIssueView[]): VisualPictureView => ({ + scenarioId: 'trail-closed', + situation: 'Trail map — Trail closed', + expected: 'expected-trail-closed.png', + actual: 'actual-trail-closed.png', + issues, + }); + const medium: VisualIssueView = { + severity: 'medium', + where: 'Legend', + what: 'below the map, should be beside it', + }; + const round = (issues: VisualIssueView[]): VisualRoundView => ({ + round: 2, + at: '2026-10-23T10:00:00.000Z', + pictures: [picture(issues)], + }); + + it('opens a pair full size with its round and its issues', () => { + const open = round([medium]); + const html = renderToStaticMarkup( + createElement(VisualPairViewer, { + podId: 'build-a1b2', + open: { + ticketId: 'PAWNEE-5', + round: open, + picture: picture([medium]), + }, + onClose: () => undefined, + }) + ); + + expect(html).toContain( + 'Visual check · round 2 · Trail map — Trail closed' + ); + expect(html).toContain( + 'src="/api/pod/build-a1b2/visual/PAWNEE-5/2/expected-trail-closed.png"' + ); + expect(html).toContain( + 'src="/api/pod/build-a1b2/visual/PAWNEE-5/2/actual-trail-closed.png"' + ); + expect(html).toContain( + '<li>Medium · Legend: below the map, should be beside it</li>' + ); + }); + + it('names a thumbnail by its picture, saying when it has issues, not by its ✗', () => { + const pairs = (issues: VisualIssueView[]) => + renderToStaticMarkup( + createElement(VisualPairs, { + podId: 'build-a1b2', + ticketId: 'PAWNEE-5', + rounds: [round(issues)], + onPair: () => undefined, + }) + ); + + expect(pairs([medium])).toContain( + 'aria-label="Round 2 · Trail map — Trail closed · has issues"' + ); + expect(pairs([medium])).toContain( + '<span class="build-shot__fail" aria-hidden="true">✗</span>' + ); + expect(pairs([])).toContain( + 'aria-label="Round 2 · Trail map — Trail closed"' + ); + }); +}); + +describe('the Workflow rail styles', () => { + // The rail marks a skipped step `build-step--skipped`; without a rule it + // reads as done apart from its "–". + it('mutes a skipped step like one still to come', () => { + const css = fs.readFileSync( + path.join(__dirname, '..', 'client', 'styles.css'), + 'utf8' + ); + + expect(css).toMatch(/\.build-step--skipped \{\s*color: var\(--grey-500\);/); + }); +}); diff --git a/src/app/__tests__/buildingScreen.test.ts b/src/app/__tests__/buildingScreen.test.ts new file mode 100644 index 0000000..e313fd3 --- /dev/null +++ b/src/app/__tests__/buildingScreen.test.ts @@ -0,0 +1,228 @@ +import type { + BuildingState, + Pod, + Screenshot, + ThreadMessage, +} from '../../pod-store'; +import { + buildingRefreshIn, + buildingReplyItems, + buildingScreenMode, + pauseHeld, + railPlaceholder, + runShots, +} from '../shared/building'; +import { buildingChat } from '../shared/conversation'; +import { ticketsRefreshDue } from '../shared/ticketList'; + +/** + * A building pod's page: which chat lines it shows, whether it is a full + * building pod or a one-ticket talk, and how often it and the Tickets screen + * ask again. + */ + +const message = ( + id: string, + from: ThreadMessage['from'], + extra: Partial<ThreadMessage> = {} +): ThreadMessage => ({ + id, + podId: 'build-a1b2', + from, + mode: 'note', + text: `message ${id}`, + createdAt: '2026-10-23T10:00:00.000Z', + delivery: 'delivered', + ...extra, +}); + +describe("a building pod's chat", () => { + it("leaves out the Product manager's relay to the Engineering manager", () => { + const thread = [ + message('m1', 'human', { target: 'product-manager' }), + message('m2', 'product-manager', { target: 'engineering-manager' }), + message('m3', 'engineering-manager'), + message('m4', 'product-manager'), + ]; + + expect(buildingChat(thread).map((one) => one.id)).toEqual([ + 'm1', + 'm3', + 'm4', + ]); + }); + + it('leaves out system notes and empty lines', () => { + const thread = [ + message('m1', 'system'), + message('m2', 'engineering-manager', { text: ' ' }), + ]; + + expect(buildingChat(thread)).toEqual([]); + }); +}); + +describe("a building pod's page", () => { + const pod = (extra: Partial<Pod>): Pick<Pod, 'kind' | 'talk'> => ({ + kind: 'building', + ...extra, + }); + + it('is a full building pod, or a one-ticket talk with no building rails', () => { + expect(buildingScreenMode(pod({}))).toBe('building'); + expect(buildingScreenMode(pod({ talk: { ticketId: '3' } }))).toBe('talk'); + }); + + it("isn't a building page for a planning pod", () => { + expect(buildingScreenMode(pod({ kind: 'planning' }))).toBeNull(); + expect(buildingScreenMode({})).toBeNull(); + }); +}); + +describe('the Tickets screen asks the sources again', () => { + const now = 1_000_000_000; + + it('on its first load', () => { + expect(ticketsRefreshDue(null, now)).toBe(true); + }); + + it('not on every event tick', () => { + expect(ticketsRefreshDue(now - 5_000, now)).toBe(false); + }); + + it('once a minute has passed', () => { + expect(ticketsRefreshDue(now - 59_999, now)).toBe(false); + expect(ticketsRefreshDue(now - 60_000, now)).toBe(true); + }); + + it('every 5 seconds while a summary is being written, not on every tick', () => { + expect(ticketsRefreshDue(now - 1_000, now, true)).toBe(false); + expect(ticketsRefreshDue(now - 5_000, now, true)).toBe(true); + }); +}); + +describe("a building pod's page asks its computer again", () => { + const now = 1_000_000_000; + + it('at once on its first load', () => { + expect(buildingRefreshIn(null, now)).toBe(0); + }); + + it('at once when the last read is 5 seconds old', () => { + expect(buildingRefreshIn(now - 5_000, now)).toBe(0); + expect(buildingRefreshIn(now - 60_000, now)).toBe(0); + }); + + it('not on every event tick, but once the 5 seconds are up', () => { + expect(buildingRefreshIn(now - 1_000, now)).toBe(4_000); + expect(buildingRefreshIn(now, now)).toBe(5_000); + }); +}); + +describe("a building pod's rails before its first answer", () => { + it('say Loading… while it loads, and why when the load failed', () => { + expect(railPlaceholder(null)).toBe('Loading…'); + expect(railPlaceholder("Leslie's Mac is offline.")).toBe( + "Couldn't load this pod: Leslie's Mac is offline." + ); + }); +}); + +describe("a building pod's page shows the items waiting on the owner", () => { + const blocked = (id: string, messageId: string) => ({ + id, + title: `Ticket ${id}`, + step: 'test', + attempt: 1, + startedAt: '2026-10-23T09:00:00.000Z', + blocked: { + at: '2026-10-23T10:00:00.000Z', + what: 'w', + problem: 'p', + options: 'o', + recommendation: 'r', + messageId, + }, + }); + const state = (extra: Partial<BuildingState> = {}): BuildingState => ({ + runs: [blocked('5', 'm1'), blocked('6', 'm2')], + upNext: [], + ...extra, + }); + const thread = [ + message('m1', 'engineering-manager', { expectsReply: true }), + message('m2', 'engineering-manager', { expectsReply: true }), + message('m3', 'engineering-manager'), + ]; + + it('lists each Blocked item still waiting, oldest first', () => { + expect(buildingReplyItems(thread, state()).map((one) => one.id)).toEqual([ + 'm1', + 'm2', + ]); + }); + + it('leaves out one whose ticket is back in the queue', () => { + expect( + buildingReplyItems( + thread, + state({ upNext: [{ id: '5', title: 'Ticket 5' }] }) + ).map((one) => one.id) + ).toEqual(['m2']); + }); + + it('leaves out one answered on that item, but not one asked of the Product manager', () => { + const answered = [ + ...thread, + message('m4', 'human', { about: 'm1' }), + message('m5', 'human', { target: 'product-manager' }), + ]; + + expect(buildingReplyItems(answered, state()).map((one) => one.id)).toEqual([ + 'm2', + ]); + }); +}); + +describe("a run's test screenshots", () => { + const shot = (file: string, at: string): Screenshot => ({ + file, + ticketId: '3', + attempt: 1, + url: 'http://localhost:3000/parks/ramsett', + consoleErrors: [], + conclusion: 'The trail markers show.', + passed: true, + at, + }); + + it("leaves out an earlier run's shots of the same ticket and attempt", () => { + const screenshots = { + '3/1': [ + shot('before.png', '2026-10-21T09:00:00.000Z'), + shot('now.png', '2026-10-23T10:30:00.000Z'), + ], + }; + + expect( + runShots( + screenshots, + { id: '3', startedAt: '2026-10-23T10:00:00.000Z' }, + 1 + ).map((one) => one.file) + ).toEqual(['now.png']); + }); +}); + +describe("a building pod's Pause button", () => { + it('stays held after its request settles, until the pod is paused', () => { + // Clicked while active; the request has settled, no new state yet. + expect(pauseHeld('active', 'active')).toBe(true); + // The state push says paused: Resume can be clicked. + expect(pauseHeld('active', 'paused')).toBe(false); + }); + + it('is free when nothing was clicked, or the click failed', () => { + expect(pauseHeld(null, 'active')).toBe(false); + }); +}); diff --git a/src/app/__tests__/buildingState.test.ts b/src/app/__tests__/buildingState.test.ts new file mode 100644 index 0000000..bfbf765 --- /dev/null +++ b/src/app/__tests__/buildingState.test.ts @@ -0,0 +1,242 @@ +import type { BuildTicketRun, BuildingState } from '../../pod-store'; +import { + buildingLine, + buildingStateOf, + headerBuilding, + openTalks, + takenTickets, + ticketsRail, +} from '../shared/building'; + +const run = ( + id: string, + extra: Partial<BuildTicketRun> = {} +): BuildTicketRun => ({ + id, + title: `Ticket ${id}`, + step: 'build', + attempt: 1, + startedAt: '2026-10-23T10:00:00.000Z', + ...extra, +}); + +const shipped = (id: string) => + run(id, { step: 'ship', shippedAt: '2026-10-23T11:00:00.000Z' }); + +const blocked = (id: string) => + run(id, { + blocked: { + at: '2026-10-23T11:00:00.000Z', + what: 'Lot 48 photos', + problem: 'The photo storage key is missing', + options: '1) add the key 2) skip photos for now', + recommendation: 'add the key', + }, + }); + +const state = ( + runs: BuildTicketRun[], + upNext: string[] = [] +): BuildingState => ({ + runs, + upNext: upNext.map((id) => ({ id, title: `Ticket ${id}` })), +}); + +describe('a building pod in the sidebar', () => { + it('reads its state from the current ticket’s step', () => { + expect( + buildingStateOf({ status: 'active' }, state([run('3', { step: 'test' })])) + .state + ).toBe('testing'); + expect( + buildingStateOf({ status: 'active' }, state([run('3', { step: 'ship' })])) + .state + ).toBe('shipping'); + expect( + buildingStateOf( + { status: 'active' }, + state([run('3', { step: 'build' })]) + ).state + ).toBe('building'); + expect( + buildingStateOf( + { status: 'active' }, + state([run('3', { step: 'a11y-check' })]) + ).state + ).toBe('building'); + }); + + it('is paused when paused, whatever its step', () => { + expect( + buildingStateOf( + { status: 'paused' }, + state([run('3', { step: 'test', attempt: 2 })]) + ).state + ).toBe('paused'); + }); + + it('is idle with no current ticket', () => { + expect(buildingStateOf({ status: 'active' }, state([])).state).toBe('idle'); + expect( + buildingStateOf({ status: 'active' }, state([shipped('1')])).state + ).toBe('idle'); + }); + + it('is blocked when its last ticket went Blocked and nothing is takeable', () => { + expect( + buildingStateOf({ status: 'active' }, state([shipped('1'), blocked('5')])) + .state + ).toBe('blocked'); + expect( + buildingStateOf( + { status: 'active' }, + state([shipped('1'), blocked('5')], ['6']) + ).state + ).toBe('idle'); + }); + + it('counts "ticket N of M": taken so far, plus what is still queued', () => { + const view = buildingStateOf( + { status: 'active' }, + state( + [shipped('1'), shipped('2'), blocked('5'), run('3', { step: 'test' })], + ['6', '7', '8'] + ) + ); + + expect(view.position).toEqual({ n: 4, m: 7 }); + expect(view.current?.id).toBe('3'); + }); + + it('counts a blocked ticket set back to Ready once, and once again when retaken', () => { + const taken = [shipped('1'), shipped('2'), blocked('5')]; + // 3 went Blocked and was set back to Ready: it's in the runs and Up next. + const setBack = buildingStateOf( + { status: 'active' }, + state([...taken, blocked('3')], ['3', '6', '7', '8']) + ); + const retaken = buildingStateOf( + { status: 'active' }, + state( + [...taken, blocked('3'), run('3', { step: 'test' })], + ['6', '7', '8'] + ) + ); + + expect(setBack.position).toEqual({ n: 4, m: 7 }); + expect(retaken.position).toEqual({ n: 4, m: 7 }); + expect(retaken.current?.id).toBe('3'); + }); + + it('counts a ticket taken again later at its latest place', () => { + const view = buildingStateOf( + { status: 'active' }, + state([shipped('1'), blocked('5'), shipped('2'), run('5')], ['6']) + ); + + expect(view.position).toEqual({ n: 3, m: 4 }); + }); + + it('counts each ticket once in "across N tickets"', () => { + expect(takenTickets(state([shipped('1'), blocked('3'), run('3')]))).toEqual( + ['1', '3'] + ); + }); + + it('has no position before it has taken anything', () => { + expect( + buildingStateOf({ status: 'active' }, state([], ['1'])).position + ).toBeNull(); + }); +}); + +describe("a building pod's Tickets rail", () => { + it('lists only shipped tickets under Done, never a blocked one', () => { + const rail = ticketsRail( + state( + [shipped('1'), blocked('5'), shipped('2'), run('3', { step: 'test' })], + ['6', '7'] + ) + ); + + expect(rail.done.map((one) => one.id)).toEqual(['1', '2']); + expect(rail.current?.id).toBe('3'); + expect(rail.upNext.map((one) => one.id)).toEqual(['6', '7']); + }); + + it('leaves out a ticket let go when its pod ended', () => { + const released = run('4', { claim: 'released' }); + + expect(ticketsRail(state([shipped('1'), released])).done).toEqual([ + expect.objectContaining({ id: '1' }), + ]); + }); +}); + +describe('the sidebar line', () => { + it('reads "testing · ticket 4 of 7"', () => { + expect( + buildingLine( + buildingStateOf( + { status: 'active' }, + state( + [ + shipped('1'), + shipped('2'), + blocked('5'), + run('3', { step: 'test' }), + ], + ['6', '7', '8'] + ) + ) + ) + ).toBe('testing · ticket 4 of 7'); + }); + + it('reads the live state in the page header, not the page’s older read', () => { + const polled = buildingStateOf( + { status: 'active' }, + state([run('3', { step: 'test' })]) + ); + const live = buildingStateOf( + { status: 'paused' }, + state([run('3', { step: 'test' })]) + ); + + expect(buildingLine(headerBuilding(live, polled) ?? polled)).toBe( + buildingLine(live) + ); + expect(buildingLine(live)).not.toBe(buildingLine(polled)); + expect(headerBuilding(undefined, polled)).toBe(polled); + }); + + it('says why it is idle', () => { + expect( + buildingLine( + buildingStateOf( + { status: 'active' }, + { ...state([]), idleReason: 'Nothing ready to build' } + ) + ) + ).toBe('idle · Nothing ready to build'); + }); +}); + +describe('the sidebar’s Conversations', () => { + const summary = ( + id: string, + status: 'active' | 'done', + talk?: { ticketId: string } + ) => ({ status, pod: { id, kind: 'building' as const, talk } }); + + it('lists each open "Talk about this ticket", never a building pod or a closed talk', () => { + expect( + openTalks([ + summary('build-a1b2', 'active'), + summary('talk-c3d4', 'active', { ticketId: '3' }), + summary('talk-e5f6', 'done', { ticketId: '4' }), + summary('talk-0a0b', 'active', { ticketId: '5' }), + ]).map((one) => one.pod.id) + ).toEqual(['talk-c3d4', 'talk-0a0b']); + }); +}); diff --git a/src/app/__tests__/cardHistory.test.ts b/src/app/__tests__/cardHistory.test.ts new file mode 100644 index 0000000..fc684a0 --- /dev/null +++ b/src/app/__tests__/cardHistory.test.ts @@ -0,0 +1,134 @@ +import type { AutonomousDecision, Question, Screen } from '../../pod-store'; +import { + buildTimeline, + decisionCardKey, + lastChange, + screenCardKey, +} from '../shared/cardHistory'; + +/** + * A checkpoint's versions as one timeline: each card (screen, ticket, + * decision) knows which versions added, changed or removed it, and each + * version how many cards it touched. + */ + +const screen = (name: string, after: string): Screen => ({ + name, + change: 'changed', + before: 'Bench list — 40 benches', + after, + changes: 'Leslie sees which benches are adopted.', + tickets: ['1'], +}); + +const decision = (id: string, text: string): AutonomousDecision => ({ + id, + podId: 'pawnee-a1b2', + phase: 4, + decision: text, + why: 'Ron forgets.', + createdAt: '2026-10-17T09:00:00.000Z', +}); + +const version = ( + n: number, + screens: Screen[], + decisions: AutonomousDecision[] = [], + versionKind: 'posted' | 'reposted' | 'edited' = 'edited' +): Question => ({ + id: 'q4', + podId: 'pawnee-a1b2', + kind: 'checkpoint', + phase: 4, + status: 'open', + blocksCheckpoint: true, + dependsOn: [], + createdAt: '2026-10-17T09:00:00.000Z', + options: [], + checkpoint: { + cp: 4, + decisionIds: decisions.map(({ id }) => id), + decisionSnapshot: decisions, + screens, + version: n, + versionAt: `2026-10-17T1${n}:00:00.000Z`, + versionKind, + }, +}); + +const benches = (after: string) => screen('Bench list', after); +const mural = screen('Mural: colours, sizes and the artist', 'Hi Donna'); +const BENCHES = screenCardKey(benches('')); +const MURAL = screenCardKey(mural); +const SWINGS = decisionCardKey('d1'); + +const history = [ + version( + 1, + [benches('31 adopted')], + [decision('d1', 'Ron inspects the swings')], + 'posted' + ), + // Renumbered and a card added: only the new card is a change. + version( + 2, + [mural, benches('31 adopted')], + [decision('d1', 'Ron inspects the swings')] + ), + // A re-post that changed nothing. + version( + 3, + [mural, benches('31 adopted')], + [decision('d1', 'Ron inspects the swings')], + 'reposted' + ), + version( + 4, + [mural, benches('31 adopted · 2 new')], + [decision('d1', 'Ron inspects the swings a day early')] + ), +]; + +it('counts the cards each version touched (v1 is the post)', () => { + expect( + buildTimeline(history).versions.map(({ version: v, kind, changes }) => ({ + v, + kind, + changes, + })) + ).toEqual([ + { v: 1, kind: 'posted', changes: 0 }, + { v: 2, kind: 'edited', changes: 1 }, + { v: 3, kind: 'reposted', changes: 0 }, + { v: 4, kind: 'edited', changes: 2 }, + ]); +}); + +it('marks the versions that added or changed each card', () => { + const { cards } = buildTimeline(history); + + expect([...(cards.get(BENCHES) ?? [])]).toEqual([ + [1, 'added'], + [4, 'changed'], + ]); + expect([...(cards.get(MURAL) ?? [])]).toEqual([[2, 'added']]); + expect([...(cards.get(SWINGS) ?? [])]).toEqual([ + [1, 'added'], + [4, 'changed'], + ]); +}); + +it('says when a card last changed, as of the version on screen', () => { + const timeline = buildTimeline(history); + + expect(lastChange(timeline, BENCHES, 4)).toEqual({ + version: 4, + change: 'changed', + }); + expect(lastChange(timeline, BENCHES, 3)).toEqual({ + version: 1, + change: 'added', + }); + // Not in v1 yet. + expect(lastChange(timeline, MURAL, 1)).toBeUndefined(); +}); diff --git a/src/app/__tests__/caughtUp.test.ts b/src/app/__tests__/caughtUp.test.ts new file mode 100644 index 0000000..a5e6591 --- /dev/null +++ b/src/app/__tests__/caughtUp.test.ts @@ -0,0 +1,203 @@ +import { caughtUpLine, launchItems } from '../shared/caughtUp'; +import { + canPause, + computerRows, + computersCounts, +} from '../shared/computersFooter'; +import type { Lambda } from '../shared/lambdas'; +import type { LambdaInfo, PodSummary } from '../shared/types'; + +/** Caught up, and the computers line: however many computers there are. */ + +const NOW = Date.parse('2026-10-26T12:00:00.000Z'); + +const buildingPod = ( + id: string, + status: 'active' | 'paused', + building: PodSummary['building'] +): PodSummary => ({ + pod: { + id, + slug: id, + title: id, + createdAt: '2026-10-20T10:00:00.000Z', + status, + phase: 1, + completedCheckpoints: [], + worktreePath: `/tmp/${id}`, + ownsWorktree: false, + managerCwd: `/tmp/${id}`, + devCwd: `/tmp/${id}`, + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, + kind: 'building', + }, + running: true, + status: 'working', + needsYou: 0, + liveForks: [], + chatHold: false, + building, +}); + +const info: LambdaInfo = { + owner: { name: 'Leslie Knope', initials: 'LK' }, + projects: [], + lastProjectId: null, + podProjects: {}, + podComputers: { + 'build-leslie': "Leslie's Mac", + 'build-ron': "Ron's iMac", + 'build-tom': "Tom's laptop", + }, + waiting: [], + computers: [ + { name: "Leslie's Mac", connected: true, lastSeenAt: NOW, projectIds: [] }, + { name: "Ron's iMac", connected: true, lastSeenAt: NOW, projectIds: [] }, + { + name: "Tom's laptop", + connected: false, + lastSeenAt: NOW - 2 * 60 * 60_000, + projectIds: [], + }, + { name: "April's Mac", connected: true, lastSeenAt: NOW, projectIds: [] }, + ], +}; + +const pods: PodSummary[] = [ + buildingPod('build-leslie', 'active', { + state: 'testing', + current: { id: 'PAWNEE-3', title: 'Bench list', step: 'test', attempt: 1 }, + position: null, + idleReason: null, + runs: [ + { + id: 'PAWNEE-3', + title: 'Bench list', + step: 'test', + startedAt: '2026-10-26T11:48:00.000Z', + shippedAt: null, + blocked: null, + }, + ], + upNext: [], + }), + buildingPod('build-ron', 'paused', { + state: 'paused', + current: null, + position: null, + idleReason: null, + runs: [], + upNext: [ + { id: 'PAWNEE-8', title: 'Gazebo paint' }, + { id: 'PAWNEE-9', title: 'Lot 48 signage' }, + ], + }), + buildingPod('build-tom', 'active', { + state: 'building', + current: null, + position: null, + idleReason: null, + runs: [], + upNext: [], + }), +]; + +const lambda = (extra: Partial<Lambda>): Lambda => ({ + key: 'ticket:parks/PAWNEE-9', + title: 'Lot 48 signage', + projectId: 'parks', + planningPodId: null, + ticketId: 'PAWNEE-9', + tickets: [], + phase: 'queued', + needsYou: [], + working: false, + shippedAt: null, + paused: false, + ...extra, +}); + +describe('the computers line', () => { + it('says each computer in a few words, a paused one with work waiting needing attention', () => { + const rows = computerRows(info, pods, NOW); + + expect(rows.map((row) => [row.name, row.state, row.detail])).toEqual([ + ["Leslie's Mac", 'testing', 'Bench list · test · 12m'], + ["Ron's iMac", 'paused', 'paused · 2 tickets waiting'], + ["Tom's laptop", 'offline', 'offline 2h'], + ["April's Mac", 'idle', 'not building'], + ]); + expect(rows.filter((row) => row.attention).map((row) => row.name)).toEqual([ + "Ron's iMac", + "Tom's laptop", + ]); + expect(computersCounts(rows)).toEqual([ + { label: '1 building', tone: 'plain' }, + { label: '1 idle', tone: 'plain' }, + { label: '1 paused', tone: 'paused' }, + { label: '1 offline', tone: 'offline' }, + ]); + }); + + it('offers Pause on a computer whose building pod runs, not one paused, offline or without one', () => { + const rows = computerRows(info, pods, NOW); + + expect(rows.map((row) => [row.name, canPause(row)])).toEqual([ + ["Leslie's Mac", true], + ["Ron's iMac", false], + ["Tom's laptop", false], + ["April's Mac", false], + ]); + }); +}); + +describe('all caught up', () => { + it('says where things stand, leaving out the zeros', () => { + expect( + caughtUpLine([ + lambda({ phase: 'planning' }), + lambda({ phase: 'building' }), + lambda({ phase: 'qa' }), + lambda({ phase: 'queued' }), + ]) + ).toBe('1 planning · 2 building · 1 queued'); + expect(caughtUpLine([])).toBe('Nothing is in flight.'); + }); + + it('offers only what they could do: plan, resume a paused computer, reassign an offline one', () => { + const unplanned = lambda({}); + const strandedOnTom = lambda({ + key: 'ticket:parks/PAWNEE-10', + planningPodId: 'plan-x', + tickets: [ + { + id: 'PAWNEE-10', + title: 'Ticket', + where: { + kind: 'queued', + place: 1, + computer: "Tom's laptop", + podId: 'build-tom', + ahead: [], + }, + }, + ], + }); + + expect( + launchItems([unplanned, strandedOnTom], info, pods, NOW).map( + (item) => item.kind + ) + ).toEqual(['new', 'unplanned', 'paused', 'offline']); + expect(launchItems([], info, pods, NOW)).toEqual([ + { kind: 'new' }, + { + kind: 'paused', + computer: "Ron's iMac", + podId: 'build-ron', + waiting: 2, + }, + ]); + }); +}); diff --git a/src/app/__tests__/chatPlaylist.test.ts b/src/app/__tests__/chatPlaylist.test.ts new file mode 100644 index 0000000..1fa1caf --- /dev/null +++ b/src/app/__tests__/chatPlaylist.test.ts @@ -0,0 +1,92 @@ +import type { ThreadMessage } from '../../pod-store'; +import { chatTracks, messageSpeech, nextChunk } from '../client/chatPlaylist'; +import { chunkSpeech } from '../shared/speech'; + +/** The Chat panel's playlist: from the message pressed to the newest. */ + +const message = (overrides: Partial<ThreadMessage>): ThreadMessage => ({ + id: 'm1', + podId: 'pawnee-a1b2', + from: 'fork', + mode: 'sync', + chat: true, + text: 'Leslie wants the parks budget on one page.', + createdAt: '2026-10-13T16:00:00.000Z', + delivery: 'received', + ...overrides, +}); + +describe('chunkSpeech', () => { + it('keeps a short message whole', () => { + expect(chunkSpeech('One. Two.')).toEqual(['One. Two.']); + }); + + it('starts with a short chunk, then longer ones, cut at sentence ends', () => { + const sentence = `${'word '.repeat(19)}end.`; // 100 characters + const chunks = chunkSpeech(Array(30).fill(sentence).join(' ')); + + expect(chunks[0]?.length).toBeLessThanOrEqual(250); + expect(chunks.slice(1, -1).every((chunk) => chunk.length > 300)).toBe(true); + expect(chunks.every((chunk) => chunk.length <= 600)).toBe(true); + expect(chunks.every((chunk) => chunk.endsWith('end.'))).toBe(true); + expect(chunks.join(' ')).toBe(Array(30).fill(sentence).join(' ')); + }); + + it('says nothing for an empty message', () => { + expect(chunkSpeech('')).toEqual([]); + }); + + it('keeps a line break where the text had one', () => { + expect(chunkSpeech('Tickets\n1. Parks budget\n2. Pawnee mural')).toEqual([ + 'Tickets\n1. Parks budget\n2. Pawnee mural', + ]); + }); +}); + +describe('messageSpeech', () => { + it("introduces the owner's own messages", () => { + expect(messageSpeech(message({ from: 'human', text: 'Ship it' }))).toBe( + 'You said: Ship it' + ); + }); + + it('reads markdown as speech', () => { + expect(messageSpeech(message({ text: '**Name:** Penguin Plaza' }))).toBe( + 'Name: Penguin Plaza' + ); + }); +}); + +describe('chatTracks and nextChunk', () => { + const tracks = chatTracks([ + message({ id: 'a', from: 'human', text: 'What changed?' }), + message({ id: 'note', from: 'system', text: 'Sent the dev what you said' }), + message({ id: 'image-only', text: '[Image: /tmp/shot.png]' }), + message({ id: 'b', text: 'The name. The colour.' }), + ]); + + it('skips system notes and messages with nothing to say', () => { + expect(tracks.map((track) => track.id)).toEqual(['a', 'b']); + }); + + it('plays through to the newest message, then stops', () => { + expect(nextChunk(tracks, 'a', 0)).toEqual({ id: 'b', chunk: 0 }); + expect(nextChunk(tracks, 'b', 0)).toBeNull(); + }); + + it('carries on into a message that arrived while playing', () => { + const later = [ + ...tracks, + ...chatTracks([message({ id: 'c', text: 'One more thing.' })]), + ]; + + expect(nextChunk(later, 'b', 0)).toEqual({ id: 'c', chunk: 0 }); + }); + + it('moves through a long message chunk by chunk', () => { + const long = [{ id: 'x', chunks: ['one', 'two'] }]; + + expect(nextChunk(long, 'x', 0)).toEqual({ id: 'x', chunk: 1 }); + expect(nextChunk(long, 'x', 1)).toBeNull(); + }); +}); diff --git a/src/app/__tests__/chunkBoundary.test.ts b/src/app/__tests__/chunkBoundary.test.ts new file mode 100644 index 0000000..8d0240c --- /dev/null +++ b/src/app/__tests__/chunkBoundary.test.ts @@ -0,0 +1,123 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { + ChunkBoundary, + CrashedNotice, + UpdatedNotice, + isChunkLoadError, +} from '../client/ChunkBoundary'; +import * as uiLogModule from '../client/uiLog'; + +describe('a screen whose chunk fails to load', () => { + it('turns a failed import into the updated notice', () => { + // What each browser says when a rebuilt client's old chunk is gone. + [ + 'Failed to fetch dynamically imported module: http://127.0.0.1:4580/assets/AnalyticsPage-3f9a.js', + 'error loading dynamically imported module', + 'Importing a module script failed.', + ].forEach((message) => { + expect(isChunkLoadError(new TypeError(message))).toBe(true); + expect( + ChunkBoundary.getDerivedStateFromError(new TypeError(message)) + ).toEqual({ failed: 'chunk' }); + }); + }); + + it('calls any other error a crash, not an update', () => { + const crash = new TypeError( + "Cannot read properties of undefined (reading 'name')" + ); + + expect(isChunkLoadError(crash)).toBe(false); + expect(ChunkBoundary.getDerivedStateFromError(crash)).toEqual({ + failed: 'crash', + }); + expect(isChunkLoadError('not an error')).toBe(false); + }); + + it('says Lambda was updated and offers a reload', () => { + const html = renderToStaticMarkup(createElement(UpdatedNotice)); + + expect(html).toContain('Lambda was updated. Reload.'); + expect(html).toContain('<button'); + }); + + it('says a crash went wrong, and offers a reload', () => { + const html = renderToStaticMarkup(createElement(CrashedNotice)); + + expect(html).toContain('Something went wrong on this screen. Reload.'); + expect(html).toContain('<button'); + }); + + it('logs what failed, so it reaches the page’s trail', () => { + const log = jest + .spyOn(uiLogModule, 'uiLog') + .mockImplementation(() => undefined); + const boundary = new ChunkBoundary({ children: null }); + + boundary.componentDidCatch(new Error('row.name is undefined'), { + componentStack: '\n at AnalyticsPage', + }); + + expect(log).toHaveBeenCalledWith('js-error', { + message: 'row.name is undefined', + where: 'at AnalyticsPage', + }); + log.mockRestore(); + }); + + it('tries the screen again once another page is opened', () => { + const boundary = new ChunkBoundary({ + children: null, + resetKey: 'analytics', + }); + boundary.state = { failed: 'crash' }; + const setState = jest + .spyOn(boundary, 'setState') + .mockImplementation(() => undefined); + + boundary.componentDidUpdate({ resetKey: 'analytics' }); + expect(setState).not.toHaveBeenCalled(); + + Object.assign(boundary, { + props: { children: null, resetKey: 'computers' }, + }); + boundary.componentDidUpdate({ resetKey: 'analytics' }); + expect(setState).toHaveBeenCalledWith({ failed: null }); + }); + + it('renders its children while nothing failed', () => { + const html = renderToStaticMarkup( + createElement(ChunkBoundary, null, createElement('p', null, 'Pawnee')) + ); + + expect(html).toBe('<p>Pawnee</p>'); + }); +}); + +describe('the pod screen’s lazy panels', () => { + /** Each `<Lazy …>` opening tag in App, with the component inside it. */ + const lazyTags = (): { tag: string; inside: string }[] => + Array.from( + fs + .readFileSync(path.join(__dirname, '..', 'client', 'App.tsx'), 'utf8') + .matchAll(/<Lazy\b([^>]*)>\s*<(\w+)/g), + (match) => ({ tag: match[1] ?? '', inside: match[2] ?? '' }) + ); + + it('try again when another pod opens, not only on a reload', () => { + const panels = ['ChatPanel', 'WorkflowPanel', 'WindowPicker', 'MadeRail']; + const tags = lazyTags().filter(({ inside }) => panels.includes(inside)); + + expect(tags.map(({ inside }) => inside).sort()).toEqual([...panels].sort()); + tags.forEach(({ tag, inside }) => + expect({ inside, tag }).toEqual({ + inside, + tag: expect.stringContaining('resetKey={detailPodId'), + }) + ); + }); +}); diff --git a/src/app/__tests__/chunks.test.ts b/src/app/__tests__/chunks.test.ts new file mode 100644 index 0000000..6e7ee40 --- /dev/null +++ b/src/app/__tests__/chunks.test.ts @@ -0,0 +1,185 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const PACKAGE = path.resolve(__dirname, '..', '..', '..'); + +/** + * Each top-level screen loads as its own chunk, so the first page load + * (the sign-in screen, or the shell) doesn't ship them all: the pod screen + * (PodScreen.tsx), Time, Analytics, Computers and Settings. The signed-in + * shell around them (App.tsx) is a chunk of its own too. + */ +const SCREENS = [ + 'PodScreen.tsx', + 'TimePage.tsx', + 'AnalyticsPage.tsx', + 'ComputersPage.tsx', + 'SettingsPage.tsx', +]; + +/** + * Text only the pod screen's code carries: xterm (the terminal), a class + * only its cards use, and the prototype viewer's own words (Prototypes.tsx: + * the shell holds only its small context). None may be in the shell's + * JavaScript. + */ +const POD_ONLY_MARKERS = [ + 'xterm-helper-textarea', + 'today-after', + 'As it opens', +]; + +interface Chunk { + file: string; + name?: string; + isEntry?: boolean; + isDynamicEntry?: boolean; + imports?: string[]; + dynamicImports?: string[]; +} + +interface Built { + manifest: Record<string, Chunk>; + /** The JavaScript of a chunk and every chunk it imports statically. */ + code: (key: string) => string; +} + +jest.setTimeout(120_000); + +/** Every module key reached by static imports from a chunk. */ +const staticallyReached = ( + manifest: Record<string, Chunk>, + key: string, + seen: Set<string> = new Set() +): Set<string> => { + if (seen.has(key)) { + return seen; + } + + seen.add(key); + (manifest[key]?.imports ?? []).forEach((next) => + staticallyReached(manifest, next, seen) + ); + + return seen; +}; + +/** The package's own vite.config.mts (manifest on), as `pnpm build` runs it. */ +const buildClient = (outDir: string): Built => { + // A child process: Vite's API is ESM, which jest's module sandbox can't load. + execFileSync( + process.execPath, + [ + // Vite's package exports don't list its bin; the CLI's own path. + path.join(PACKAGE, 'node_modules', 'vite', 'bin', 'vite.js'), + 'build', + '--config', + path.join(PACKAGE, 'vite.config.mts'), + '--outDir', + outDir, + '--emptyOutDir', + '--logLevel', + 'error', + ], + { cwd: PACKAGE, stdio: 'pipe' } + ); + const manifest: Record<string, Chunk> = JSON.parse( + fs.readFileSync(path.join(outDir, '.vite', 'manifest.json'), 'utf8') + ); + + return { + manifest, + code: (key) => + [...staticallyReached(manifest, key)] + .map((reached) => manifest[reached]?.file ?? '') + .filter((file) => file.endsWith('.js')) + .map((file) => fs.readFileSync(path.join(outDir, file), 'utf8')) + .join('\n'), + }; +}; + +/** A module's chunk: keyed by its source, or by its name when it's shared. */ +const chunkOf = ( + manifest: Record<string, Chunk>, + screen: string +): [string, Chunk] | [] => + Object.entries(manifest).find( + ([key, chunk]) => + key === screen || chunk.name === screen.replace(/\.tsx$/, '') + ) ?? []; + +describe('the built client', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-client-')); + // Built in beforeAll, not at collection: a filtered run (`-t`) skips the + // build, and a failed build is a failed test rather than a collection error. + const state: { built?: Built } = {}; + const built = (): Built => { + if (state.built === undefined) { + throw new Error('The client was not built.'); + } + + return state.built; + }; + const keys = () => { + const { manifest } = built(); + const [entryKey] = + Object.entries(manifest).find(([, chunk]) => chunk.isEntry === true) ?? + []; + const [shellKey] = chunkOf(manifest, 'App.tsx'); + const [podKey] = chunkOf(manifest, 'PodScreen.tsx'); + + return { manifest, entryKey, shellKey, podKey }; + }; + + beforeAll(() => { + state.built = buildClient(outDir); + }); + + afterAll(() => fs.rmSync(outDir, { recursive: true, force: true })); + + it('splits every screen into its own chunk, none reached from the entry', () => { + const { manifest, entryKey } = keys(); + expect(entryKey).toBe('index.html'); + const fromEntry = staticallyReached(manifest, entryKey ?? ''); + + SCREENS.forEach((screen) => { + const [key, chunk] = chunkOf(manifest, screen); + + expect({ screen, dynamic: chunk?.isDynamicEntry }).toEqual({ + screen, + dynamic: true, + }); + expect({ screen, fromEntry: fromEntry.has(key ?? '') }).toEqual({ + screen, + fromEntry: false, + }); + }); + }); + + it('keeps the pod screen out of the signed-in shell’s chunk', () => { + const { manifest, shellKey, podKey } = keys(); + expect(shellKey).toBeDefined(); + expect(podKey).toBeDefined(); + expect(manifest[shellKey ?? '']?.isDynamicEntry).toBe(true); + expect(manifest[shellKey ?? '']?.dynamicImports ?? []).toContain(podKey); + expect(staticallyReached(manifest, shellKey ?? '').has(podKey ?? '')).toBe( + false + ); + + const shell = built().code(shellKey ?? ''); + const pod = built().code(podKey ?? ''); + + POD_ONLY_MARKERS.forEach((marker) => { + expect({ marker, inPodScreen: pod.includes(marker) }).toEqual({ + marker, + inPodScreen: true, + }); + expect({ marker, inShell: shell.includes(marker) }).toEqual({ + marker, + inShell: false, + }); + }); + }); +}); diff --git a/src/app/__tests__/clipWait.test.ts b/src/app/__tests__/clipWait.test.ts new file mode 100644 index 0000000..802eb75 --- /dev/null +++ b/src/app/__tests__/clipWait.test.ts @@ -0,0 +1,61 @@ +import { waitForClip } from '../client/clipWait'; + +/** The player waits for Kokoro, never for a timer, and never switches voice. */ + +const options = ( + overrides: Partial<Parameters<typeof waitForClip>[1]> = {} +) => ({ + again: jest.fn(async () => null), + retries: 2, + gapMs: 3_000, + sleep: jest.fn(async () => undefined), + cancelled: () => false, + ...overrides, +}); + +describe('waitForClip', () => { + it('waits however long the render takes (no timeout)', async () => { + const slow = new Promise<string | null>((resolve) => { + setTimeout(() => resolve('clip.mp3'), 50); + }); + const args = options(); + + await expect(waitForClip(slow, args)).resolves.toBe('clip.mp3'); + expect(args.again).not.toHaveBeenCalled(); + expect(args.sleep).not.toHaveBeenCalled(); + }); + + it('asks again when a clip comes back unavailable', async () => { + const again = jest + .fn<Promise<string | null>, []>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('clip.mp3'); + const args = options({ again }); + + await expect(waitForClip(Promise.resolve(null), args)).resolves.toBe( + 'clip.mp3' + ); + expect(again).toHaveBeenCalledTimes(2); + expect(args.sleep).toHaveBeenCalledWith(3_000); + }); + + it('gives up (null) after its retries, rather than another voice', async () => { + const args = options(); + + await expect(waitForClip(Promise.resolve(null), args)).resolves.toBeNull(); + expect(args.again).toHaveBeenCalledTimes(2); + }); + + it('stops asking once the listener has moved on', async () => { + const state = { moved: false }; + const args = options({ + sleep: jest.fn(async () => { + state.moved = true; + }), + cancelled: () => state.moved, + }); + + await expect(waitForClip(Promise.resolve(null), args)).resolves.toBeNull(); + expect(args.again).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/__tests__/designRoutes.test.ts b/src/app/__tests__/designRoutes.test.ts new file mode 100644 index 0000000..8f30b36 --- /dev/null +++ b/src/app/__tests__/designRoutes.test.ts @@ -0,0 +1,217 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { required } from '../../__tests__/required'; +import { + type FakeRunner, + type Harness, + connectRunner, + holdProject, + podSnapshot, + startHarness, +} from '../../mothership/__tests__/harness'; +import { storeSnapshots } from '../../mothership/pods'; +import { podFileRequestOf, servePodFile } from '../../runner/podFiles'; +import { FULL_UNAVAILABLE, RenderSwitch } from '../client/WorkflowPanel'; + +/** + * The UX review's pictures in Lambda: `/api/pod-file/` sits behind the + * sign-in, is forwarded to the runner that has the pod (the mothership reads + * none of a pod's files), and the workflow panel greys out Full when that + * runner can't use Claude Design. + */ + +// Spy-able: the mothership's file reads are counted. +jest.mock('fs', () => ({ ...jest.requireActual('fs') })); +jest.mock('../client/Tip', () => ({ + Tip: ({ children }: { children: unknown }) => children, +})); + +const POD = 'pawnee-map-a1b2'; +const FOLDER = '/home/leslie/pawnee-parks'; +/** One tiny valid PNG (1×1). */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + +/** A pods root on "the runner": one design picture and the pod's own files. */ +const seedPodsRoot = (): string => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-design-routes-')) + ); + const designs = path.join(root, POD, 'designs', 'trail-map'); + fs.mkdirSync(designs, { recursive: true }); + fs.writeFileSync(path.join(designs, 'trail-closed.png'), PNG); + fs.writeFileSync(path.join(root, POD, 'pod.json'), '{"secret":true}'); + + return root; +}; + +describe('the pod-file route', () => { + const fixture: { harness?: Harness; runner?: FakeRunner; root?: string } = {}; + const harness = (): Harness => required(fixture.harness, 'harness'); + const runner = (): FakeRunner => required(fixture.runner, 'runner'); + const asked: { files: unknown[] } = { files: [] }; + + beforeEach(async () => { + asked.files = []; + fixture.root = seedPodsRoot(); + fixture.harness = await startHarness(); + fixture.runner = await connectRunner(harness(), { + name: "Ann's MacBook", + }); + await runner().next('owner'); + holdProject(harness(), runner().runnerId, { folder: FOLDER }); + storeSnapshots( + harness().db, + runner().runnerId, + FOLDER, + [podSnapshot(POD)], + [], + harness().clock.now + ); + }); + + afterEach(async () => { + await runner() + .close() + .catch(() => undefined); + await harness().close(); + fs.rmSync(required(fixture.root, 'root'), { recursive: true, force: true }); + }); + + it('is refused without a session, and the runner is never asked', async () => { + runner().on('pod-file', (message) => { + asked.files = [...asked.files, message]; + + return null; + }); + const response = await harness().request( + 'GET', + `/api/pod-file/${POD}/designs/trail-map/trail-closed.png` + ); + + expect(response.status).toBe(401); + expect(asked.files).toEqual([]); + }); + + it("forwards the request to the pod's runner, and the mothership reads no file", async () => { + runner().on('pod-file', (message) => { + asked.files = [...asked.files, message]; + + return { base64: PNG.toString('base64'), headers: {} }; + }); + const cookie = harness().signIn(); + // The module object itself: every `import * as fs` reads through it. + const realFs = jest.requireMock<typeof fs>('fs'); + const reads = [ + jest.spyOn(realFs, 'readFileSync'), + jest.spyOn(realFs, 'createReadStream'), + jest.spyOn(realFs, 'openSync'), + jest.spyOn(realFs.promises, 'readFile'), + ]; + + try { + const response = await harness().request( + 'GET', + `/api/pod-file/${POD}/designs/trail-map/trail-closed.png`, + { cookie } + ); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('image/png'); + reads.forEach((read) => expect(read).toHaveBeenCalledTimes(0)); + } finally { + reads.forEach((read) => read.mockRestore()); + } + + expect(asked.files).toEqual([ + expect.objectContaining({ + type: 'pod-file', + podId: POD, + folder: 'designs', + path: 'trail-map/trail-closed.png', + }), + ]); + }); + + it("serves the runner's own picture through the runner's checks, and never the pod's other files", async () => { + const root = required(fixture.root, 'root'); + runner().on('pod-file', (message) => + servePodFile(root, podFileRequestOf(message)) + ); + const cookie = harness().signIn(); + const get = (urlPath: string) => + harness().request('GET', urlPath, { cookie }); + + const picture = await get( + `/api/pod-file/${POD}/designs/trail-map/trail-closed.png` + ); + + expect(picture.status).toBe(200); + expect(picture.headers['cache-control']).toBe('no-cache'); + + const statuses = await Promise.all( + [ + `/api/pod-file/${POD}/designs/../pod.json`, + `/api/pod-file/${POD}/designs/%2e%2e/pod.json`, + `/api/pod-file/${POD}/designs/trail-map/missing.png`, + ].map(async (urlPath) => (await get(urlPath)).status) + ); + + expect(statuses).toEqual([404, 404, 404]); + }); + + it('answers 409 "<computer name> is offline." when the pod\'s runner is disconnected', async () => { + const cookie = harness().signIn(); + await runner().close(); + // The mothership notices the socket closing. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const response = await harness().request( + 'GET', + `/api/pod-file/${POD}/designs/trail-map/trail-closed.png`, + { cookie } + ); + + expect(response.status).toBe(409); + expect(response.text).toContain("Ann's MacBook is offline."); + }); +}); + +describe("the workflow panel's Render switch", () => { + const render = (designAvailable: boolean | undefined): string => + renderToStaticMarkup( + createElement(RenderSwitch, { + mode: 'ascii', + designAvailable, + onChange: () => undefined, + }) + ); + + it("greys out Full, saying why, when the pod's runner reports design.available false", () => { + const html = render(false); + + expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Full<\/button>/); + expect(html).toContain( + 'Full needs Claude Design, which isn't available with this Claude account.' + ); + expect(FULL_UNAVAILABLE).toBe( + "Full needs Claude Design, which isn't available with this Claude account." + ); + }); + + it.each([true, undefined])( + 'offers Full when the runner reports %s', + (available) => { + const html = render(available); + + expect(html).not.toContain('disabled'); + expect(html).not.toContain('Full needs Claude Design'); + } + ); +}); diff --git a/src/app/__tests__/designSystemRoutes.test.ts b/src/app/__tests__/designSystemRoutes.test.ts new file mode 100644 index 0000000..218b1b9 --- /dev/null +++ b/src/app/__tests__/designSystemRoutes.test.ts @@ -0,0 +1,1284 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { required } from '../../__tests__/required'; +import type { MachineConfig } from '../../config/machineConfig'; +import { loadProjectConfig } from '../../config/projectConfig'; +import type { Project } from '../../config/projectConfig'; +import type * as Detect from '../../design/system/detect'; +import { detectStack } from '../../design/system/detect'; +import { + type FakeRunner, + type Harness, + connectRunner, + eventually, + holdProject, + startHarness, +} from '../../mothership/__tests__/harness'; +import { + makeDesignSystem, + readDesignSystemPage, +} from '../../runner/designSystemSettings'; +import { openTicketSource } from '../../tickets/context'; +import type { TicketSource } from '../../tickets/source'; +import { DesignSystemView } from '../client/SettingsPage'; +import { + type DesignSystemInfo, + type DesignSystemPage, + type MadeDesignSystem, + designSystemControls, + forProject, + openTicketLine, + rereadsDesignSystem, + showsMakeIt, + systemLines, +} from '../shared/designSystem'; + +/** + * Settings → Design system: the mothership never reads the project folder; + * the runner holding the project reads the design system as committed on + * `origin/main` and makes the setup ticket there. + */ + +jest.mock('../client/Tip', () => ({ + Tip: ({ children }: { children: unknown }) => children, +})); + +// detectStack, still working, but counted. +jest.mock('../../design/system/detect', () => { + const actual = jest.requireActual<typeof Detect>( + '../../design/system/detect' + ); + + return { ...actual, detectStack: jest.fn(actual.detectStack) }; +}); + +const PROJECT = 'Pawnee parks map'; +const SETUP_TITLE = 'Make the design system from the app'; +const MANIFEST = '.lambda/design-system/design-system.json'; +const DESIGN_AVAILABLE: MachineConfig = { + design: { + available: true, + typeUrl: 'https://claude.ai/artifact/pawnee-design-type', + checkedAt: '2026-10-21T12:00:00.000Z', + }, +}; + +const MANIFEST_JSON = { + namespace: 'PawneeParksMap', + version: 1, + builtFrom: 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', + syncedBy: 'PAWNEE-12', + source: 'code+photos', + check: { + result: 'close', + rounds: 3, + photos: 4, + issues: [ + { + severity: 'medium-low', + where: 'Buttons', + what: 'corners 4px rounder than the app', + }, + ], + }, + components: ['Button', 'Card', 'Input', 'Dialog', 'ParkCard', 'BenchRow'], + patterns: ['MapView (the real one loads mapbox-gl)'], +}; + +const SCREENS_INDEX = `export const screens = [ + { id: 'parks', title: 'Parks list', render: () => null }, + { id: 'park', title: "Park page", render: () => null }, + { id: 'map', title: \`Map\`, render: () => null }, +]; +`; + +const sh = (cwd: string, args: string[]): string => + execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + +const commitAll = (cwd: string, subject: string): void => { + sh(cwd, ['add', '-A']); + sh(cwd, [ + '-c', + 'user.name=Leslie Knope', + '-c', + 'user.email=leslie@pawnee.gov', + 'commit', + '--quiet', + '-m', + subject, + ]); +}; + +const write = (root: string, file: string, text: string): void => { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); +}; + +interface Fixture { + base: string; + /** Where origin's commits are made. */ + upstream: string; + /** The runner's main checkout: a clone, with origin/main. */ + folder: string; +} + +/** origin's main, with the codebase's app on it; the checkout is a clone. */ +const makeRepos = ( + build: (upstream: string) => void = () => undefined +): Fixture => { + const base = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-design-system-')) + ); + const upstream = path.join(base, 'upstream'); + const folder = path.join(base, 'pawnee-parks-map'); + fs.mkdirSync(upstream); + sh(upstream, ['init', '--quiet', '-b', 'main']); + write( + upstream, + 'lambda.json', + JSON.stringify({ + name: PROJECT, + tickets: { source: 'markdown', prefix: 'PAWNEE' }, + }) + ); + write( + upstream, + 'package.json', + JSON.stringify({ + name: 'pawnee-parks-map', + dependencies: { react: '^18.3.1', tailwindcss: '^3.4.0' }, + }) + ); + write(upstream, 'TASKS.md', ''); + write(upstream, 'tailwind.config.ts', 'export default { theme: {} };\n'); + write( + upstream, + 'src/components/Button.tsx', + 'export const Button = () => null;\n' + ); + write( + upstream, + 'src/App.tsx', + "import { Button } from './components/Button';\nexport const App = () => Button();\n" + ); + commitAll(upstream, 'PAWNEE-1: The parks map'); + build(upstream); + execFileSync('git', ['clone', '--quiet', upstream, folder]); + + return { base, upstream, folder }; +}; + +/** PAWNEE-12 makes the design system on origin/main. */ +const addDesignSystem = (upstream: string): void => { + write(upstream, MANIFEST, `${JSON.stringify(MANIFEST_JSON, null, 2)}\n`); + write(upstream, '.lambda/design-system/screens/index.ts', SCREENS_INDEX); + write( + upstream, + '.lambda/design-system/sampleData.ts', + "export const parks = [{ name: 'Ramsett Park' }];\n" + ); + commitAll(upstream, `PAWNEE-12: ${SETUP_TITLE}`); +}; + +const benchRows = (upstream: string): void => { + write( + upstream, + '.lambda/design-system/components/BenchRow.tsx', + 'export const BenchRow = () => null; // opening hours\n' + ); + commitAll(upstream, "PAWNEE-31: Bench rows show the park's opening hours"); +}; + +const shortSha = (fixture: Fixture): string => + sh(fixture.folder, ['rev-parse', 'origin/main']).slice(0, 7); + +const machine = () => DESIGN_AVAILABLE; + +const sourceIn = (folder: string) => { + const project = loadProjectConfig(folder); + + if (project === null) { + throw new Error(`No project at ${folder}`); + } + + return openTicketSource(project, null); +}; + +/** The runner's half, as runtime.ts answers these. */ +const answerAsRunner = (runner: FakeRunner): void => { + runner.on('design-system-read', (message) => + readDesignSystemPage(String(message.folder), { machine }) + ); + runner.on('design-system-make', (message) => + makeDesignSystem(String(message.folder), String(message.by), { machine }) + ); +}; + +const sent = (runner: FakeRunner, type: string) => + runner.received.filter((message) => message.type === type); + +describe('Settings → Design system routes', () => { + const state: { + harness?: Harness; + runner?: FakeRunner; + cookie?: string; + repos?: Fixture; + projectId?: string; + } = {}; + const harness = (): Harness => required(state.harness, 'harness'); + const runner = (): FakeRunner => required(state.runner, 'runner'); + const cookie = (): string => required(state.cookie, 'cookie'); + const repos = (): Fixture => required(state.repos, 'repos'); + const projectId = (): string => required(state.projectId, 'projectId'); + + /** A fresh mothership, with Leslie's MacBook holding the fixture's checkout. */ + const start = async (fixture: Fixture): Promise<void> => { + state.repos = fixture; + state.harness = await startHarness(); + state.runner = await connectRunner(harness(), { + name: "Leslie's MacBook", + }); + await runner().next('owner'); + answerAsRunner(runner()); + state.projectId = holdProject(harness(), runner().runnerId, { + name: PROJECT, + folder: fixture.folder, + }); + state.cookie = harness().signIn(); + }; + + afterEach(async () => { + await state.runner?.close(); + await state.harness?.close(); + + if (state.repos !== undefined) { + fs.rmSync(state.repos.base, { recursive: true, force: true }); + } + + state.runner = undefined; + state.harness = undefined; + state.repos = undefined; + }); + + const read = () => + harness().request('GET', `/api/projects/${projectId()}/design-system`, { + cookie: cookie(), + }); + const make = () => + harness().request( + 'POST', + `/api/projects/${projectId()}/design-system/make`, + { cookie: cookie() } + ); + + it('builds the page from the manifest on origin/main, with the newest change', async () => { + await start( + makeRepos((upstream) => { + addDesignSystem(upstream); + benchRows(upstream); + }) + ); + // An uncommitted edit in the main checkout is never read. + write( + repos().folder, + MANIFEST, + JSON.stringify({ ...MANIFEST_JSON, version: 9, syncedBy: 'PAWNEE-99' }) + ); + + const response = await read(); + + expect(response.status).toBe(200); + const page = response.json<DesignSystemPage>(); + expect(sent(runner(), 'design-system-read')).toHaveLength(1); + expect(page.project).toBe(PROJECT); + expect(page.branch).toBe('main'); + expect(page.system).toMatchObject({ + version: 1, + syncedBy: 'PAWNEE-12', + builtFrom: MANIFEST_JSON.builtFrom, + check: MANIFEST_JSON.check, + components: MANIFEST_JSON.components, + patterns: MANIFEST_JSON.patterns, + screens: ['Parks list', 'Park page', 'Map'], + sampleData: true, + madeByHand: false, + lastChanged: { + ticket: 'PAWNEE-31', + sha: shortSha(repos()), + rest: "Bench rows show the park's opening hours", + }, + }); + expect(showsMakeIt(page)).toBe(false); + expect(page.look).toContain('tailwind.config.ts'); + + const lines = systemLines(page).map((line) => line.text); + expect(lines).toEqual([ + 'Version 1 · made by PAWNEE-12 from main@a1b2c3d', + `Last changed by PAWNEE-31 (main@${shortSha( + repos() + )}): Bench rows show the park's opening hours`, + "Kept in the codebase's code: .lambda/design-system", + 'Kept in step by the build: a ticket that changes how the app looks updates the design system too.', + 'Matches the app, with 1 small difference:', + 'Medium-low · Buttons: corners 4px rounder than the app', + 'Checked against 4 photos · ', + 'Look: colours, fonts and spacing from tailwind.config.ts', + 'Components (6): Button, Card, Input, Dialog, ParkCard, …', + 'Copied by hand (1): MapView (the real one loads mapbox-gl)', + 'Reference screens (3): Parks list, Park page, Map', + 'Sample data: one set, the one every design draws from', + ]); + + // View-only: no Make it. + const html = renderToStaticMarkup( + createElement(DesignSystemView, { + page, + made: null, + busy: false, + onMake: () => undefined, + }) + ); + expect(html).not.toContain('Make it'); + expect(html).toContain('pictures on PAWNEE-12'); + }); + + it('hides Last changed when the newest commit is the one that made it', async () => { + await start(makeRepos(addDesignSystem)); + + const page = (await read()).json<DesignSystemPage>(); + + expect(page.system?.version).toBe(1); + expect(page.system?.lastChanged).toBeNull(); + expect( + systemLines(page).some((line) => line.text.startsWith('Last changed')) + ).toBe(false); + }); + + it('shows a subject with no ticket id whole', async () => { + await start( + makeRepos((upstream) => { + addDesignSystem(upstream); + write( + upstream, + '.lambda/design-system/tokens.css', + ':root { --park-green: #2f6b3a; }\n' + ); + commitAll(upstream, 'Tidy the park greens'); + }) + ); + + const page = (await read()).json<DesignSystemPage>(); + + expect(page.system?.lastChanged).toEqual({ + ticket: 'Tidy the park greens', + sha: shortSha(repos()), + rest: '', + }); + expect(systemLines(page)[1]?.text).toBe( + `Last changed by Tidy the park greens (main@${shortSha(repos())})` + ); + expect(showsMakeIt(page)).toBe(false); + }); + + it('offers Make it with no manifest on origin/main, even with one in the checkout', async () => { + await start(makeRepos()); + write(repos().folder, MANIFEST, JSON.stringify(MANIFEST_JSON)); + + const page = (await read()).json<DesignSystemPage>(); + + expect(page.system).toBeNull(); + expect(page.setupTicket).toBeNull(); + expect(showsMakeIt(page)).toBe(true); + expect(page.designAvailable).toBe(true); + const html = renderToStaticMarkup( + createElement(DesignSystemView, { + page, + made: null, + busy: false, + onMake: () => undefined, + }) + ); + expect(html).toContain( + 'No design system yet. Designs use Claude Design's default look.' + ); + expect(html).toContain('>Make it</button>'); + expect(html).not.toContain('disabled'); + }); + + it('greys Make it when Claude Design isn’t available', () => { + const page: DesignSystemPage = { + project: PROJECT, + branch: 'main', + dir: '.lambda/design-system', + look: 'tailwind.config.ts', + components: '1 — Button', + designAvailable: false, + setupTicket: null, + system: null, + ticketsError: null, + }; + const html = renderToStaticMarkup( + createElement(DesignSystemView, { + page, + made: null, + busy: false, + onMake: () => undefined, + }) + ); + + expect(html).toContain('disabled'); + expect(html).toContain( + 'Needs Claude Design, which isn't available with this Claude account.' + ); + }); + + it('shows the open setup ticket and its status instead of Make it', async () => { + await start(makeRepos()); + await sourceIn(repos().folder).create({ + title: SETUP_TITLE, + status: 'building', + }); + + const page = (await read()).json<DesignSystemPage>(); + + expect(page.system).toBeNull(); + expect(page.setupTicket).toMatchObject({ + id: 'PAWNEE-1', + title: SETUP_TITLE, + status: 'building', + }); + expect(showsMakeIt(page)).toBe(false); + const html = renderToStaticMarkup( + createElement(DesignSystemView, { + page, + made: null, + busy: false, + onMake: () => undefined, + }) + ); + expect(html).toContain( + 'PAWNEE-1 is open: Make the design system from the app (building). Designs keep using Claude Design's default look until it ships.' + ); + expect(html).not.toContain('Make it</button>'); + }); + + it('makes the setup ticket once, and answers the open one after', async () => { + await start(makeRepos()); + + const first = await make(); + + expect(first.status).toBe(200); + expect(sent(runner(), 'design-system-make')).toHaveLength(1); + expect(sent(runner(), 'design-system-make')[0]).toMatchObject({ + by: 'Leslie Knope', + folder: repos().folder, + }); + const made = first.json<{ result: MadeDesignSystem }>().result; + expect(made).toMatchObject({ title: SETUP_TITLE, existing: false }); + expect(made.id).toMatch(/^PAWNEE-\d+$/); + + const tickets = await sourceIn(repos().folder).list(); + expect(tickets).toHaveLength(1); + expect(tickets[0]).toMatchObject({ id: made.id, status: 'ready' }); + + const html = renderToStaticMarkup( + createElement(DesignSystemView, { + page: (await read()).json<DesignSystemPage>(), + made, + busy: false, + onMake: () => undefined, + }) + ); + expect(html).toContain( + `Created ${made.id}: Make the design system from the app. It's in Ready. Designs use it once it ships.` + ); + + const second = await make(); + + expect(second.status).toBe(200); + expect(second.json<{ result: MadeDesignSystem }>().result).toMatchObject({ + id: made.id, + title: SETUP_TITLE, + existing: true, + }); + expect(await sourceIn(repos().folder).list()).toHaveLength(1); + }); + + it('refuses Make it when origin/main has a design system, creating nothing', async () => { + await start(makeRepos(addDesignSystem)); + + const response = await make(); + + expect(response.status).toBe(409); + expect(response.json().error).toBe( + 'Pawnee parks map already has a design system.' + ); + expect(await sourceIn(repos().folder).list()).toEqual([]); + }); + + it('says the computer is offline, with no socket message and no ticket', async () => { + await start(makeRepos()); + const { runnerId } = runner(); + await runner().close(); + await eventually(() => !harness().mothership.relay.isConnected(runnerId)); + + const made = await make(); + const page = await read(); + + expect(made.status).toBe(409); + expect(made.json().error).toBe("Leslie's MacBook is offline."); + expect(page.status).toBe(409); + expect(page.json().error).toBe("Leslie's MacBook is offline."); + expect(sent(runner(), 'design-system-make')).toEqual([]); + expect(sent(runner(), 'design-system-read')).toEqual([]); + expect(await sourceIn(repos().folder).list()).toEqual([]); + }); + + it("refuses a computer's Make it answer that isn't a ticket", async () => { + await start(makeRepos()); + runner().on('design-system-make', () => ({ id: 'PAWNEE-1' })); + + const response = await make(); + + expect(response.status).toBe(500); + expect(harness().logs).toEqual([ + expect.stringContaining( + 'Unexpected answer from the computer to design-system-make.' + ), + ]); + }); + + it("refuses a computer's page that isn't a design system page", async () => { + await start(makeRepos()); + runner().on('design-system-read', () => ({ project: PROJECT })); + + const response = await read(); + + expect(response.status).toBe(500); + expect(harness().logs).toEqual([ + expect.stringContaining( + 'Unexpected answer from the computer to design-system-read.' + ), + ]); + }); + + it("refuses a computer's page with a malformed field, relaying none of it", async () => { + await start(makeRepos()); + const good: DesignSystemPage = { + project: PROJECT, + branch: 'main', + dir: '.lambda/design-system', + look: 'none', + components: 'none', + designAvailable: null, + setupTicket: null, + system: null, + ticketsError: null, + }; + const replies: unknown[] = [ + { ...good, designAvailable: 'yes' }, + { + ...good, + setupTicket: { + id: 'PAWNEE-12', + title: SETUP_TITLE, + url: 'javascript:alert(1)', + status: 'ready', + blockReason: null, + }, + }, + { ...good, extra: 'Li’l Sebastian' }, + ]; + runner().on('design-system-read', () => replies.shift()); + + expect((await read()).status).toBe(500); + expect((await read()).status).toBe(500); + const relayed = await read(); + expect(relayed.status).toBe(200); + expect(relayed.json()).toEqual(good); + }); + + it('refuses both routes without a session, and both are in the route table', async () => { + await start(makeRepos()); + const samples = harness().mothership.routes.map( + (route) => `${route.method} ${route.sample}` + ); + + expect(samples).toEqual( + expect.arrayContaining([ + 'GET /api/projects/prj_1/design-system', + 'POST /api/projects/prj_1/design-system/make', + ]) + ); + + const get = await harness().request( + 'GET', + `/api/projects/${projectId()}/design-system` + ); + const post = await harness().request( + 'POST', + `/api/projects/${projectId()}/design-system/make` + ); + + expect(get.status).toBe(401); + expect(post.status).toBe(401); + expect(runner().received.map((message) => message.type)).not.toEqual( + expect.arrayContaining(['design-system-read']) + ); + expect(sent(runner(), 'design-system-make')).toEqual([]); + }); +}); + +describe('Settings → Design system, read on the runner', () => { + const state: { repos?: Fixture } = {}; + const repos = (): Fixture => required(state.repos, 'repos'); + + afterEach(() => { + if (state.repos !== undefined) { + fs.rmSync(state.repos.base, { recursive: true, force: true }); + } + + state.repos = undefined; + jest.mocked(detectStack).mockClear(); + }); + + const SCREENS_WITH_SITUATIONS = `export const screens = [ + { + file: 'ParksList.tsx', + title: 'Parks list', + situations: [ + { slug: 'empty', title: 'No parks yet' }, + { slug: 'full', title: 'Every park in Pawnee' }, + ], + path: [{ step: 'open', action: 'Open Parks' }], + }, + { + file: 'Park.tsx', + title: 'Park page', + situations: [{ slug: 'closed', title: 'Ramsett Park, closed' }], + }, +]; +`; + + it('lists only the screens, never their situations', async () => { + state.repos = makeRepos((upstream) => { + addDesignSystem(upstream); + write( + upstream, + '.lambda/design-system/screens/index.ts', + SCREENS_WITH_SITUATIONS + ); + commitAll(upstream, 'PAWNEE-14: Situations on the reference screens'); + }); + + const page = await readDesignSystemPage(repos().folder, { machine }); + + expect(page.system?.screens).toEqual(['Parks list', 'Park page']); + expect(systemLines(page).map((line) => line.text)).toContain( + 'Reference screens (2): Parks list, Park page' + ); + }); + + it("prefers the manifest's screens when it lists them", async () => { + state.repos = makeRepos((upstream) => { + addDesignSystem(upstream); + write( + upstream, + MANIFEST, + JSON.stringify({ ...MANIFEST_JSON, screens: ['Parks list', 'Map'] }) + ); + write( + upstream, + '.lambda/design-system/screens/index.ts', + SCREENS_WITH_SITUATIONS + ); + commitAll(upstream, 'PAWNEE-15: The manifest names its screens'); + }); + + const page = await readDesignSystemPage(repos().folder, { machine }); + + expect(page.system?.screens).toEqual(['Parks list', 'Map']); + }); + + const failingSource = (project: Project): TicketSource => ({ + ...openTicketSource(project, null), + list: () => Promise.reject(new Error('The tracker is down.')), + }); + + it('still shows a design system when the tickets can’t be read', async () => { + state.repos = makeRepos(addDesignSystem); + + const page = await readDesignSystemPage(repos().folder, { + machine, + openSource: failingSource, + }); + + expect(page.system?.version).toBe(1); + expect(page.setupTicket).toBeNull(); + expect(page.ticketsError).toBe( + "Couldn't read the codebase's tickets: The tracker is down." + ); + expect(systemLines(page).map((line) => line.text)).toContain( + "Couldn't read the codebase's tickets: The tracker is down." + ); + }); + + it('fails the page with no design system when the tickets can’t be read', async () => { + state.repos = makeRepos(); + + await expect( + readDesignSystemPage(repos().folder, { + machine, + openSource: failingSource, + }) + ).rejects.toThrow('The tracker is down.'); + }); + + it('hides Last changed for the commit that made it, even after a rename', async () => { + state.repos = makeRepos((upstream) => { + write(upstream, 'old/design-system.json', JSON.stringify(MANIFEST_JSON)); + commitAll(upstream, 'PAWNEE-11: A design system in the old folder'); + fs.mkdirSync(path.join(upstream, '.lambda/design-system'), { + recursive: true, + }); + sh(upstream, ['mv', 'old/design-system.json', MANIFEST]); + commitAll(upstream, `PAWNEE-12: ${SETUP_TITLE}`); + }); + + const page = await readDesignSystemPage(repos().folder, { machine }); + + expect(page.system?.lastChanged).toBeNull(); + }); + + it('detects the stack once per commit on origin/main', async () => { + state.repos = makeRepos(addDesignSystem); + + const first = await readDesignSystemPage(repos().folder, { machine }); + const second = await readDesignSystemPage(repos().folder, { machine }); + + expect(jest.mocked(detectStack)).toHaveBeenCalledTimes(1); + expect(second.look).toBe(first.look); + expect(second.components).toBe(first.components); + + benchRows(repos().upstream); + sh(repos().folder, ['fetch', '--quiet']); + await readDesignSystemPage(repos().folder, { machine }); + + expect(jest.mocked(detectStack)).toHaveBeenCalledTimes(2); + }); +}); + +describe('which runner Make it relays to', () => { + const state: { + harness?: Harness; + macBook?: FakeRunner; + macMini?: FakeRunner; + repos?: Fixture; + projectId?: string; + cookie?: string; + } = {}; + const harness = (): Harness => required(state.harness, 'harness'); + const macBook = (): FakeRunner => required(state.macBook, 'macBook'); + const macMini = (): FakeRunner => required(state.macMini, 'macMini'); + const repos = (): Fixture => required(state.repos, 'repos'); + const projectId = (): string => required(state.projectId, 'projectId'); + const cookie = (): string => required(state.cookie, 'cookie'); + + const lastSeen = (runner: FakeRunner, at: number): void => { + harness() + .db.prepare('UPDATE runners SET last_seen_at = ? WHERE id = ?') + .run(at, runner.runnerId); + }; + const currentProject = (runner: FakeRunner, id: string | null): void => { + harness() + .db.prepare('UPDATE runners SET current_project_id = ? WHERE id = ?') + .run(id, runner.runnerId); + }; + + beforeEach(async () => { + state.repos = makeRepos(); + state.harness = await startHarness(); + state.macBook = await connectRunner(harness(), { + name: "Leslie's MacBook", + }); + await macBook().next('owner'); + state.macMini = await connectRunner(harness(), { + name: "Leslie's Mac mini", + }); + await macMini().next('owner'); + answerAsRunner(macBook()); + answerAsRunner(macMini()); + // The MacBook is on another project. + const eagleton = holdProject(harness(), macBook().runnerId, { + name: 'Eagleton parks', + folder: path.join(repos().base, 'eagleton'), + }); + state.projectId = holdProject(harness(), macBook().runnerId, { + name: PROJECT, + folder: repos().folder, + current: false, + }); + currentProject(macBook(), eagleton); + state.cookie = harness().signIn(); + }); + + afterEach(async () => { + await state.macBook?.close(); + await state.macMini?.close(); + await state.harness?.close(); + fs.rmSync(repos().base, { recursive: true, force: true }); + }); + + const make = () => + harness().request( + 'POST', + `/api/projects/${projectId()}/design-system/make`, + { cookie: cookie() } + ); + const read = () => + harness().request('GET', `/api/projects/${projectId()}/design-system`, { + cookie: cookie(), + }); + + /** The Mac mini holds Pawnee parks map too, in the same checkout. */ + const macMiniHolds = (current: boolean): void => { + holdProject(harness(), macMini().runnerId, { + name: PROJECT, + folder: repos().folder, + current, + }); + }; + + it('goes to the runner on the project, even when the other was seen later', async () => { + macMiniHolds(true); + lastSeen(macMini(), 1000); + lastSeen(macBook(), 2000); + + const response = await make(); + + expect(response.status).toBe(200); + expect(sent(macMini(), 'design-system-make')).toHaveLength(1); + expect(sent(macBook(), 'design-system-make')).toEqual([]); + + lastSeen(macMini(), 1000); + lastSeen(macBook(), 2000); + expect((await read()).status).toBe(200); + expect(sent(macMini(), 'design-system-read')).toHaveLength(1); + expect(sent(macBook(), 'design-system-read')).toEqual([]); + }); + + it('else goes to the runner seen most recently', async () => { + macMiniHolds(false); + currentProject(macMini(), null); + lastSeen(macMini(), 1000); + lastSeen(macBook(), 2000); + + expect((await make()).status).toBe(200); + expect(sent(macBook(), 'design-system-make')).toHaveLength(1); + expect(sent(macMini(), 'design-system-make')).toEqual([]); + + lastSeen(macMini(), 3000); + lastSeen(macBook(), 2000); + expect((await read()).status).toBe(200); + expect(sent(macMini(), 'design-system-read')).toHaveLength(1); + expect(sent(macBook(), 'design-system-read')).toEqual([]); + }); + + it('goes to the MacBook when only it holds the project', async () => { + const response = await make(); + + expect(response.status).toBe(200); + expect(sent(macBook(), 'design-system-make')).toHaveLength(1); + expect(sent(macMini(), 'design-system-make')).toEqual([]); + }); + + it('says the MacBook is offline when its only runner is disconnected', async () => { + const { runnerId } = macBook(); + await macBook().close(); + await eventually(() => !harness().mothership.relay.isConnected(runnerId)); + + const response = await make(); + + expect(response.status).toBe(409); + expect(response.json().error).toBe("Leslie's MacBook is offline."); + expect(sent(macBook(), 'design-system-make')).toEqual([]); + expect(sent(macMini(), 'design-system-make')).toEqual([]); + expect(await sourceIn(repos().folder).list()).toEqual([]); + }); +}); + +describe('Settings → Design system, the other states', () => { + const system: DesignSystemInfo = { + version: 2, + madeByHand: false, + syncedBy: 'PAWNEE-12', + builtFrom: 'a1b2c3d', + source: 'code', + check: { result: 'match', rounds: 2, photos: 4, issues: [] }, + components: [], + patterns: [], + screens: [], + sampleData: false, + lastChanged: null, + picturesUrl: null, + }; + const page = (fields: Partial<DesignSystemInfo>): DesignSystemPage => ({ + project: PROJECT, + branch: 'main', + dir: '.lambda/design-system', + look: 'none', + components: 'none', + designAvailable: true, + setupTicket: null, + system: { ...system, ...fields }, + ticketsError: null, + }); + const texts = (fields: Partial<DesignSystemInfo>): string[] => + systemLines(page(fields)).map((line) => line.text); + + it('says when it was made from photos only, or not checked', () => { + expect( + texts({ + source: 'photos', + check: { + result: 'not-checked', + rounds: 0, + photos: 0, + issues: [], + reason: + 'this codebase has no photo hook (commands.shot in lambda.json)', + }, + }) + ).toEqual( + expect.arrayContaining([ + "Made from photos only: Lambda couldn't find this app's theme or components in the code, so designs look like the app but use stand-in parts.", + 'Not checked against the app: this codebase has no photo hook (commands.shot in lambda.json).', + ]) + ); + }); + + it('says a design system with no version was made by hand', () => { + const lines = texts({ + version: null, + madeByHand: true, + syncedBy: null, + check: null, + }); + + expect(lines[0]).toBe('Made by hand, not by Lambda.'); + expect(lines.some((line) => line.startsWith('Version'))).toBe(false); + }); + + it('links the pictures on the ticket that made it', () => { + const photos = systemLines( + page({ picturesUrl: 'https://linear.app/pawnee/issue/PAWNEE-12' }) + ).find((line) => line.text.startsWith('Checked against')); + + expect(photos).toEqual({ + text: 'Checked against 4 photos · ', + link: { + label: 'pictures on PAWNEE-12', + url: 'https://linear.app/pawnee/issue/PAWNEE-12', + }, + }); + }); + + it('says a blocked setup ticket is blocked, and why', () => { + expect( + openTicketLine({ + id: 'PAWNEE-12', + title: SETUP_TITLE, + url: null, + status: 'blocked', + blockReason: + "after 7 rounds the design system still didn't match the app. High · Header: dark green bar, the app's is white.", + }) + ).toBe( + "PAWNEE-12 is blocked: after 7 rounds the design system still didn't match the app. High · Header: dark green bar, the app's is white. Designs keep using Claude Design's default look." + ); + }); +}); + +describe('Settings → Design system, a failed Make it', () => { + const MAKE_FAILED = + "Couldn't create the design system ticket: The tracker is down."; + + it("isn't retried on the heartbeat, so its message stays", () => { + expect(rereadsDesignSystem({ readError: null, busy: false })).toBe(false); + }); + + it('a failed read is still retried on the heartbeat, but not while busy', () => { + const offline = "Leslie's MacBook is offline."; + + expect(rereadsDesignSystem({ readError: offline, busy: false })).toBe(true); + expect(rereadsDesignSystem({ readError: offline, busy: true })).toBe(false); + }); + + it('keeps its message to the project it was pressed on', () => { + const answered = { projectId: 'prj_parks', value: MAKE_FAILED }; + + expect(forProject(answered, 'prj_parks')).toBe(MAKE_FAILED); + expect(forProject(answered, 'prj_bakery')).toBeNull(); + expect(forProject(null, 'prj_parks')).toBeNull(); + }); +}); + +describe('Settings → Design system, a Make it answer after a project change', () => { + const made: MadeDesignSystem = { + id: 'PAWNEE-12', + title: SETUP_TITLE, + url: null, + existing: false, + }; + + it('shows the created ticket only on the project it was made for', () => { + const answered = { projectId: 'prj_parks', value: made }; + + expect(forProject(answered, 'prj_parks')).toBe(made); + expect(forProject(answered, 'prj_bakery')).toBeNull(); + }); +}); + +describe('Settings → Design system, Claude Design on this computer', () => { + const state: { repos?: Fixture; home?: string } = {}; + const repos = (): Fixture => required(state.repos, 'repos'); + const lambdaHome = process.env.LAMBDA_HOME; + + /** A `LAMBDA_HOME` for this test, holding `config` when given. */ + const homeWith = (config?: string): void => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-home-')); + + if (config !== undefined) { + fs.writeFileSync(path.join(home, 'config.json'), config); + } + + state.home = home; + process.env.LAMBDA_HOME = home; + }; + + afterEach(() => { + process.env.LAMBDA_HOME = lambdaHome; + + for (const dir of [state.repos?.base, state.home]) { + if (dir !== undefined) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + state.repos = undefined; + state.home = undefined; + jest.restoreAllMocks(); + }); + + const view = (page: DesignSystemPage): string => + renderToStaticMarkup( + createElement(DesignSystemView, { + page, + made: null, + busy: false, + onMake: () => undefined, + }) + ); + + it('says unknown, not missing, on a computer that never checked, and Make it works', async () => { + state.repos = makeRepos(); + homeWith(); + + const page = await readDesignSystemPage(repos().folder); + + expect(page.designAvailable).toBeNull(); + expect(view(page)).toContain('>Make it</button>'); + expect(view(page)).not.toContain('disabled'); + }); + + it('greys Make it only when Claude Design was found missing', () => { + const page: DesignSystemPage = { + project: PROJECT, + branch: 'main', + dir: '.lambda/design-system', + look: 'none', + components: 'none', + designAvailable: null, + setupTicket: null, + system: null, + ticketsError: null, + }; + + expect(view(page)).not.toContain('Needs Claude Design'); + expect(view({ ...page, designAvailable: false })).toContain('disabled'); + }); + + it('still reads the page when ~/.lambda/config.json is malformed', async () => { + state.repos = makeRepos(addDesignSystem); + homeWith('{ not json'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const page = await readDesignSystemPage(repos().folder); + + expect(page.system?.version).toBe(1); + expect(page.designAvailable).toBeNull(); + }); + + it('links the pictures on an archived setup ticket, from the tracker', async () => { + state.repos = makeRepos(addDesignSystem); + const url = 'https://linear.app/pawnee/issue/PAWNEE-12'; + const asked: string[] = []; + const archived = (project: Project): TicketSource => ({ + ...openTicketSource(project, null), + list: () => Promise.resolve([]), + get: (id: string) => { + asked.splice(asked.length, 0, id); + + return Promise.resolve({ + id, + title: SETUP_TITLE, + status: 'done', + labels: [], + parent: null, + children: [], + blockedBy: [], + blockReason: null, + reporter: null, + url, + priority: 'none', + assignee: null, + description: '', + }); + }, + }); + + const page = await readDesignSystemPage(repos().folder, { + machine, + openSource: archived, + }); + + expect(asked).toEqual(['PAWNEE-12']); + expect(page.system?.picturesUrl).toBe(url); + }); +}); + +describe('Settings → Design system, the page’s reads and Make it', () => { + const PAGE: DesignSystemPage = { + project: PROJECT, + branch: 'main', + dir: '.lambda/design-system', + look: 'none', + components: 'none', + designAvailable: true, + setupTicket: null, + system: null, + ticketsError: null, + }; + const MADE: MadeDesignSystem = { + id: 'PAWNEE-12', + title: SETUP_TITLE, + url: null, + existing: false, + }; + + /** Controls over fakes; `calls` records every target call in order. */ + const controlsWith = (io: { + read?: () => Promise<DesignSystemPage>; + make?: () => Promise<MadeDesignSystem>; + }) => { + const calls: [string, unknown][] = []; + const record = + (kind: string) => + (value: unknown): void => { + calls.splice(calls.length, 0, [kind, value]); + }; + const controls = designSystemControls({ + read: io.read ?? (() => Promise.resolve(PAGE)), + make: io.make ?? (() => Promise.resolve(MADE)), + load: async (request) => { + calls.splice(calls.length, 0, ['load', null]); + await request.then(record('page'), (caught: unknown) => + record('error')(String(caught)) + ); + }, + errorText: (caught) => + caught instanceof Error ? caught.message : String(caught), + shown: () => 'prj_parks', + target: { + page: record('page'), + error: record('error'), + made: record('made'), + makeError: record('makeError'), + busy: record('busy'), + reading: record('reading'), + }, + }); + + return { calls, controls }; + }; + + it("clears the last project's page and error on a project change", async () => { + const { calls, controls } = controlsWith({}); + + await controls.open('prj_bakery'); + + const beforeRead = calls.slice( + 0, + calls.findIndex(([kind]) => kind === 'load') + ); + expect(beforeRead).toEqual( + expect.arrayContaining([ + ['page', null], + ['error', null], + ]) + ); + }); + + it('reads the page again after a failed Make it, keeping its message', async () => { + const { calls, controls } = controlsWith({ + make: () => Promise.reject(new Error('Conflict: it exists.')), + }); + + await controls.make('prj_parks'); + + const failed = calls.findIndex(([kind]) => kind === 'makeError'); + expect(calls.slice(failed).map(([kind]) => kind)).toEqual( + expect.arrayContaining(['makeError', 'load', 'page']) + ); + expect(calls.filter(([kind]) => kind === 'makeError').at(-1)).toEqual([ + 'makeError', + { projectId: 'prj_parks', value: 'Conflict: it exists.' }, + ]); + }); + + it('says a read is on its way, so the heartbeat doesn’t stack another', async () => { + const answer: { resolve: (page: DesignSystemPage) => void } = { + resolve: () => undefined, + }; + const { calls, controls } = controlsWith({ + read: () => + new Promise<DesignSystemPage>((resolve) => { + answer.resolve = resolve; + }), + }); + + const reading = controls.read('prj_parks'); + expect(calls).toContainEqual(['reading', true]); + answer.resolve(PAGE); + await reading; + expect(calls.at(-1)).toEqual(['reading', false]); + }); + + it('keeps "Created …" when the read after it fails', () => { + const html = renderToStaticMarkup( + createElement(DesignSystemView, { + page: null, + made: MADE, + busy: false, + onMake: () => undefined, + }) + ); + + expect(html).toContain('Created PAWNEE-12'); + }); +}); diff --git a/src/app/__tests__/devProxy.test.ts b/src/app/__tests__/devProxy.test.ts new file mode 100644 index 0000000..dc41798 --- /dev/null +++ b/src/app/__tests__/devProxy.test.ts @@ -0,0 +1,20 @@ +import { proxiedOrigin } from '../devProxy'; + +const MOTHERSHIP = 'http://127.0.0.1:4580'; + +describe('the dev server’s proxy to the mothership', () => { + it('presents its own page as the mothership’s origin', () => { + expect(proxiedOrigin('http://localhost:4581', MOTHERSHIP)).toBe(MOTHERSHIP); + expect(proxiedOrigin('http://127.0.0.1:4581', MOTHERSHIP)).toBe(MOTHERSHIP); + }); + + it('passes any other origin through, so the mothership refuses it', () => { + expect(proxiedOrigin('http://localhost:3000', MOTHERSHIP)).toBe( + 'http://localhost:3000' + ); + expect(proxiedOrigin('https://evil.example', MOTHERSHIP)).toBe( + 'https://evil.example' + ); + expect(proxiedOrigin(undefined, MOTHERSHIP)).toBeUndefined(); + }); +}); diff --git a/src/app/__tests__/images.test.ts b/src/app/__tests__/images.test.ts new file mode 100644 index 0000000..c65a9bc --- /dev/null +++ b/src/app/__tests__/images.test.ts @@ -0,0 +1,77 @@ +import { + imageUrl, + podImageSrc, + splitImages, + withImage, + withWords, + withoutImage, + wordsOf, +} from '../shared/images'; + +const SHOT = '/home/leslie/.pods/_uploads/img_m1x2_ab12cd.png'; + +describe('images in a message', () => { + it('splits a message into its words and the images it carries', () => { + expect( + splitImages(`The lot is flooded\n[Image: ${SHOT}]\nsee above`) + ).toEqual({ text: 'The lot is flooded\nsee above', images: [SHOT] }); + }); + + it('adds an image on its own line, and takes it out again', () => { + const draft = withImage('Look at this ', SHOT); + + expect(draft).toBe(`Look at this\n[Image: ${SHOT}]\n`); + expect(withImage('', SHOT)).toBe(`[Image: ${SHOT}]\n`); + expect(withoutImage(draft, SHOT)).toBe('Look at this\n'); + }); + + it('serves only its own uploads', () => { + expect(imageUrl(SHOT)).toBe('/api/uploads/img_m1x2_ab12cd.png'); + expect(imageUrl('/etc/passwd')).toBeNull(); + expect(imageUrl('/tmp/PAWNEE-74.png')).toBeNull(); + }); +}); + +describe('the message box shows words, not image lines', () => { + it('shows only the words, as typed (trailing space and new line kept)', () => { + expect(wordsOf(withImage('The lot is flooded ', SHOT))).toBe( + 'The lot is flooded' + ); + expect(wordsOf(`Ron says \n[Image: ${SHOT}]`)).toBe('Ron says '); + }); + + it('typing keeps the attached images, after the words', () => { + const draft = withImage('', SHOT); + const typed = withWords(draft, 'Leslie, look'); + + expect(splitImages(typed)).toEqual({ + text: 'Leslie, look', + images: [SHOT], + }); + expect(wordsOf(typed)).toBe('Leslie, look'); + }); + + it('a draft without images is just the words', () => { + expect(withWords('old', 'Ann Perkins')).toBe('Ann Perkins'); + }); +}); + +describe('podImageSrc', () => { + it("serves a pod's own picture linked by its path on disk", () => { + expect( + podImageSrc( + '/home/a/work/pawnee/.pods/live-home-3557/shots/home-desktop.png' + ) + ).toBe('/api/pod-file/live-home-3557/shots/home-desktop.png'); + }); + + it('leaves any other picture as it is', () => { + expect(podImageSrc('https://example.com/a.png')).toBe( + 'https://example.com/a.png' + ); + expect(podImageSrc('/etc/passwd')).toBe('/etc/passwd'); + expect(podImageSrc('/api/uploads/img_a_b.png')).toBe( + '/api/uploads/img_a_b.png' + ); + }); +}); diff --git a/src/app/__tests__/items.test.ts b/src/app/__tests__/items.test.ts new file mode 100644 index 0000000..4ca6443 --- /dev/null +++ b/src/app/__tests__/items.test.ts @@ -0,0 +1,470 @@ +import type { Question, ThreadMessage } from '../../pod-store'; +import { pendingReply } from '../shared/conversation'; +import { + awaitingAgent, + exchangeView, + exchangesOf, + itemChat, + originView, + splitExchange, + waitingOnSpawned, +} from '../shared/items'; +import { contextSections } from '../shared/speech'; + +const at = (minute: number): string => + `2026-10-04T10:${String(minute).padStart(2, '0')}:00.000Z`; + +const msg = ( + id: string, + from: ThreadMessage['from'], + text: string, + minute: number, + extra: Partial<ThreadMessage> = {} +): ThreadMessage => ({ + id, + podId: 'pawnee-a1b2', + qid: 'q1', + from, + mode: 'async', + text, + createdAt: at(minute), + delivery: 'received', + ...(from === 'human' ? { target: 'dev' } : {}), + ...extra, +}); + +const question = (id: string, overrides: Partial<Question> = {}): Question => ({ + id, + podId: 'pawnee-a1b2', + kind: 'question', + phase: 1, + status: 'open', + blocksCheckpoint: false, + dependsOn: [], + createdAt: at(0), + committedAt: at(0), + summary: 'Should the ten-day fallback ship? I recommend A.', + options: [], + ...overrides, +}); + +const checkpoint = question('q1', { + kind: 'checkpoint', + summary: 'I read the epic.', + checkpoint: { cp: 1, decisionIds: [] }, +}); + +describe('the item chat', () => { + const thread = [ + msg('m1', 'human', 'what if the trail map is offline?', 1), + msg('m2', 'dev', 'on it — reading the trail-marker ticket', 2, { + progress: true, + }), + msg('m3', 'dev', 'Leslie, that needs two decisions from you.', 3), + msg('m4', 'dev', 'resolution for the dev', 4, { + from: 'fork', + target: 'dev', + }), + msg('m5', 'system', 'startup', 5), + ]; + + it('holds what the owner said and what Claude said TO them — not agent traffic', () => { + expect(itemChat(thread, 'q1').map((message) => message.id)).toEqual([ + 'm1', + 'm2', + 'm3', + ]); + }); + + it('shows the latest exchange without progress notes', () => { + const { exchange } = exchangeView(itemChat(thread, 'q1')); + + expect(exchange?.you?.id).toBe('m1'); + expect(exchange?.replies.map((reply) => reply.id)).toEqual(['m3']); + }); + + it('talking about it in the Chat panel never takes it out of the inbox', () => { + expect( + awaitingAgent(checkpoint, [ + msg('c1', 'human', 'what is a trail marker?', 1, { + mode: 'sync', + chat: true, + target: 'fork-chat', + }), + ]) + ).toBe(false); + }); + + it('ending a live conversation with no last word does not hide the item', () => { + const live = [ + msg('l1', 'human', 'does Ron have to inspect it?', 1, { mode: 'sync' }), + msg('l2', 'fork', 'No: a photo is enough.', 2), + msg('l3', 'human', '', 3, { mode: 'sync' }), + ]; + + expect(awaitingAgent(checkpoint, live)).toBe(false); + // A last word still waits for Claude. + expect( + awaitingAgent(checkpoint, [ + ...live.slice(0, 2), + msg('l4', 'human', 'ok, apply it', 3, { mode: 'sync' }), + ]) + ).toBe(true); + }); + + it('keeps the item out of the inbox until the real reply, not the progress note', () => { + expect(awaitingAgent(checkpoint, thread.slice(0, 2))).toBe(true); + expect(awaitingAgent(checkpoint, thread.slice(0, 3))).toBe(false); + }); +}); + +describe('decisions spawned from a message', () => { + const thread = [ + msg('m1', 'human', 'what if the trail map is offline?', 1), + msg('m3', 'dev', 'That needs two decisions from you.', 3), + ]; + const spawned = (id: string, overrides: Partial<Question> = {}) => + question(id, { + origin: { kind: 'message', messageId: 'm1', qid: 'q1' }, + ...overrides, + }); + + it('hides the item while they are open', () => { + expect( + waitingOnSpawned( + checkpoint, + [checkpoint, spawned('q2'), spawned('q3')], + thread + ) + ).toBe(true); + }); + + it('keeps it hidden until Claude replies after the last answer', () => { + const answered = [ + checkpoint, + spawned('q2', { status: 'answered', answeredAt: at(10) }), + spawned('q3', { status: 'answered', answeredAt: at(11) }), + ]; + + expect(waitingOnSpawned(checkpoint, answered, thread)).toBe(true); + expect( + waitingOnSpawned(checkpoint, answered, [ + ...thread, + msg('m9', 'dev', 'Applied both: …', 12), + ]) + ).toBe(false); + }); + + it('a reply on the spawned question itself brings the item back too', () => { + const answered = [ + checkpoint, + spawned('q2', { status: 'answered', answeredAt: at(10) }), + ]; + + // The dev answered on q2 ("Going with A… the card is updated"), not on + // the checkpoint: that used to leave it hidden — a stall nothing nudged. + expect( + waitingOnSpawned(checkpoint, answered, [ + ...thread, + msg('m9', 'dev', 'Going with A: the card is updated.', 12, { + qid: 'q2', + }), + ]) + ).toBe(false); + }); + + it('so does an edit to the card after the answer', () => { + const edited = { + ...checkpoint, + checkpoint: { + cp: 1, + decisionIds: [], + ...checkpoint.checkpoint, + refreshedAt: at(12), + }, + }; + const answered = [ + edited, + spawned('q2', { status: 'answered', answeredAt: at(10) }), + ]; + + expect(waitingOnSpawned(edited, answered, thread)).toBe(false); + }); + + it('quotes their own words, links back, and numbers the series', () => { + const questions = [checkpoint, spawned('q2'), spawned('q3')]; + const origin = originView(spawned('q3'), questions, thread); + + expect(origin?.lead).toBe('From what you said on Checkpoint 1'); + expect(origin?.link).toEqual({ qid: 'q1', label: 'Checkpoint 1' }); + expect(origin?.quote).toBe('what if the trail map is offline?'); + expect(origin?.reply).toBe('That needs two decisions from you.'); + expect(origin?.series).toEqual({ index: 1, total: 2 }); + expect(origin?.spoken).toBe( + 'This comes from what you said on Checkpoint 1: "what if the trail map is offline?". Lambda answered: "That needs two decisions from you." It\'s the second of 2 decisions.' + ); + }); +}); + +describe('a question from one part of a long message', () => { + it('quotes just that part and the answer to it — the whole message is kept aside', () => { + const long = '1: the legend?\n2: why north always up?\n3: closed trails?'; + const thread = [ + msg('m1', 'human', long, 1), + msg('m3', 'dev', '1: … 2: … 3: … (answers to everything)', 3), + ]; + const decision = question('q2', { + origin: { + kind: 'message', + messageId: 'm1', + qid: 'q1', + quote: '1: the legend?', + reply: 'The legend lets a ranger hide a closed trail in one tap.', + }, + }); + const origin = originView(decision, [checkpoint, decision], thread); + + expect(origin?.quote).toBe('1: the legend?'); + expect(origin?.reply).toBe( + 'The legend lets a ranger hide a closed trail in one tap.' + ); + expect(origin?.fullMessage).toBe(long); + expect(origin?.spoken).not.toContain('north always'); + expect(origin?.spoken).toContain( + 'Lambda answered: "The legend lets a ranger hide a closed trail in one tap."' + ); + }); +}); + +describe('a reply answered part by part', () => { + const long = '1: the legend?\n\n2: when would a trail stay hidden?'; + const thread = [ + msg('m1', 'human', long, 1), + msg('m2', 'dev', 'An old, plain reply to everything.', 2), + msg('m3', 'dev', '1: …\n2: …', 3, { + replyTo: 'm1', + parts: [ + { + quote: '1: the legend?', + answer: 'It lets a ranger hide a closed trail.', + }, + { + quote: '2: when would a trail stay hidden?', + answer: 'Only while it is closed.', + }, + ], + }), + ]; + const decision = question('q2', { + status: 'answered', + origin: { + kind: 'message', + messageId: 'm1', + qid: 'q1', + quote: '1: the legend?', + }, + }); + const human = [{ qid: 'q2', decision: 'drop the legend' }]; + + it('pairs each part with its answer and the decision it became', () => { + const split = splitExchange( + exchangeView(itemChat(thread, 'q1')).exchange, + [checkpoint, decision], + human + ); + + expect(split?.parts.map((part) => part.answer)).toEqual([ + 'It lets a ranger hide a closed trail.', + 'Only while it is closed.', + ]); + expect(split?.parts[0]?.decision).toMatchObject({ + qid: 'q2', + resolved: true, + chose: 'drop the legend', + }); + expect(split?.parts[1]?.decision).toBeUndefined(); + // The split reply replaces the plain one before it. + expect(split?.rest).toEqual([]); + }); +}); + +describe('what is read first', () => { + it("reads how it got here — not the item's back-and-forth, which is in the chat", () => { + const decision = question('q2', { + origin: { + kind: 'work', + phase: 2, + doing: 'checking the trail-marker ticket against the code', + }, + }); + const thread = [ + msg('m1', 'human', 'what about the ridge loop?', 1, { qid: 'q2' }), + msg('m2', 'dev', 'It counts as a main trail.', 2, { qid: 'q2' }), + ]; + const sections = contextSections(decision, [decision], thread); + + expect(sections.map((section) => section.key)).toEqual(['origin']); + expect(sections[0]?.text).toBe( + 'This came up during Epic review, while checking the trail-marker ticket against the code.' + ); + }); + + it('adds nothing for an item nobody has talked about', () => { + expect(contextSections(checkpoint, [checkpoint], [])).toEqual([]); + }); +}); + +describe('a live question does not replace what the item opens with', () => { + const thread = [ + msg('m1', 'human', 'why blue markers?', 1), + msg('m2', 'dev', 'Because red means closed.', 2), + msg('m3', 'human', 'and the ridge loop?', 3), + msg('m4', 'dev', 'It stays on the map.', 4), + // Then a live (⌘⏎) question about it. + msg('m5', 'human', 'does Ron have to walk it first?', 5, { + mode: 'sync', + }), + msg('m6', 'fork', 'No: a photo is enough.', 6), + ]; + const chat = itemChat(thread, 'q1'); + + it('splits the chat into exchanges, oldest first', () => { + expect( + exchangesOf(chat).map((exchange) => [ + exchange.you?.id, + exchange.replies.map((reply) => reply.id), + ]) + ).toEqual([ + ['m1', ['m2']], + ['m3', ['m4']], + ['m5', ['m6']], + ]); + }); + + it('opens with the last sent reply, and reads the live talk after it', () => { + const view = exchangeView(chat); + + expect(view.exchange?.you?.id).toBe('m3'); + expect(view.index).toBe(1); + expect(view.total).toBe(3); + expect(view.liveAfter.map((exchange) => exchange.you?.id)).toEqual(['m5']); + }); + + it('steps back to an earlier exchange — with no live tail', () => { + const view = exchangeView(chat, 0); + + expect(view.exchange?.you?.id).toBe('m1'); + expect(view.isDefault).toBe(false); + expect(view.liveAfter).toEqual([]); + }); + + it('opens with the last live exchange when there is nothing else', () => { + expect( + exchangeView(itemChat(thread.slice(4), 'q1')).exchange?.you?.id + ).toBe('m5'); + }); +}); + +describe('pendingReply — the side chat never ends their turn', () => { + it('keeps "Your turn" through a side-chat message', () => { + const at = (n: number) => `2026-10-10T00:00:0${n}.000Z`; + const base = { + podId: 'p', + mode: 'note' as const, + delivery: 'delivered' as const, + }; + const ask = { + ...base, + id: 'a', + from: 'dev' as const, + text: 'Which?', + expectsReply: true, + createdAt: at(1), + }; + const side = { + ...base, + id: 's', + from: 'human' as const, + text: 'photo', + chat: true, + createdAt: at(2), + }; + + expect(pendingReply([ask, side])?.id).toBe('a'); + expect( + pendingReply([ + ask, + side, + { + ...base, + id: 'r', + from: 'human' as const, + text: 'A', + createdAt: at(3), + }, + ]) + ).toBeNull(); + }); + + it('in a freeform chat, any reply from the dev is their turn', () => { + const at = (n: number) => `2026-10-19T00:00:0${n}.000Z`; + const base = { + podId: 'p', + mode: 'note' as const, + delivery: 'delivered' as const, + }; + const theirs = { + ...base, + id: 'h', + from: 'human' as const, + text: '1. A', + createdAt: at(1), + }; + const fyi = { + ...base, + id: 'f', + from: 'dev' as const, + text: 'All three are applied in Linear.', + createdAt: at(2), + }; + const progress = { + ...fyi, + id: 'p1', + text: 'checking PAWNEE-34', + progress: true, + createdAt: at(3), + }; + + expect(pendingReply([theirs, fyi])).toBeNull(); + expect(pendingReply([theirs, fyi], true)?.id).toBe('f'); + expect(pendingReply([theirs, fyi, progress], true)?.id).toBe('f'); + expect(pendingReply([theirs], true)).toBeNull(); + }); + + it("their answer on one of the pod's items answers the dev's ask too", () => { + const base = { + podId: 'p', + mode: 'note' as const, + delivery: 'delivered' as const, + }; + const ask = { + ...base, + id: 'a', + from: 'dev' as const, + text: 'One question is left: the marker-colour one.', + expectsReply: true, + createdAt: '2026-10-19T00:00:01.000Z', + }; + const onItem = { + ...base, + id: 'd', + from: 'human' as const, + text: 'Go with D.', + qid: 'q5', + createdAt: '2026-10-19T00:00:02.000Z', + }; + + expect(pendingReply([ask])?.id).toBe('a'); + expect(pendingReply([ask, onItem])).toBeNull(); + }); +}); diff --git a/src/app/__tests__/keys.test.ts b/src/app/__tests__/keys.test.ts new file mode 100644 index 0000000..9e26e06 --- /dev/null +++ b/src/app/__tests__/keys.test.ts @@ -0,0 +1,25 @@ +import { toHexBytes } from '../client/keys'; +import { toSendKeysCalls } from '../server/keys'; + +const calls = (typed: string): string[][] => toSendKeysCalls(toHexBytes(typed)); + +describe('toSendKeysCalls', () => { + it('sends text raw and Enter as the named key (a raw CR does not submit)', () => { + expect(calls('ok\r')).toEqual([['-H', '6f', '6b'], ['Enter']]); + }); + + it('maps backspace, tab, ctrl-c and arrows to named keys', () => { + expect(calls('a\x7f')).toEqual([['-H', '61'], ['BSpace']]); + expect(calls('\t')).toEqual([['Tab']]); + expect(calls('\x03')).toEqual([['C-c']]); + expect(calls('\x1b[A\x1b[D')).toEqual([['Up'], ['Left']]); + }); + + it('treats a lone trailing ESC as the Escape key', () => { + expect(calls('\x1b')).toEqual([['Escape']]); + }); + + it('keeps multi-byte characters raw', () => { + expect(calls('é')).toEqual([['-H', 'c3', 'a9']]); + }); +}); diff --git a/src/app/__tests__/lambdaPage.test.ts b/src/app/__tests__/lambdaPage.test.ts new file mode 100644 index 0000000..a941b7e --- /dev/null +++ b/src/app/__tests__/lambdaPage.test.ts @@ -0,0 +1,219 @@ +import { + ago, + blockerChoices, + epicProgress, + focusTicket, + foldedLine, + mainAction, + ordinal, + phaseDivider, + phaseStrip, + queuePlace, + stripStepOf, +} from '../shared/lambdaPage'; +import type { Lambda, LambdaTicket } from '../shared/lambdas'; + +/** A Lambda's page past planning: the same page in every phase. */ + +const NOW = Date.parse('2026-10-26T12:00:00.000Z'); + +const ticket = (id: string, where: LambdaTicket['where']): LambdaTicket => ({ + id, + title: `Ticket ${id}`, + where, +}); + +const lambda = (extra: Partial<Lambda> = {}): Lambda => ({ + key: 'pod:plan-parks', + title: 'Parks site launch', + projectId: 'parks', + planningPodId: 'plan-parks', + ticketId: 'PAWNEE-2', + tickets: [], + phase: 'queued', + needsYou: [], + working: false, + shippedAt: null, + paused: false, + ...extra, +}); + +const queued = (place: number): LambdaTicket['where'] => ({ + kind: 'queued', + place, + computer: "Leslie's Mac", + podId: 'build-leslie', + ahead: [], +}); + +describe("a Lambda's page", () => { + it('shows QA in the phase strip only when the review step is on', () => { + expect(phaseStrip('ready', false).map((one) => one.label)).toEqual([ + 'Planning', + 'Ready', + 'Building', + 'Merged', + ]); + expect( + phaseStrip('building', true).map((one) => [one.id, one.state]) + ).toEqual([ + ['planning', 'done'], + ['ready', 'done'], + ['building', 'current'], + ['qa', 'todo'], + ['merged', 'todo'], + ]); + expect(phaseStrip('qa', false).map((one) => one.id)).toContain('qa'); + expect(stripStepOf('shipped')).toBe('merged'); + }); + + it('is about the ticket waiting on the owner first, a blocker before a review', () => { + const page = lambda({ + tickets: [ + ticket('PAWNEE-2a', { + kind: 'building', + step: 'build', + startedAt: '2026-10-26T11:00:00.000Z', + computer: "Leslie's Mac", + podId: 'build-leslie', + }), + ticket('PAWNEE-2b', queued(2)), + ticket('PAWNEE-2c', { + kind: 'blocked', + computer: "Ann's Mac", + podId: 'build-ann', + messageId: 'm1', + }), + ], + needsYou: [ + { + podId: 'build-leslie', + kind: 'reply', + key: 'k-review', + ticketId: 'PAWNEE-2a', + tag: 'review', + }, + { + podId: 'build-ann', + kind: 'reply', + key: 'k-blocked', + ticketId: 'PAWNEE-2c', + tag: 'blocked', + }, + ], + }); + + expect(focusTicket(page)?.id).toBe('PAWNEE-2c'); + expect(mainAction(page)).toBe('Answer, and carry on'); + expect( + focusTicket({ ...page, needsYou: page.needsYou.slice(0, 1) })?.id + ).toBe('PAWNEE-2a'); + }); + + it("says a queued ticket's place in line and roughly when it starts", () => { + expect(ordinal(3)).toBe('3rd'); + expect(ordinal(12)).toBe('12th'); + expect(ordinal(22)).toBe('22nd'); + expect(queuePlace(ticket('PAWNEE-4', queued(3)), 20 * 60_000)).toEqual({ + headline: '3rd in line', + detail: "on Leslie's Mac · starts in about 40 min", + }); + expect(queuePlace(ticket('PAWNEE-4', queued(1)), 20 * 60_000)).toEqual({ + headline: 'Next in line', + detail: "on Leslie's Mac", + }); + }); + + it('folds every phase before this one into one line', () => { + expect(foldedLine('ready', 23, 5)).toBe( + 'Planning · 5 checkpoints, 23 messages' + ); + expect(foldedLine('building', 31, 5)).toBe( + 'Planning and Ready · 31 messages' + ); + expect(foldedLine('qa', 44, 5)).toBe( + 'Planning, Ready and Building · 44 messages' + ); + expect(foldedLine('merged', 18, 0)).toBe( + 'Planning, Ready and Building · 18 messages' + ); + }); + + it('marks where each phase starts', () => { + expect( + phaseDivider( + lambda({ phase: 'building' }), + ticket('PAWNEE-6', { + kind: 'building', + step: 'build', + startedAt: '2026-10-26T11:42:00.000Z', + computer: "Leslie's Mac", + podId: 'build-leslie', + }), + NOW, + {} + ) + ).toBe("Leslie's Mac picked it up · 18m ago"); + expect( + phaseDivider( + lambda({ phase: 'shipped', shippedAt: '2026-10-26T10:00:00.000Z' }), + null, + NOW, + { commit: '4bde137aa' } + ) + ).toBe('Merged · 4bde137 · 2h ago'); + expect( + phaseDivider(lambda({ phase: 'qa' }), null, NOW, { + builtMs: 31 * 60_000, + }) + ).toBe('Built in 31m · waiting for your review'); + expect(ago('2026-10-23T12:00:00.000Z', NOW)).toBe('3d ago'); + }); + + it("gives each phase its main action, and an epic's progress once a part ships", () => { + expect(mainAction(lambda({ phase: 'queued' }))).toBe('Plan more'); + expect(mainAction(lambda({ phase: 'qa' }))).toBe('Approve and merge'); + expect(mainAction(lambda({ phase: 'shipped' }))).toBe("Plan what's next"); + expect(mainAction(lambda({ phase: 'building' }))).toBeNull(); + + const shipped = ticket('PAWNEE-1', { + kind: 'shipped', + at: '2026-10-26T10:00:00.000Z', + computer: "Leslie's Mac", + podId: 'build-leslie', + }); + + expect( + epicProgress( + lambda({ + tickets: [ + shipped, + { ...shipped, id: 'PAWNEE-3' }, + ticket('PAWNEE-4', queued(2)), + ticket('PAWNEE-5', queued(3)), + ticket('PAWNEE-7', queued(4)), + ], + }) + )?.line + ).toBe('Parks site launch is 2 of 5 shipped'); + expect(epicProgress(lambda({ tickets: [shipped] }))).toBeNull(); + }); +}); + +describe("a Blocked item's choices", () => { + it('splits its numbered options, picking the one its recommendation names', () => { + expect( + blockerChoices( + '1) Parks only, for now 2) Both Parks and Animal Control', + 'Parks only, for now.' + ) + ).toEqual({ + choices: ['Parks only, for now', 'Both Parks and Animal Control'], + recommended: 0, + }); + expect(blockerChoices('Look at the Agents tab', 'Check it.')).toEqual({ + choices: [], + recommended: -1, + }); + }); +}); diff --git a/src/app/__tests__/lambdas.test.ts b/src/app/__tests__/lambdas.test.ts new file mode 100644 index 0000000..10438b5 --- /dev/null +++ b/src/app/__tests__/lambdas.test.ts @@ -0,0 +1,315 @@ +import type { Pod, Question } from '../../pod-store'; +import type { RunView } from '../shared/building'; +import { + askRouteOf, + lambdaOfTicket, + lambdasOf, + sidebarSections, +} from '../shared/lambdas'; +import type { AppState, InboxEntry, PodSummary } from '../shared/types'; + +/** One Lambda across its whole life: planning, its tickets, their builds. */ + +const NOW = Date.parse('2026-10-26T12:00:00.000Z'); + +const pod = (id: string, extra: Partial<Pod> = {}): Pod => ({ + id, + slug: id, + title: id, + createdAt: '2026-10-20T10:00:00.000Z', + status: 'active', + phase: 1, + completedCheckpoints: [], + worktreePath: `/tmp/${id}`, + ownsWorktree: false, + managerCwd: `/tmp/${id}`, + devCwd: `/tmp/${id}`, + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, + ...extra, +}); + +const summary = ( + podValue: Pod, + extra: Partial<PodSummary> = {} +): PodSummary => ({ + pod: podValue, + running: true, + status: 'working', + needsYou: 0, + liveForks: [], + chatHold: false, + ...extra, +}); + +const run = (id: string, extra: Partial<RunView> = {}): RunView => ({ + id, + title: `Ticket ${id}`, + step: 'build', + startedAt: '2026-10-26T11:00:00.000Z', + shippedAt: null, + blocked: null, + ...extra, +}); + +const building = ( + id: string, + runs: RunView[], + upNext: Array<{ id: string; title: string }> +): PodSummary => + summary(pod(id, { kind: 'building' }), { + building: { + state: 'building', + current: runs.some( + (one) => one.shippedAt === null && one.blocked === null + ) + ? { id: 'x', title: 'x', step: 'build', attempt: 1 } + : null, + position: null, + idleReason: null, + runs, + upNext, + }, + }); + +const checkpoint = (ids: string[]): Question => ({ + id: 'q3', + podId: 'plan-parks', + kind: 'checkpoint', + phase: 3, + status: 'answered', + blocksCheckpoint: true, + dependsOn: [], + createdAt: '2026-10-21T10:00:00.000Z', + options: [], + checkpoint: { + cp: 3, + decisionIds: [], + planTickets: ids.map((id) => ({ + id, + title: `Ticket ${id}`, + name: `Plain ${id}`, + })), + }, +}); + +const entry = (key: string, extra: Partial<InboxEntry> = {}): InboxEntry => ({ + podId: 'build-leslie', + kind: 'reply', + key, + ...extra, +}); + +const state = (extra: Partial<AppState> = {}): AppState => ({ + pods: [ + summary( + pod('plan-parks', { + status: 'done', + epicId: 'PAWNEE-2', + title: 'Parks site launch', + }) + ), + summary(pod('plan-dog'), { devBusy: true }), + summary(pod('plan-chat', { status: 'done', title: 'A chat' })), + building( + 'build-leslie', + [ + run('PAWNEE-1', { shippedAt: '2026-10-26T10:00:00.000Z' }), + run('PAWNEE-2a', { step: 'test' }), + ], + [ + { id: 'PAWNEE-2b', title: 'Ticket PAWNEE-2b' }, + { id: 'PAWNEE-4', title: 'Contact page' }, + ] + ), + ], + inbox: [], + questions: { 'plan-parks': [checkpoint(['PAWNEE-2a', 'PAWNEE-2b'])] }, + priority: ['plan-dog', 'plan-parks'], + lambda: { + owner: { name: 'Leslie Knope', initials: 'LK' }, + projects: [], + lastProjectId: null, + podProjects: { 'plan-parks': 'parks', 'build-leslie': 'parks' }, + podComputers: { 'build-leslie': "Leslie's MacBook Pro" }, + waiting: [], + }, + ...extra, +}); + +describe('a Lambda across its whole life', () => { + it("follows a plan's tickets into the build: building while one is, with each ticket's place", () => { + const parks = lambdasOf(state()).find( + (one) => one.key === 'pod:plan-parks' + ); + + expect(parks?.phase).toBe('building'); + expect( + parks?.tickets.map((ticket) => [ + ticket.id, + ticket.title, + ticket.where.kind, + ]) + ).toEqual([ + ['PAWNEE-2a', 'Plain PAWNEE-2a', 'building'], + ['PAWNEE-2b', 'Plain PAWNEE-2b', 'queued'], + ]); + expect(parks?.tickets[1]?.where).toMatchObject({ + kind: 'queued', + place: 2, + computer: "Leslie's MacBook Pro", + }); + }); + + it('drops a ticket an ended pod put back, but keeps what it shipped', () => { + const ended = building( + 'build-ann', + [ + run('PAWNEE-7', { shippedAt: '2026-10-26T09:00:00.000Z' }), + run('PAWNEE-2a', { + step: 'pick-up', + startedAt: '2026-10-26T11:30:00.000Z', + }), + ], + [{ id: 'PAWNEE-9', title: 'Lot 48 signage' }] + ); + const base = state(); + const lambdas = lambdasOf({ + ...base, + pods: [ + ...base.pods.filter((one) => one.pod.id !== 'build-leslie'), + { ...ended, pod: { ...ended.pod, status: 'done' } }, + ], + }); + + expect( + lambdas.find((one) => one.key === 'pod:plan-parks')?.tickets[0]?.where + .kind + ).not.toBe('building'); + expect( + lambdas + .filter((one) => one.planningPodId === null) + .map((one) => [one.ticketId, one.phase]) + ).toEqual([['PAWNEE-7', 'shipped']]); + }); + + it('gives a ticket made directly a Lambda of its own', () => { + const lambdas = lambdasOf(state()); + + expect( + lambdas + .filter((one) => one.planningPodId === null) + .map((one) => [one.ticketId, one.phase]) + ).toEqual([ + ['PAWNEE-1', 'shipped'], + ['PAWNEE-4', 'queued'], + ]); + }); + + it('puts what waits on the owner in Needs you only, a build blocker first', () => { + const blocked = state({ + inbox: [ + entry('build-leslie/reply/m1', { + ticketId: 'PAWNEE-2a', + tag: 'blocked', + }), + ], + }); + const sections = sidebarSections(lambdasOf(blocked), blocked.inbox, NOW); + + expect(sections.needsYou.map((one) => one.key)).toEqual(['pod:plan-parks']); + expect(sections.building).toEqual([]); + }); + + it('sorts the rest by phase: planning, queued, shipped for a day, and done', () => { + const sections = sidebarSections(lambdasOf(state()), [], NOW); + + expect(sections.planning.map((one) => one.key)).toEqual(['pod:plan-dog']); + expect(sections.planning[0]?.working).toBe(true); + expect(sections.building.map((one) => one.key)).toEqual(['pod:plan-parks']); + expect(sections.queued.map((one) => one.ticketId)).toEqual(['PAWNEE-4']); + expect(sections.shipped.map((one) => one.ticketId)).toEqual(['PAWNEE-1']); + expect(sections.done.map((one) => one.key)).toEqual(['pod:plan-chat']); + expect( + sidebarSections(lambdasOf(state()), [], NOW + 25 * 60 * 60_000).shipped + ).toEqual([]); + }); + + it('reads as QA while a ticket of it waits on the owner’s review', () => { + const review = state({ + inbox: [ + entry('build-leslie/reply/m2', { + ticketId: 'PAWNEE-2a', + tag: 'review', + }), + ], + }); + + expect( + lambdasOf(review).find((one) => one.key === 'pod:plan-parks')?.phase + ).toBe('qa'); + }); +}); + +describe("a ticket's Lambda", () => { + it("finds a ticket's Lambda through its epic, and none for a ticket nobody has", () => { + const lambdas = lambdasOf(state()); + + expect(lambdaOfTicket(lambdas, 'parks', 'PAWNEE-2b')?.key).toBe( + 'pod:plan-parks' + ); + expect(lambdaOfTicket(lambdas, 'parks', 'PAWNEE-2')?.key).toBe( + 'pod:plan-parks' + ); + expect(lambdaOfTicket(lambdas, 'parks', 'PAWNEE-99')).toBeUndefined(); + }); + + it('sends an ask to the block it waits on, the planning chat while planning, else Lambda', () => { + const blocked = state({ + inbox: [ + entry('build-leslie/reply/m1', { + ticketId: 'PAWNEE-2a', + tag: 'blocked', + messageId: 'm1', + }), + ], + }); + const lambdas = lambdasOf(blocked); + + expect( + askRouteOf(lambdaOfTicket(lambdas, 'parks', 'PAWNEE-2a'), 'PAWNEE-2a') + ).toEqual({ to: 'block', podId: 'build-leslie', messageId: 'm1' }); + expect( + askRouteOf(lambdaOfTicket(lambdas, 'parks', 'PAWNEE-2b'), 'PAWNEE-2b') + ).toEqual({ to: 'lambda' }); + + const planning = lambdasOf(state()).find( + (one) => one.key === 'pod:plan-dog' + ); + + expect(askRouteOf(planning, 'PAWNEE-30')).toEqual({ + to: 'planning', + podId: 'plan-dog', + }); + expect(askRouteOf(undefined, 'PAWNEE-30')).toEqual({ to: 'lambda' }); + }); + + it('gives an ask about a ticket no row of its own, until a reply waits on the owner', () => { + const talk = summary( + pod('talk-ab12', { kind: 'building', talk: { ticketId: 'PAWNEE-30' } }) + ); + const quiet = state({ pods: [...state().pods, talk] }); + + expect(lambdasOf(quiet).some((one) => one.ticketId === 'PAWNEE-30')).toBe( + false + ); + + const asked = { + ...quiet, + inbox: [entry('talk-ab12/reply/m9', { podId: 'talk-ab12' })], + }; + const sections = sidebarSections(lambdasOf(asked), asked.inbox, NOW); + + expect(sections.needsYou.map((one) => one.ticketId)).toEqual(['PAWNEE-30']); + }); +}); diff --git a/src/app/__tests__/liveCards.test.ts b/src/app/__tests__/liveCards.test.ts new file mode 100644 index 0000000..63dbc15 --- /dev/null +++ b/src/app/__tests__/liveCards.test.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { type Pod, type Question, createPodStore } from '../../pod-store'; +import { refreshStaleCards, staleCards } from '../server/liveCards'; + +/** + * Which cards the inbox re-reads: an OPEN checkpoint whose recorded files + * changed after the card was last read — once per edit, so an edit that fails + * its checks isn't re-run until the file changes again. + */ + +const POD = 'pawnee-a1b2'; +const PROPOSAL = '/tmp/pawnee/proposal.json'; +const READ_AT = '2026-10-16T10:00:00.000Z'; + +const pod: Pod = { + id: POD, + slug: 'pawnee', + createdAt: '2026-10-07T00:00:00.000Z', + status: 'active', + phase: 3, + completedCheckpoints: [], + worktreePath: '/tmp/pawnee', + ownsWorktree: false, + managerCwd: '/tmp/pawnee', + devCwd: '/tmp/pawnee', + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, + pipeline: 2, +}; + +const checkpoint = (overrides: Partial<Question> = {}): Question => ({ + id: 'q1', + podId: POD, + kind: 'checkpoint', + phase: 3, + status: 'open', + blocksCheckpoint: true, + dependsOn: [], + createdAt: '2026-10-16T09:00:00.000Z', + options: [], + checkpoint: { + cp: 3, + decisionIds: [], + sources: { summary: 'Two tickets.', proposalFile: PROPOSAL }, + refreshedAt: READ_AT, + }, + ...overrides, +}); + +const storeWith = (question: Question) => { + const store = createPodStore( + fs.mkdtempSync(path.join(os.tmpdir(), 'pods-live-')) + ); + store.writePod(pod); + store.writeQuestion(question); + + return store; +}; + +const later = Date.parse(READ_AT) + 5_000; +const edited = (file: string): number | null => + file === PROPOSAL ? later : null; + +it('a file edited after the card was read makes the card stale', () => { + expect(staleCards(storeWith(checkpoint()), edited, new Map())).toEqual([ + { podId: POD, cp: 3, mtime: later }, + ]); +}); + +it('an edit already refreshed for (even one that failed) is not re-run', () => { + expect( + staleCards(storeWith(checkpoint()), edited, new Map([[`${POD}/3`, later]])) + ).toEqual([]); +}); + +it('files older than the last read, or a closed card, are left alone', () => { + expect( + staleCards( + storeWith(checkpoint()), + () => Date.parse(READ_AT) - 1, + new Map() + ) + ).toEqual([]); + expect( + staleCards(storeWith(checkpoint({ status: 'answered' })), edited, new Map()) + ).toEqual([]); +}); + +it('says why a refresh failed, and leaves that edit for the next one', async () => { + const logs: { lines: string[] } = { lines: [] }; + const tried = new Map<string, number>(); + const refreshing = new Set<string>(); + + await refreshStaleCards({ + store: storeWith(checkpoint()), + mtimeOf: edited, + tried, + refreshing, + refresh: () => Promise.reject(new Error('proposal.json is not JSON')), + log: (line) => { + logs.lines = [...logs.lines, line]; + }, + }); + + expect(logs.lines).toEqual([ + `Couldn't refresh checkpoint 3 of ${POD}: proposal.json is not JSON. It's tried again when its files change.`, + ]); + expect(tried.get(`${POD}/3`)).toBe(later); + expect(refreshing.size).toBe(0); +}); diff --git a/src/app/__tests__/loading.test.ts b/src/app/__tests__/loading.test.ts new file mode 100644 index 0000000..27673a2 --- /dev/null +++ b/src/app/__tests__/loading.test.ts @@ -0,0 +1,65 @@ +import { errorText, latestLoader } from '../client/loading'; + +const deferred = <T>() => { + const settle: { resolve: (value: T) => void } = { resolve: () => undefined }; + const promise = new Promise<T>((yes) => { + settle.resolve = yes; + }); + + return { promise, resolve: (value: T) => settle.resolve(value) }; +}; + +/** Every call to the page's setters, in order. */ +const recorder = () => { + const calls = jest.fn<void, [string, string | null]>(); + + return { + seen: () => calls.mock.calls, + target: { + value: (value: string) => calls('value', value), + error: (message: string | null) => calls('error', message), + }, + }; +}; + +describe('loading a page’s data', () => { + it('drops a slower answer to an older request', async () => { + const { seen, target } = recorder(); + const load = latestLoader(target); + const pawnee = deferred<string>(); + const sweetums = deferred<string>(); + + const first = load(pawnee.promise); + const second = load(sweetums.promise); + sweetums.resolve('Sweetums recipe app'); + await second; + pawnee.resolve('Pawnee Parks site'); + await first; + + expect(seen()).toEqual([ + ['error', null], + ['value', 'Sweetums recipe app'], + ]); + }); + + it('clears an earlier error once a request succeeds', async () => { + const { seen, target } = recorder(); + const load = latestLoader(target); + + await load(Promise.reject(new Error('Leslie’s Mac mini is offline.'))); + await load(Promise.resolve('Pawnee Parks site')); + + expect(seen()).toEqual([ + ['error', 'Leslie’s Mac mini is offline.'], + ['error', null], + ['value', 'Pawnee Parks site'], + ]); + }); + + it('shows an error’s message, not "Error: …"', () => { + expect(errorText(new Error('Leslie’s Mac mini is offline.'))).toBe( + 'Leslie’s Mac mini is offline.' + ); + expect(errorText('no such pod')).toBe('no such pod'); + }); +}); diff --git a/src/app/__tests__/newPod.test.ts b/src/app/__tests__/newPod.test.ts new file mode 100644 index 0000000..2b3df43 --- /dev/null +++ b/src/app/__tests__/newPod.test.ts @@ -0,0 +1,87 @@ +import { + aboutTicketText, + projectDone, + sendsNewPod, + ticketIdsIn, +} from '../client/NewPodForm'; + +describe('New pod — the one box reads ticket ids as the server does', () => { + it('in order, upper-cased, each once', () => { + expect(ticketIdsIn('pawnee-84, PAWNEE-83 and PAWNEE-84 again')).toEqual([ + 'PAWNEE-84', + 'PAWNEE-83', + ]); + expect(ticketIdsIn('rangers repaint the trail markers')).toEqual([]); + }); +}); + +describe('⏎ in the new-pod box', () => { + const press = (overrides: Partial<Parameters<typeof sendsNewPod>[0]>) => + sendsNewPod({ + key: 'Enter', + shiftKey: false, + composing: false, + inBox: true, + ...overrides, + }); + + it('starts the pod when it’s pressed in the box itself', () => { + expect(press({})).toBe(true); + }); + + it('leaves ⏎ in the Add a project / New project dialog to that dialog', () => { + // The dialog sits inside the new-pod section, so its keys bubble there: + // ⏎ in "Sweetums recipe app" must make the project, not a pod. + expect(press({ inBox: false })).toBe(false); + }); + + it('is a new line with ⇧, and nothing while an IME is composing', () => { + expect(press({ shiftKey: true })).toBe(false); + expect(press({ composing: true })).toBe(false); + expect(press({ key: 'a' })).toBe(false); + }); +}); + +describe('a project made from the new-pod box', () => { + it('opens the pod New project started with the idea, and empties the box', () => { + expect( + projectDone('new', 'A map of every dog park', { + projectId: 'prj_sweetums', + podId: 'nougat-tracker', + }) + ).toEqual({ + chosen: 'prj_sweetums', + clearBox: true, + open: 'nougat-tracker', + }); + }); + + it('only picks the project when nothing was started', () => { + expect( + projectDone('new', '', { projectId: 'prj_sweetums', podId: null }) + ).toEqual({ chosen: 'prj_sweetums', clearBox: false, open: null }); + expect( + projectDone('add', 'A map of every dog park', { projectId: 'prj_pawnee' }) + ).toEqual({ chosen: 'prj_pawnee', clearBox: false, open: null }); + }); +}); + +describe('a chat about a ticket', () => { + it("reads a sub-ticket's id, its letter kept small", () => { + expect(ticketIdsIn('Plan pawnee-2a and PAWNEE-12 Park map page')).toEqual([ + 'PAWNEE-2a', + 'PAWNEE-12', + ]); + }); + + it('starts with the ticket, then what was typed, or asks for a summary when nothing was', () => { + const about = { id: 'PAWNEE-2a', title: 'Park map page' }; + + expect(aboutTicketText(about, ' Should Ramsett show? ')).toBe( + 'About PAWNEE-2a Park map page: Should Ramsett show?' + ); + expect(aboutTicketText(about, '')).toBe( + "About PAWNEE-2a Park map page: sum up this ticket, then ask me what I'd like to work out or change." + ); + }); +}); diff --git a/src/app/__tests__/page.test.ts b/src/app/__tests__/page.test.ts new file mode 100644 index 0000000..0498e0b --- /dev/null +++ b/src/app/__tests__/page.test.ts @@ -0,0 +1,22 @@ +import { accountPageOf, pageOfRoute } from '../client/page'; + +describe('the one page on screen', () => { + it('is the page a URL names, or none for the inbox and a Lambda', () => { + expect(pageOfRoute({ page: 'new' })).toBe('new'); + expect(pageOfRoute({ page: 'time' })).toBe('time'); + expect(pageOfRoute({ page: 'tickets' })).toBe('tickets'); + expect(pageOfRoute({ page: 'account', which: 'settings/process' })).toBe( + 'settings/process' + ); + expect(pageOfRoute({ page: 'auto' })).toBeNull(); + expect(pageOfRoute({ page: 'lambda', lambda: 'dog-park-map' })).toBeNull(); + }); + + it('names an account page only for one of the account menu’s', () => { + expect(accountPageOf('computers')).toBe('computers'); + expect(accountPageOf('settings/devices')).toBe('settings/devices'); + expect(accountPageOf('time')).toBeNull(); + expect(accountPageOf('new')).toBeNull(); + expect(accountPageOf(null)).toBeNull(); + }); +}); diff --git a/src/app/__tests__/pieceQueue.test.ts b/src/app/__tests__/pieceQueue.test.ts new file mode 100644 index 0000000..0b2d8ad --- /dev/null +++ b/src/app/__tests__/pieceQueue.test.ts @@ -0,0 +1,167 @@ +import { + CancelledError, + type Priority, + comparePriority, + createPieceQueue, +} from '../server/tts/pieceQueue'; + +/** + * A renderer whose pieces finish only when the test says so, and whose + * priorities the test sets — like a click in the app would. + */ +const manual = (workers = 1) => { + const order: string[] = []; + const pending: { piece: string; finish: () => void; fail: () => void }[] = []; + const priorities = new Map<string, Priority>(); + const queue = createPieceQueue( + (piece: string) => + new Promise<string>((resolve, reject) => { + order.splice(order.length, 0, piece); + pending.splice(pending.length, 0, { + piece, + finish: () => resolve(piece.toUpperCase()), + fail: () => reject(new Error(`failed ${piece}`)), + }); + }), + { + workers, + priorityOf: (key) => priorities.get(key) ?? [9], + } + ); + const flush = (): Promise<void> => + new Promise((resolve) => setImmediate(resolve)); + /** Finish the piece rendering longest, then let the worker pick again. */ + const step = async (): Promise<void> => { + pending.shift()?.finish(); + await flush(); + }; + + return { queue, order, pending, priorities, step, flush }; +}; + +describe('comparePriority', () => { + it('compares tuples, lowest first', () => { + expect(comparePriority([0, -5], [1, 0])).toBeLessThan(0); + expect(comparePriority([2, 3], [2, 1])).toBeGreaterThan(0); + expect(comparePriority([3], [3])).toBe(0); + }); +}); + +describe('createPieceQueue — a live priority, decided before every piece', () => { + it('a click mid-clip takes effect at the next piece; the interrupted clip resumes', async () => { + const { queue, order, priorities, step, flush } = manual(); + priorities.set('other-pod', [2, 5]); + + const background = queue.add('other-pod', ['b1', 'b2', 'b3']); + await flush(); + // The owner jumps to a section: it becomes the most important clip. + priorities.set('jump', [0, -1]); + const jump = queue.add('jump', ['j1', 'j2']); + + await step(); // b1 was already rendering: it finishes + await step(); // j1 + await step(); // j2 + await expect(jump).resolves.toEqual(['J1', 'J2']); + + await step(); // b2 — the background clip picks up where it stopped + await step(); // b3 + await expect(background).resolves.toEqual(['B1', 'B2', 'B3']); + expect(order).toEqual(['b1', 'j1', 'j2', 'b2', 'b3']); + }); + + it('re-reads priorities each piece: changing them re-orders queued work', async () => { + const { queue, order, priorities, step, flush } = manual(); + priorities.set('a', [2, 1]); + priorities.set('b', [2, 2]); + + queue.add('a', ['a1', 'a2']).catch(() => undefined); + queue.add('b', ['b1']).catch(() => undefined); + await flush(); + // They open b's item: b now outranks a. + priorities.set('b', [2, 0]); + queue.poke(); + + await step(); + await step(); + await step(); + expect(order).toEqual(['a1', 'b1', 'a2']); + }); + + it('ties go to the clip queued first', async () => { + const { queue, order, step, flush } = manual(); + + queue.add('first', ['f1']).catch(() => undefined); + queue.add('second', ['s1']).catch(() => undefined); + await flush(); + await step(); + await step(); + expect(order).toEqual(['f1', 's1']); + }); + + it('cancel drops a clip nobody needs, even part-rendered', async () => { + const { queue, order, step, flush } = manual(); + + const gone = queue.add('gone', ['g1', 'g2']); + const kept = queue.add('kept', ['k1']); + await flush(); + queue.cancel('gone'); + await expect(gone).rejects.toBeInstanceOf(CancelledError); + await step(); // g1 was already rendering; its result is thrown away + await step(); + await expect(kept).resolves.toEqual(['K1']); + expect(order).toEqual(['g1', 'k1']); + }); + + it('two workers render two pieces at once, and keep each clip in order', async () => { + const { queue, pending, flush } = manual(2); + + const clip = queue.add('clip', ['p1', 'p2', 'p3']); + await flush(); + expect(pending.map((entry) => entry.piece)).toEqual(['p1', 'p2']); + + // p2 finishes before p1: the clip still comes back in order. + pending[1]?.finish(); + await flush(); + pending[0]?.finish(); + await flush(); + pending[2]?.finish(); + await expect(clip).resolves.toEqual(['P1', 'P2', 'P3']); + }); + + it('a failed piece rejects only its own clip', async () => { + const { queue, pending, step, flush } = manual(); + + const broken = queue.add('broken', ['x1', 'x2']); + const fine = queue.add('fine', ['ok']); + await flush(); + pending.shift()?.fail(); + await expect(broken).rejects.toThrow('failed x1'); + await flush(); + await step(); + await expect(fine).resolves.toEqual(['OK']); + }); + + it('reports what it is on and how much waits', async () => { + const { queue, flush } = manual(); + + queue.add('a', ['a1']).catch(() => undefined); + queue.add('b', ['b1']).catch(() => undefined); + await flush(); + expect(queue.status()).toEqual({ current: 'a', waiting: 1 }); + }); + + it("tells the renderer which clip each piece is for (Kokoro's CPU tier)", async () => { + const seen: string[] = []; + const queue = createPieceQueue( + async (piece: string, key: string) => { + seen.splice(seen.length, 0, `${key}:${piece}`); + + return piece; + }, + { priorityOf: () => [0] } + ); + + await queue.add('waiting-on', ['w1', 'w2']); + expect(seen).toEqual(['waiting-on:w1', 'waiting-on:w2']); + }); +}); diff --git a/src/app/__tests__/podFile.test.ts b/src/app/__tests__/podFile.test.ts new file mode 100644 index 0000000..c36cd95 --- /dev/null +++ b/src/app/__tests__/podFile.test.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { podFilePath } from '../server/podFile'; + +/** + * The UX review's pictures are served from the pod's own `designs/` folder + * and nowhere else: questions, thread and intent stay unreachable. + */ + +const POD = 'pawnee-a1b2'; + +const roots: string[] = []; + +afterEach(() => { + roots + .splice(0) + .forEach((made) => fs.rmSync(made, { recursive: true, force: true })); +}); + +const seed = (): string => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'pods-file-')) + ); + const designs = path.join(root, POD, 'designs', 'trail-map'); + fs.mkdirSync(designs, { recursive: true }); + fs.writeFileSync(path.join(designs, 'after.png'), 'png'); + fs.writeFileSync(path.join(designs, 'a.svg'), '<svg/>'); + fs.writeFileSync(path.join(root, POD, 'intent.md'), 'intent'); + fs.writeFileSync(path.join(root, POD, 'pod.json'), '{}'); + fs.writeFileSync(path.join(root, 'outside.png'), 'png'); + fs.symlinkSync( + path.join(root, 'outside.png'), + path.join(designs, 'escape.png') + ); + roots.splice(roots.length, 0, root); + + return root; +}; + +describe('podFilePath', () => { + it('resolves a PNG inside the pod designs folder', () => { + const root = seed(); + + expect(podFilePath(root, POD, 'designs/trail-map/after.png')).toBe( + path.join(root, POD, 'designs', 'trail-map', 'after.png') + ); + }); + + it.each([ + ['../x', POD], + ['designs/%2e%2e/pod.json', POD], + ['designs/../pod.json', POD], + ['designs/trail-map/a.svg', POD], + ['intent.md', POD], + ['designs/trail-map/escape.png', POD], + ['designs/trail-map/after.png', 'Pawnee!'], + ['designs/trail-map/missing.png', POD], + ])('refuses %s (pod %s)', (rest, podId) => { + expect(podFilePath(seed(), podId, rest)).toBeNull(); + }); + + it("serves a PNG from the pod's shots folder too, nothing else beside it", () => { + const root = seed(); + const shots = path.join(root, POD, 'shots'); + fs.mkdirSync(shots, { recursive: true }); + fs.writeFileSync(path.join(shots, 'home-desktop.png'), 'png'); + + expect(podFilePath(root, POD, 'shots/home-desktop.png')).toBe( + path.join(root, POD, 'shots', 'home-desktop.png') + ); + expect(podFilePath(root, POD, 'turns/dev.png')).toBeNull(); + }); + + it('serves a manifest.json only from a screenshots folder', () => { + const root = seed(); + const attempt = path.join(root, POD, 'screenshots', '3', '1'); + fs.mkdirSync(attempt, { recursive: true }); + fs.writeFileSync(path.join(attempt, 'manifest.json'), '[]'); + fs.writeFileSync(path.join(root, POD, 'designs', 'manifest.json'), '[]'); + + expect(podFilePath(root, POD, 'screenshots/3/1/manifest.json')).toBe( + path.join(attempt, 'manifest.json') + ); + expect(podFilePath(root, POD, 'designs/manifest.json')).toBeNull(); + }); + + it('serves a folder or file named in capitals (designs/home/C/busy.png)', () => { + const root = seed(); + const folder = path.join(root, POD, 'designs', 'home', 'C'); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync(path.join(folder, 'Busy.png'), 'png'); + + expect(podFilePath(root, POD, 'designs/home/C/Busy.png')).toBe( + path.join(folder, 'Busy.png') + ); + }); + + it.each(['screenshots', 'shots'])( + 'refuses a %s folder that is a symlink out of the pod', + (folder) => { + const root = seed(); + const elsewhere = path.join(root, 'elsewhere'); + fs.mkdirSync(path.join(elsewhere, '3', '1'), { recursive: true }); + fs.writeFileSync(path.join(elsewhere, '3', '1', 'home.png'), 'png'); + fs.symlinkSync(elsewhere, path.join(root, POD, folder)); + + expect(podFilePath(root, POD, `${folder}/3/1/home.png`)).toBeNull(); + } + ); +}); diff --git a/src/app/__tests__/processControls.test.ts b/src/app/__tests__/processControls.test.ts new file mode 100644 index 0000000..3e5bb01 --- /dev/null +++ b/src/app/__tests__/processControls.test.ts @@ -0,0 +1,107 @@ +import { processControls, rereadsOnTick } from '../client/processControls'; + +const deferred = <T>() => { + const settle: { resolve: (value: T) => void } = { resolve: () => undefined }; + const promise = new Promise<T>((yes) => { + settle.resolve = yes; + }); + + return { promise, resolve: (value: T) => settle.resolve(value) }; +}; + +const recorder = () => { + const calls = jest.fn<void, [string, string | null]>(); + + return { + seen: () => calls.mock.calls, + last: () => calls.mock.calls.filter(([kind]) => kind === 'value').at(-1), + target: { + value: (value: string) => calls('value', value), + error: (message: string | null) => calls('error', message), + }, + }; +}; + +describe('Settings → Process', () => { + it('keeps a switch where it was flipped when an older read answers late', async () => { + const { last, target } = recorder(); + const read = deferred<string>(); + const controls = processControls( + { + read: () => read.promise, + flip: () => Promise.resolve('Research on'), + }, + target + ); + + const reading = controls.read('prj_pawnee'); + await controls.flip('prj_pawnee', 'planning', 'research', true); + read.resolve('Research off'); + await reading; + + expect(last()).toEqual(['value', 'Research on']); + }); + + it('clears an earlier error once a flip lands', async () => { + const { seen, target } = recorder(); + const controls = processControls( + { + read: () => Promise.reject(new Error('Leslie’s Mac mini is offline.')), + flip: () => Promise.resolve('Research on'), + }, + target + ); + + await controls.read('prj_pawnee'); + await controls.flip('prj_pawnee', 'planning', 'research', true); + + expect(seen()).toEqual([ + ['error', 'Leslie’s Mac mini is offline.'], + ['error', null], + ['value', 'Research on'], + ]); + }); + + it('holds the switches while a flip is on its way, and lets go however it ends', async () => { + const { target } = recorder(); + const busy = jest.fn<void, [boolean]>(); + const flip = deferred<string>(); + const controls = processControls( + { + read: () => Promise.resolve('Research off'), + flip: () => flip.promise, + }, + { ...target, busy } + ); + + const flipping = controls.flip('prj_pawnee', 'planning', 'research', true); + expect(busy.mock.calls).toEqual([[true]]); + + flip.resolve('Research on'); + await flipping; + expect(busy.mock.calls).toEqual([[true], [false]]); + + const refused = processControls( + { + read: () => Promise.resolve('Research off'), + flip: () => Promise.reject(new Error('Leslie’s Mac mini is offline.')), + }, + { ...target, busy } + ); + await refused.flip('prj_pawnee', 'planning', 'research', true); + expect(busy.mock.calls.at(-1)).toEqual([false]); + }); + + it('reads again on the next heartbeat after an error, unless a flip is on its way', () => { + // Stuck on "Leslie’s Mac mini is offline." until the runner is back. + expect( + rereadsOnTick({ error: 'Leslie’s Mac mini is offline.', busy: false }) + ).toBe(true); + // Each read is a trip to the computer: not while all is well... + expect(rereadsOnTick({ error: null, busy: false })).toBe(false); + // ...nor over a flip, whose answer the newer read would drop. + expect( + rereadsOnTick({ error: 'Leslie’s Mac mini is offline.', busy: true }) + ).toBe(false); + }); +}); diff --git a/src/app/__tests__/projects.test.ts b/src/app/__tests__/projects.test.ts new file mode 100644 index 0000000..05ad1e8 --- /dev/null +++ b/src/app/__tests__/projects.test.ts @@ -0,0 +1,119 @@ +import { + chosenProjectId, + defaultProjectId, + groupByProject, + showsProjects, + ticketShortcut, + withProjectTag, +} from '../client/projects'; +import type { LambdaInfo } from '../shared/types'; + +const pawnee = { + id: 'prj_pawnee', + name: 'Pawnee Parks site', + ticketPrefix: 'PAWNEE', +}; +const sweetums = { + id: 'prj_sweetums', + name: 'Sweetums recipe app', + ticketPrefix: null, +}; + +const lambda = (projects: LambdaInfo['projects']): LambdaInfo => ({ + owner: { name: 'Leslie Knope', initials: 'LK' }, + projects, + lastProjectId: projects.at(-1)?.id ?? null, + podProjects: { + 'dog-park': 'prj_pawnee', + benches: 'prj_pawnee', + nougat: 'prj_sweetums', + }, + waiting: [], +}); + +const pods = ['dog-park', 'benches', 'nougat'].map((id) => ({ pod: { id } })); + +describe('projects in the app', () => { + it('looks as it always has with one project: no headings, no tags', () => { + const one = lambda([pawnee]); + + expect(showsProjects(one)).toBe(false); + expect(groupByProject(pods, one)).toEqual([ + { project: null, other: false, pods, waiting: [] }, + ]); + expect(withProjectTag('Dog park map', 'dog-park', one)).toBe( + 'Dog park map' + ); + }); + + it('groups under project headings and tags items with two or more', () => { + const two = lambda([pawnee, sweetums]); + const groups = groupByProject(pods, two); + + expect( + groups.map((group) => [ + group.project?.name, + group.pods.map((summary) => summary.pod.id), + ]) + ).toEqual([ + ['Pawnee Parks site', ['dog-park', 'benches']], + ['Sweetums recipe app', ['nougat']], + ]); + expect(withProjectTag('Dog park map', 'dog-park', two)).toBe( + 'Dog park map · Pawnee Parks site' + ); + }); + + it('keeps pods with no project in a trailing group with two or more', () => { + const two = lambda([pawnee, sweetums]); + const withStray = [...pods, { pod: { id: 'unfiled' } }]; + const groups = groupByProject(withStray, two); + + expect( + groups.map((group) => [ + group.project?.name ?? null, + group.other, + group.pods.map((summary) => summary.pod.id), + ]) + ).toEqual([ + ['Pawnee Parks site', false, ['dog-park', 'benches']], + ['Sweetums recipe app', false, ['nougat']], + [null, true, ['unfiled']], + ]); + }); + + it('still has one empty group to say "Nothing matches." with two or more', () => { + const groups = groupByProject([], lambda([pawnee, sweetums])); + + expect(groups).toEqual([ + { project: null, other: false, pods: [], waiting: [] }, + ]); + }); + + it('sends a new idea to the last project used', () => { + expect(defaultProjectId(lambda([pawnee, sweetums]))).toBe('prj_sweetums'); + expect(defaultProjectId(undefined)).toBeNull(); + }); + + it('uses the project’s own ticket prefix, and hides the shortcut without one', () => { + expect(ticketShortcut('PAWNEE')).toEqual({ + label: 'Plan a ticket or epic — PAWNEE-…', + fill: 'Plan PAWNEE-', + }); + expect(ticketShortcut(null)).toBeNull(); + }); + + it('follows the projects as they come, until one is picked', () => { + // A page opened before any project exists says "Add a codebase first."; + // once one is added it shows that project without being reopened. + expect(chosenProjectId(null, lambda([]))).toBeNull(); + expect(chosenProjectId(null, lambda([pawnee]))).toBe('prj_pawnee'); + expect(chosenProjectId('prj_pawnee', lambda([pawnee, sweetums]))).toBe( + 'prj_pawnee' + ); + // A picked project that's gone falls back to the default. + expect(chosenProjectId('prj_gone', lambda([pawnee, sweetums]))).toBe( + 'prj_sweetums' + ); + }); +}); diff --git a/src/app/__tests__/protoBase.test.ts b/src/app/__tests__/protoBase.test.ts new file mode 100644 index 0000000..a7a251f --- /dev/null +++ b/src/app/__tests__/protoBase.test.ts @@ -0,0 +1,36 @@ +import { protoToken } from '../../mothership/protoToken'; +import { PROTO_BASE, prototypeHeaders } from '../server/prototypes'; + +const connectSrc = (headers: Record<string, string>): string => + (headers['content-security-policy'] ?? '') + .split('; ') + .find((directive) => directive.startsWith('connect-src')) ?? ''; + +describe("a prototype's own folder, with its link token", () => { + const token = protoToken( + 'secret', + 'dog-park', + 'desk', + 'v1', + Date.UTC(2026, 9, 21) + ); + const base = `/proto/${token}/dog-park/desk/v1/`; + + it('lets the prototype read its tokened folder back', () => { + expect(PROTO_BASE.test(base)).toBe(true); + expect( + connectSrc(prototypeHeaders('/x/Desk.dc.html', 'localhost:4580', base)) + ).toBe( + `connect-src http://localhost:4580${base} https://localhost:4580${base}` + ); + }); + + it('still takes a folder without a token, and nothing else', () => { + expect(PROTO_BASE.test('/proto/dog-park/desk/v1/')).toBe(true); + expect(PROTO_BASE.test('/proto/dog-park/desk/v1/x/')).toBe(false); + expect(PROTO_BASE.test('/api/pod/dog-park/')).toBe(false); + expect( + connectSrc(prototypeHeaders('/x/Desk.dc.html', 'localhost:4580', '/api/')) + ).toBe("connect-src 'none'"); + }); +}); diff --git a/src/app/__tests__/prototypeRoutes.test.ts b/src/app/__tests__/prototypeRoutes.test.ts new file mode 100644 index 0000000..c6a26cd --- /dev/null +++ b/src/app/__tests__/prototypeRoutes.test.ts @@ -0,0 +1,331 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { required } from '../../__tests__/required'; +import { + type FakeRunner, + type Harness, + connectRunner, + holdProject, + podSnapshot, + startHarness, +} from '../../mothership/__tests__/harness'; +import { storeSnapshots } from '../../mothership/pods'; +import { + PROTO_TOKEN_LIFETIME_MS, + protoToken, + sessionSecret, +} from '../../mothership/protoToken'; +import { podFileRequestOf, servePodFile } from '../../runner/podFiles'; +import { BRIDGE_SCRIPT, madeHere } from '../server/prototypes'; + +/** + * The prototype routes end to end (the unit half is prototypes.test.ts): + * `/api/made/<pod>` and `/proto/<token>/<pod>/<slug>/v<n>/<file>` through + * the mothership's sign-in, forwarded to the runner that has the pod, which + * serves only a version's own `files/`. + */ + +// Spy-able: the mothership's file reads are counted. +jest.mock('fs', () => ({ ...jest.requireActual('fs') })); + +const POD = 'pawnee-map-a1b2'; +const FOLDER = '/home/leslie/pawnee-parks'; + +const manifest = (version: number) => ({ + slug: 'map', + version, + title: 'Trail map', + createdAt: `2026-10-20T1${version}:00:00Z`, + hash: `h${version}`, + situations: [{ slug: 'trail-closed', title: 'Trail closed' }], + boards: [ + { + file: 'Prototype.dc.html', + letter: 'A', + title: 'Map', + w: 1440, + h: 900, + stills: { + 'trail-closed': `designs/map/prototype/v${version}/stills/A/trail-closed.png`, + }, + }, + ], +}); + +/** The runner's pods root: two versions of the trail map's prototype. */ +const seedPodsRoot = (): string => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-proto-routes-')) + ); + const write = (rel: string, body: string): void => { + const file = path.join(root, POD, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, body); + }; + [1, 2].forEach((version) => { + const dir = `designs/map/prototype/v${version}`; + write( + `${dir}/files/Prototype.dc.html`, + '<html><body><x-dc>Ann Perkins · Trail map</x-dc></body></html>' + ); + write(`${dir}/files/support.js`, '// runtime'); + write(`${dir}/stills/A/trail-closed.png`, 'png'); + write(`${dir}/prototype.json`, JSON.stringify(manifest(version))); + }); + write('designs/map/prototype/v1/files/notes.xyz', 'not served'); + write('thread.jsonl', '{}'); + fs.writeFileSync(path.join(root, 'outside.js'), '// outside'); + fs.symlinkSync( + path.join(root, 'outside.js'), + path.join(root, POD, 'designs/map/prototype/v1/files/escape.js') + ); + + return root; +}; + +describe('the prototype routes', () => { + const fixture: { harness?: Harness; runner?: FakeRunner; root?: string } = {}; + const harness = (): Harness => required(fixture.harness, 'harness'); + const runner = (): FakeRunner => required(fixture.runner, 'runner'); + const root = (): string => required(fixture.root, 'root'); + const asked: { files: string[] } = { files: [] }; + + beforeEach(async () => { + asked.files = []; + fixture.root = seedPodsRoot(); + fixture.harness = await startHarness(); + fixture.runner = await connectRunner(harness()); + await runner().next('owner'); + holdProject(harness(), runner().runnerId, { folder: FOLDER }); + storeSnapshots( + harness().db, + runner().runnerId, + FOLDER, + [podSnapshot(POD)], + [], + harness().clock.now + ); + // The runner's real answers, from its own pods root. + runner().on('pod-action', (message) => + message.action === 'made' ? madeHere(root(), POD) : null + ); + runner().on('pod-file', (message) => { + asked.files = [ + ...asked.files, + `${String(message.folder)}:${String(message.path)}`, + ]; + + return servePodFile(root(), podFileRequestOf(message)); + }); + }); + + afterEach(async () => { + await runner().close(); + await harness().close(); + fs.rmSync(root(), { recursive: true, force: true }); + }); + + const token = (version: string) => + protoToken( + sessionSecret(harness().db), + POD, + 'map', + version, + harness().clock.now + ); + const proto = (version: string, file: string, tokenFor = version) => + `/proto/${token(tokenFor)}/${POD}/map/${version}/${file}`; + + it('serves Prototype.dc.html with the bridge and the CSP', async () => { + const response = await harness().request( + 'GET', + proto('v1', 'Prototype.dc.html') + ); + const csp = String(response.headers['content-security-policy']); + + expect(response.status).toBe(200); + expect(response.text).toContain('Ann Perkins · Trail map'); + expect(response.text).toContain(`<script>${BRIDGE_SCRIPT}</script>`); + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain(`/proto/${token('v1')}/${POD}/map/v1/`); + expect(csp).toContain("form-action 'none'"); + expect(csp).toContain('sandbox allow-scripts'); + expect(csp).toContain('frame-ancestors'); + expect(asked.files).toEqual(['prototype:map/v1/Prototype.dc.html']); + }); + + it('serves the runtime support.js beside the board', async () => { + const response = await harness().request('GET', proto('v1', 'support.js')); + + expect(response.status).toBe(200); + expect(response.text).toBe('// runtime'); + }); + + it.each([ + ['v0', 'Prototype.dc.html'], + ['v1', '..%2F..%2F..%2Fthread.jsonl'], + ['v1', 'notes.xyz'], + ['v1', 'escape.js'], + ])('answers 404 for %s/%s', async (version, file) => { + const response = await harness().request('GET', proto(version, file)); + + expect(response.status).toBe(404); + }); + + it('never serves a path that climbs out of the version folder', async () => { + // The URL is normalised first: what's left has no version, so no link. + const response = await harness().request( + 'GET', + proto('v1', '%2e%2e/prototype.json') + ); + + expect(response.status).toBe(401); + expect(asked.files).toEqual([]); + }); + + // Signed in, "Sign in first." would mislead: the path is what's wrong. + it('answers 404 to a signed-in session for a path that is not a prototype file', async () => { + const cookie = harness().signIn(); + const statuses = await Promise.all( + [ + `/proto/${POD}/map/prototype.json`, + proto('v1', '%2e%2e/prototype.json'), + ].map( + async (urlPath) => + ( + await harness().request('GET', urlPath, { cookie }) + ).status + ) + ); + + expect(statuses).toEqual([404, 404]); + expect(asked.files).toEqual([]); + }); + + it('answers 401 with no session and no token, and with a token for another version', async () => { + const statuses = await Promise.all( + [ + `/proto/${POD}/map/v1/Prototype.dc.html`, + `/proto/-/${POD}/map/v1/Prototype.dc.html`, + proto('v2', 'Prototype.dc.html', 'v1'), + `/api/made/${POD}`, + ].map(async (urlPath) => (await harness().request('GET', urlPath)).status) + ); + + expect(statuses).toEqual([401, 401, 401, 401]); + expect(asked.files).toEqual([]); + }); + + it('answers only GET and HEAD, even with a good token', async () => { + const statuses = await Promise.all( + ['PUT', 'DELETE', 'POST', 'HEAD'].map( + async (method) => + ( + await harness().request(method, proto('v1', 'support.js')) + ).status + ) + ); + + expect(statuses).toEqual([405, 405, 405, 200]); + }); + + it('opens any version for a signed-in session, with or without a token', async () => { + const cookie = harness().signIn(); + const responses = await Promise.all( + [ + `/proto/${POD}/map/v1/Prototype.dc.html`, + `/proto/${POD}/map/v2/Prototype.dc.html`, + proto('v2', 'Prototype.dc.html', 'v1'), + ].map((urlPath) => harness().request('GET', urlPath, { cookie })) + ); + + expect(responses.map((response) => response.status)).toEqual([ + 200, 200, 200, + ]); + // A tokenless page may fetch only its own version's folder. + expect(String(responses[0]?.headers['content-security-policy'])).toContain( + `/proto/${POD}/map/v1/` + ); + }); + + it('opens with a token only the version it was made for', async () => { + const statuses = await Promise.all( + [ + proto('v1', 'Prototype.dc.html'), + proto('v2', 'Prototype.dc.html'), + proto('v2', 'Prototype.dc.html', 'v1'), + proto('v1', 'Prototype.dc.html', 'v2'), + ].map(async (urlPath) => (await harness().request('GET', urlPath)).status) + ); + + expect(statuses).toEqual([200, 200, 401, 401]); + }); + + it('answers 401 to a wrong or expired token with no session', async () => { + const good = token('v1'); + const expired = protoToken( + sessionSecret(harness().db), + POD, + 'map', + 'v1', + harness().clock.now - 2 * PROTO_TOKEN_LIFETIME_MS + ); + const statuses = await Promise.all( + [ + `/proto/zz.${good.split('.')[1]}/${POD}/map/v1/Prototype.dc.html`, + `/proto/${good.split('.')[0]}.forged/${POD}/map/v1/Prototype.dc.html`, + `/proto/${expired}/${POD}/map/v1/Prototype.dc.html`, + ].map(async (urlPath) => (await harness().request('GET', urlPath)).status) + ); + + expect(statuses).toEqual([401, 401, 401]); + expect(asked.files).toEqual([]); + }); + + it("lists the pod's versions on /api/made with a token per version, and the mothership reads no file", async () => { + const cookie = harness().signIn(); + const realFs = jest.requireMock<typeof fs>('fs'); + const reads = [ + jest.spyOn(realFs, 'readFileSync'), + jest.spyOn(realFs, 'createReadStream'), + jest.spyOn(realFs.promises, 'readFile'), + ]; + // Answered by a runner that sends canned bytes, so any read is the + // mothership's own. + runner().on('pod-file', () => ({ + base64: Buffer.from('<html><body></body></html>').toString('base64'), + headers: { 'content-type': 'text/html; charset=utf-8' }, + })); + runner().on('pod-action', () => ({ + prototypes: [ + { + slug: 'map', + versions: [manifest(2), manifest(1)], + }, + ], + pictures: [], + })); + + try { + const made = await harness().request('GET', `/api/made/${POD}`, { + cookie, + }); + const { bases } = made.json<{ bases: Record<string, string> }>(); + + expect(made.status).toBe(200); + expect(Object.keys(bases).sort()).toEqual(['map/v1', 'map/v2']); + + const file = await harness().request( + 'GET', + `${required(bases['map/v2'], 'base')}Prototype.dc.html` + ); + + expect(file.status).toBe(200); + reads.forEach((read) => expect(read).toHaveBeenCalledTimes(0)); + } finally { + reads.forEach((read) => read.mockRestore()); + } + }); +}); diff --git a/src/app/__tests__/prototypes.test.ts b/src/app/__tests__/prototypes.test.ts new file mode 100644 index 0000000..02aca85 --- /dev/null +++ b/src/app/__tests__/prototypes.test.ts @@ -0,0 +1,241 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + madeHere, + prototypeFilePath, + prototypeHeaders, + withBridge, +} from '../server/prototypes'; +import { + messageShows, + pictureOnPrototype, + podFileOf, + splitPrototypeLines, +} from '../shared/prototypes'; +import { speakable } from '../shared/speech'; + +/** + * Lambda's prototypes in the inbox (2026-10-20): the lines that put a + * card in the chat, the sandboxed files the focus view frames, and the Made + * here listing. + */ + +const POD = 'pawnee-home-a1b2'; + +const manifest = (version: number, createdAt: string) => ({ + slug: 'home', + version, + title: 'Home page directions', + createdAt, + hash: `h${version}`, + situations: [{ slug: 'busy', title: 'Busy afternoon' }], + boards: [ + { + file: 'Desk.dc.html', + letter: 'C', + title: 'Your desk', + w: 1440, + h: 1240, + stills: { busy: `designs/home/prototype/v${version}/stills/C/busy.png` }, + }, + ], +}); + +const seed = (): string => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'pods-proto-')) + ); + const write = (rel: string, body: string): void => { + const file = path.join(root, POD, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, body); + }; + [1, 2].forEach((version) => { + const dir = `designs/home/prototype/v${version}`; + write( + `${dir}/files/Desk.dc.html`, + '<html><body><x-dc></x-dc></body></html>' + ); + write(`${dir}/files/support.js`, '//'); + write(`${dir}/stills/C/busy.png`, 'png'); + write( + `${dir}/prototype.json`, + JSON.stringify(manifest(version, `2026-10-20T1${version}:00:00Z`)) + ); + }); + write('designs/home/prototype/v2/files/notes.md', 'not served'); + write('designs/home/C/busy.png', 'png'); + write('designs/home/walk/round-1/busy.png', 'draft'); + write('shots/home-desktop.png', 'png'); + write('thread.jsonl', '{}'); + fs.writeFileSync(path.join(root, 'outside.js'), '//'); + fs.symlinkSync( + path.join(root, 'outside.js'), + path.join(root, POD, 'designs/home/prototype/v1/files/escape.js') + ); + + return root; +}; + +describe('prototypeFilePath', () => { + it("serves a version's files", () => { + const root = seed(); + + expect(prototypeFilePath(root, `${POD}/home/v2/Desk.dc.html`)).toBe( + path.join(root, POD, 'designs/home/prototype/v2/files/Desk.dc.html') + ); + }); + + it.each([ + `${POD}/home/v2/notes.md`, + `${POD}/home/v1/escape.js`, + `${POD}/home/v2/../../../../thread.jsonl`, + `${POD}/home/v2/%2e%2e/prototype.json`, + `${POD}/home/v0/Desk.dc.html`, + `${POD}/home/v3/Desk.dc.html`, + `${POD}/Home/v2/Desk.dc.html`, + `Pawnee!/home/v2/Desk.dc.html`, + `${POD}/home/v2`, + ])('refuses %s', (rest) => { + expect(prototypeFilePath(seed(), rest)).toBeNull(); + }); + + // e.g. `files -> ~/.lambda`, which would serve the machine config. + it.each([ + ['files', 'designs/home/prototype/v3/files', 'secret'], + ['version', 'designs/home/prototype/v3', 'secret-v3'], + ])('refuses a %s folder that is a symlink', (_, link, target) => { + const root = seed(); + const secret = path.join(root, 'secret-v3/files/config.json'); + fs.mkdirSync(path.dirname(secret), { recursive: true }); + fs.writeFileSync(secret, '{}'); + fs.mkdirSync(path.dirname(path.join(root, POD, link)), { recursive: true }); + fs.symlinkSync( + path.join(root, target === 'secret' ? 'secret-v3/files' : target), + path.join(root, POD, link) + ); + + expect(prototypeFilePath(root, `${POD}/home/v3/config.json`)).toBeNull(); + }); +}); + +describe('prototypeHeaders', () => { + it('forbids fetching and forms, and names the inbox as it was reached', () => { + const csp = + prototypeHeaders( + 'Desk.dc.html', + 'lambda.tail1234.ts.net', + `/proto/${POD}/home/v2/` + )['content-security-policy'] ?? ''; + + // Its own folder only: the runtime reads its artboard back, nothing else. + expect(csp).toContain( + `connect-src http://lambda.tail1234.ts.net/proto/${POD}/home/v2/ https://lambda.tail1234.ts.net/proto/${POD}/home/v2/;` + ); + expect(csp).toContain("form-action 'none'"); + // Its own tab is sandboxed too, never the inbox's origin. + expect(csp).toContain('sandbox allow-scripts'); + expect(csp).toContain( + "frame-ancestors 'self' http://lambda.tail1234.ts.net https://lambda.tail1234.ts.net" + ); + }); + + it('ignores a host header it cannot trust', () => { + expect( + prototypeHeaders( + 'a.js', + 'evil.com; script-src *', + `/proto/${POD}/home/v2/` + )['content-security-policy'] + ).not.toContain('evil.com'); + }); +}); + +describe('withBridge', () => { + it("adds the bridge at the end of the artboard's body", () => { + const html = withBridge('<html><body><x-dc></x-dc></body></html>'); + + expect(html).toMatch(/<\/x-dc><script>\(function \(\) \{/); + expect(html.endsWith('</script></body></html>')).toBe(true); + }); +}); + +describe('madeHere', () => { + it('lists every version, newest first, and the pictures without drafts or stills', () => { + const made = madeHere(seed(), POD); + + expect(made?.prototypes.map((group) => group.slug)).toEqual(['home']); + expect(made?.prototypes[0]?.versions.map((v) => v.version)).toEqual([2, 1]); + expect(made?.pictures.map((picture) => picture.path).sort()).toEqual([ + 'designs/home/C/busy.png', + 'shots/home-desktop.png', + ]); + }); + + it('refuses a pod id it would never name', () => { + expect(madeHere(seed(), '../etc')).toBeNull(); + }); +}); + +describe('the chat lines', () => { + it("reads a Lambda message's prototype cards", () => { + expect( + splitPrototypeLines( + 'Three directions.\n[Prototype: home v3]\nTry C first.' + ) + ).toEqual({ + text: 'Three directions.\nTry C first.', + prototypes: [{ slug: 'home', version: 3 }], + }); + }); + + it('is never read aloud', () => { + expect(speakable('Here they are.\n[Prototype: home v3]\nTry C.')).toBe( + 'Here they are.\nTry C.' + ); + }); + + it('knows which message sent a version or a picture', () => { + expect( + messageShows('x\n[Prototype: home v3]', { + prototype: { slug: 'home', version: 3 }, + }) + ).toBe(true); + expect( + messageShows('x\n[Prototype: home v3]', { + prototype: { slug: 'home', version: 2 }, + }) + ).toBe(false); + expect( + messageShows(`![C](/home/leslie/.pods/${POD}/designs/home/C/busy.png)`, { + picture: 'designs/home/C/busy.png', + }) + ).toBe(true); + }); +}); + +describe('pictures', () => { + it('finds the board and situation a picture shows', () => { + expect(pictureOnPrototype('designs/home/C/busy.png')).toEqual({ + slug: 'home', + letter: 'C', + situation: 'busy', + }); + expect(pictureOnPrototype('designs/map/offline.png')).toEqual({ + slug: 'map', + letter: null, + situation: 'offline', + }); + expect(pictureOnPrototype('shots/home.png')).toBeNull(); + }); + + it("reads a pod's picture back from where the inbox serves it", () => { + expect(podFileOf(`/api/pod-file/${POD}/designs/home/C/busy.png`)).toEqual({ + podId: POD, + path: 'designs/home/C/busy.png', + }); + expect(podFileOf('/api/uploads/img_1_a.png')).toBeNull(); + }); +}); diff --git a/src/app/__tests__/renewalBanner.test.ts b/src/app/__tests__/renewalBanner.test.ts new file mode 100644 index 0000000..695b777 --- /dev/null +++ b/src/app/__tests__/renewalBanner.test.ts @@ -0,0 +1,98 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import type { SetupState } from '../../setup/routes'; +import { HOUR_MS, RenewalBanner, everyHour } from '../client/RenewalBanner'; +import { SignedIn } from '../client/Root'; + +/** The sign-in made 2026-10-19 runs out on Tue, Oct 19 2027. */ +const EXPIRES = '2027-10-19T12:00:00.000Z'; +// Read on a pinned clock, so the suite passes in any zone. +const PAWNEE = 'America/Indiana/Indianapolis'; + +const state = (done: boolean): SetupState => ({ + steps: { + claude: { done: true, run: { phase: 'idle' } }, + project: { done }, + tickets: { done }, + browser: { phase: 'not-app' }, + }, + claudeExpiresOn: EXPIRES, + ticketSource: 'markdown', +}); + +const text = (html: string): string => html.replace(/<[^>]+>/g, ' '); + +describe('the Claude renewal banner', () => { + it('shows above setup too, not only above the app', () => { + const onSetup = renderToStaticMarkup( + createElement(SignedIn, { + state: state(false), + setupPage: true, + day: '2027-10-09', + zone: PAWNEE, + }) + ); + + expect(text(onSetup)).toContain( + 'Your Claude sign-in runs out on Tue, Oct 19. Agents stop working after that.' + ); + }); + + it('reads only that it ran out, once it has', () => { + const html = renderToStaticMarkup( + createElement(RenewalBanner, { + expiresOn: EXPIRES, + day: '2027-10-21', + zone: PAWNEE, + }) + ); + + expect(text(html)).toContain('Your Claude sign-in ran out on Tue, Oct 19.'); + expect(html).not.toContain('Agents stop working after that.'); + }); + + it("names the day on the reader's clock, far east of UTC too", () => { + const html = renderToStaticMarkup( + createElement(RenewalBanner, { + expiresOn: EXPIRES, + day: '2027-10-09', + zone: 'Pacific/Auckland', + }) + ); + + // Noon UTC on Oct 19 is 1 am on Wed Oct 20 in Auckland. + expect(text(html)).toContain( + 'Your Claude sign-in runs out on Wed, Oct 20.' + ); + }); + + it('is not there outside the 21 days', () => { + expect( + renderToStaticMarkup( + createElement(RenewalBanner, { + expiresOn: EXPIRES, + day: '2027-09-27', + zone: PAWNEE, + }) + ) + ).toBe(''); + }); +}); + +describe("the banner's clock", () => { + afterEach(() => jest.useRealTimers()); + + it('reads the time again every hour, so a window left open crosses the 21 days', () => { + jest.useFakeTimers({ now: Date.parse('2027-09-27T23:30:00.000Z') }); + const read: number[] = []; + const stop = everyHour((now) => read.splice(read.length, 0, now)); + + jest.advanceTimersByTime(HOUR_MS); + expect(read).toEqual([Date.parse('2027-09-28T00:30:00.000Z')]); + + stop(); + jest.advanceTimersByTime(HOUR_MS); + expect(read).toHaveLength(1); + }); +}); diff --git a/src/app/__tests__/route.test.ts b/src/app/__tests__/route.test.ts new file mode 100644 index 0000000..909568c --- /dev/null +++ b/src/app/__tests__/route.test.ts @@ -0,0 +1,129 @@ +import { + formatRoute, + itemSlug, + lambdaFromSlug, + lambdaSlugs, + parseRoute, + questionFromSlug, + slugify, +} from '../shared/route'; + +describe('routes — every screen has a URL, and back', () => { + it.each([ + ['/', { page: 'new' }], + ['/time', { page: 'time' }], + ['/tickets', { page: 'tickets' }], + [ + '/tickets/prj_parks/PAWNEE-2a', + { page: 'tickets', project: 'prj_parks', ticket: 'PAWNEE-2a' }, + ], + ['/projects', { page: 'projects' }], + [ + '/projects/prj_parks/Harvest%20festival', + { page: 'project', codebase: 'prj_parks', name: 'Harvest festival' }, + ], + [ + '/projects/prj_parks/Lot%2048?filter=eyJncm91cEJ5Ijoic3RhdHVzIn0', + { + page: 'project', + codebase: 'prj_parks', + name: 'Lot 48', + filter: 'eyJncm91cEJ5Ijoic3RhdHVzIn0', + }, + ], + ['/views', { page: 'views' }], + ['/views/view_queue', { page: 'view', id: 'view_queue' }], + [ + '/views/view_queue?filter=eyJncm91cEJ5Ijoic3RhdHVzIn0', + { page: 'view', id: 'view_queue', filter: 'eyJncm91cEJ5Ijoic3RhdHVzIn0' }, + ], + ['/computers', { page: 'account', which: 'computers' }], + ['/analytics', { page: 'account', which: 'analytics' }], + ['/settings/devices', { page: 'account', which: 'settings/devices' }], + ['/settings/process', { page: 'account', which: 'settings/process' }], + [ + '/settings/design-system', + { page: 'account', which: 'settings/design-system' }, + ], + ['/auto/caught-up', { page: 'auto' }], + ['/work/pod%3Aplan-parks', { page: 'work', lambda: 'pod:plan-parks' }], + [ + '/work/ticket%3Aprj_parks%2FPAWNEE-4', + { page: 'work', lambda: 'ticket:prj_parks/PAWNEE-4' }, + ], + [ + '/auto/dog-park-map/checkpoint-4', + { page: 'auto', lambda: 'dog-park-map', item: 'checkpoint-4' }, + ], + ['/lambda/dog-park-map', { page: 'lambda', lambda: 'dog-park-map' }], + [ + '/lambda/dog-park-map/decision-7?v=3', + { + page: 'lambda', + lambda: 'dog-park-map', + item: 'decision-7', + version: 3, + }, + ], + ])('%s', (url, route) => { + const [pathname, search] = url.split('?'); + expect( + parseRoute(pathname ?? '/', search === undefined ? '' : `?${search}`) + ).toEqual(expect.objectContaining(route)); + expect( + formatRoute( + parseRoute(pathname ?? '/', search === undefined ? '' : `?${search}`) + ) + ).toBe(url); + }); + + it('a bare /settings is Settings → Devices', () => { + expect(formatRoute(parseRoute('/settings'))).toBe('/settings/devices'); + }); + + it('anything unknown is the front door', () => { + expect(parseRoute('/whatever/else')).toEqual({ page: 'new' }); + expect(parseRoute('/lambda')).toEqual({ page: 'new' }); + }); +}); + +describe('readable names', () => { + it('slugifies a Lambda name', () => { + expect(slugify('Duck pond water & trail markers!')).toBe( + 'duck-pond-water-trail-markers' + ); + expect(slugify('Café jardinière')).toBe('cafe-jardiniere'); + }); + + it('keeps clashing names apart, and finds a Lambda by name, id or slug', () => { + const lambdas = [ + { id: 'pawnee-a1b2', name: 'Pawnee parks' }, + { id: 'pawnee-c3d4', name: 'Pawnee parks' }, + { id: 'eagleton-e5f6', name: 'Eagleton' }, + ]; + const slugs = lambdaSlugs(lambdas); + + expect(slugs.get('pawnee-a1b2')).toBe('pawnee-parks-a1b2'); + expect(slugs.get('pawnee-c3d4')).toBe('pawnee-parks-c3d4'); + expect(slugs.get('eagleton-e5f6')).toBe('eagleton'); + expect(lambdaFromSlug('eagleton', lambdas)).toBe('eagleton-e5f6'); + expect(lambdaFromSlug('pawnee-c3d4', lambdas)).toBe('pawnee-c3d4'); + expect(lambdaFromSlug('pawnee-parks', lambdas)).toBeUndefined(); + }); + + it('names items, and finds them again', () => { + expect(itemSlug({ kind: 'checkpoint', qid: 'q4' }, 4)).toBe('checkpoint-4'); + expect(itemSlug({ kind: 'question', qid: 'q7' })).toBe('decision-7'); + expect(itemSlug({ kind: 'reply' })).toBe('your-turn'); + expect(itemSlug({ kind: 'structure' })).toBe('structured-planning'); + + const questions = [ + { id: 'q2', status: 'closed', checkpoint: { cp: 4 } }, + { id: 'q4', status: 'open', checkpoint: { cp: 4 } }, + { id: 'q7', status: 'open' }, + ]; + expect(questionFromSlug('checkpoint-4', questions)?.id).toBe('q4'); + expect(questionFromSlug('decision-7', questions)?.id).toBe('q7'); + expect(questionFromSlug('decision-9', questions)).toBeUndefined(); + }); +}); diff --git a/src/app/__tests__/savedViews.test.ts b/src/app/__tests__/savedViews.test.ts new file mode 100644 index 0000000..3d1aaf9 --- /dev/null +++ b/src/app/__tests__/savedViews.test.ts @@ -0,0 +1,145 @@ +import type { TicketListItem } from '../shared/building'; +import { iconEmoji } from '../shared/emoji'; +import { + NO_SETTINGS, + decodeSettings, + encodeSettings, + favoriteRef, + listSettingsOf, + sameSettings, + splitFavoriteRef, + viewCounts, +} from '../shared/savedViews'; + +/** + * Saved views as the client uses them: the address's `?filter=`, the + * comparison that offers Save, a view panel's counts, and the icons Linear + * keeps as shortcodes. + */ + +const item = ( + id: string, + extra: Partial<TicketListItem> = {} +): TicketListItem => ({ + id, + title: `Ticket ${id}`, + status: 'ready', + parent: null, + labels: [], + url: null, + projectId: 'parks', + source: 'linear', + ...extra, +}); + +describe('?filter=', () => { + it('round-trips settings, any text included', () => { + const settings = { + groupBy: 'assignee' as const, + chips: [ + { field: 'project' as const, value: 'Lil’ Sebastian memorial 🐴' }, + { field: 'label' as const, value: 'bug' }, + ], + }; + const encoded = encodeSettings(settings); + + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeSettings(encoded)).toEqual(settings); + }); + + it('reads a missing or foreign filter as none', () => { + expect(decodeSettings(null)).toBeNull(); + expect(decodeSettings('')).toBeNull(); + expect(decodeSettings('not base64 json!')).toBeNull(); + }); + + it('drops a field or grouping it does not know', () => { + expect( + listSettingsOf({ + groupBy: 'cycle', + chips: [ + { field: 'cycle', value: '12' }, + { field: 'label', value: 'bug' }, + { field: 'status', value: 3 }, + ], + }) + ).toEqual({ groupBy: 'status', chips: [{ field: 'label', value: 'bug' }] }); + }); +}); + +describe('sameSettings', () => { + it('ignores the order of filters, not the grouping', () => { + const a = { + groupBy: 'status' as const, + chips: [ + { field: 'label' as const, value: 'bug' }, + { field: 'priority' as const, value: 'high' }, + ], + }; + + expect(sameSettings(a, { ...a, chips: [...a.chips].reverse() })).toBe(true); + expect(sameSettings(a, { ...a, groupBy: 'project' })).toBe(false); + expect(sameSettings(a, NO_SETTINGS)).toBe(false); + }); +}); + +describe('viewCounts', () => { + const items = [ + item('1', { + assignee: "Leslie's MacBook", + labels: ['bug'], + project: 'Lot 48', + }), + item('2', { assignee: "Leslie's MacBook", labels: ['bug', 'parks'] }), + item('3', { labels: ['parks'], project: 'Lot 48' }), + item('4', { assignee: 'Ann Perkins', project: 'Harvest festival' }), + ]; + + it('counts by assignee, the unassigned last', () => { + expect(viewCounts(items, 'assignee')).toEqual([ + { key: "Leslie's MacBook", label: "Leslie's MacBook", count: 2 }, + { key: 'Ann Perkins', label: 'Ann Perkins', count: 1 }, + { key: '', label: 'No assignee', count: 1 }, + ]); + }); + + it('counts each label a ticket has', () => { + expect( + viewCounts(items, 'label').map((one) => [one.key, one.count]) + ).toEqual([ + ['bug', 2], + ['parks', 2], + ]); + }); + + it('counts by project', () => { + expect( + viewCounts(items, 'project').map((one) => [one.label, one.count]) + ).toEqual([ + ['Lot 48', 2], + ['Harvest festival', 1], + ['No project', 1], + ]); + }); +}); + +describe('favorite refs', () => { + it("keep a project's name whole, slashes and all", () => { + expect(splitFavoriteRef(favoriteRef('parks', 'Q3 / Q4 launch'))).toEqual({ + codebaseId: 'parks', + name: 'Q3 / Q4 launch', + }); + }); +}); + +describe('iconEmoji', () => { + it("draws Linear's shortcodes and typed emoji, not its icon names", () => { + expect(iconEmoji(':bee:')).toBe('🐝'); + expect(iconEmoji(':Rocket:')).toBe('🚀'); + expect(iconEmoji('🦇')).toBe('🦇'); + expect(iconEmoji('FaceId')).toBeNull(); + expect(iconEmoji(':not_a_shortcode:')).toBeNull(); + expect(iconEmoji(null)).toBeNull(); + expect(iconEmoji('')).toBeNull(); + }); +}); diff --git a/src/app/__tests__/screenChoice.test.ts b/src/app/__tests__/screenChoice.test.ts new file mode 100644 index 0000000..582f9fc --- /dev/null +++ b/src/app/__tests__/screenChoice.test.ts @@ -0,0 +1,90 @@ +import { + firstRailOf, + planningRailOf, + planningRailsOf, + railFor, + sideRowNeedsYou, +} from '../shared/screenChoice'; + +describe('the rail a Lambda opens on', () => { + it('opens a building pod on Workflow on its own page', () => { + expect(firstRailOf({ kind: 'building' }, true)).toBe('workflow'); + }); + + it('opens a building pod from the inbox on Plan, never the planning Workflow', () => { + expect(firstRailOf({ kind: 'building' }, false)).toBe('plan'); + }); + + it('opens a structured planning pod on Plan and a freeform one shut', () => { + expect(firstRailOf({ kind: 'planning', mode: 'structured' }, false)).toBe( + 'plan' + ); + expect(firstRailOf({ mode: 'freeform' }, false)).toBeNull(); + expect(firstRailOf(undefined, false)).toBe('plan'); + }); +}); + +describe("the planning screen's rail", () => { + const on = { offPod: false, buildingPod: false }; + + it('hides the Workflow rail when the pod behind it is a building pod', () => { + expect(planningRailOf('workflow', { ...on, buildingPod: true })).toBeNull(); + expect(planningRailOf('workflow', on)).toBe('workflow'); + }); + + it('shows Tickets and Plan only on a planning pod', () => { + expect(planningRailOf('tickets', on)).toBe('tickets'); + expect(planningRailOf('tickets', { ...on, offPod: true })).toBeNull(); + expect(planningRailOf('tickets', { ...on, buildingPod: true })).toBeNull(); + expect(planningRailOf('plan', { ...on, offPod: true })).toBeNull(); + expect(planningRailOf('plan', on)).toBe('plan'); + expect(planningRailOf('agents', { ...on, buildingPod: true })).toBe( + 'agents' + ); + }); +}); + +describe("the planning screen's rail buttons", () => { + it('offers a planning pod every planning rail', () => { + expect(planningRailsOf('planning')).toEqual([ + 'plan', + 'tickets', + 'workflow', + 'made', + 'agents', + ]); + expect(planningRailsOf(undefined)).toContain('workflow'); + }); + + it('offers a building pod no Workflow button there', () => { + expect(planningRailsOf('building')).not.toContain('workflow'); + expect(planningRailsOf('building')).not.toContain('tickets'); + expect(planningRailsOf('building')).toContain('plan'); + }); +}); + +describe('the rail a Lambda comes back to', () => { + const building = ['workflow', 'tickets', 'agents']; + + it('opens on its first rail the first time', () => { + expect(railFor(undefined, building, 'workflow')).toBe('workflow'); + }); + + it('keeps the rail left open, or shut, where that rail exists', () => { + expect(railFor('tickets', building, 'workflow')).toBe('tickets'); + expect(railFor('', building, 'workflow')).toBeNull(); + }); + + it("falls back to the first rail when the one left open isn't one of its rails", () => { + // Opened from the inbox on Plan, then picked in the sidebar. + expect(railFor('plan', building, 'workflow')).toBe('workflow'); + expect(railFor('nonsense', building, 'workflow')).toBe('workflow'); + }); +}); + +describe('a sidebar row that needs you', () => { + it('is marked while the pod has an inbox item, a Blocked one included', () => { + expect(sideRowNeedsYou({ needsYou: 1 })).toBe(' side-row--needs-you'); + expect(sideRowNeedsYou({ needsYou: 0 })).toBe(''); + }); +}); diff --git a/src/app/__tests__/session.test.ts b/src/app/__tests__/session.test.ts new file mode 100644 index 0000000..6548722 --- /dev/null +++ b/src/app/__tests__/session.test.ts @@ -0,0 +1,22 @@ +import { sessionStatus } from '../client/sessionStatus'; + +const leslie = { + owner: { name: 'Leslie Knope', initials: 'LK' }, + deviceId: 'dev_1', +}; + +describe('the first screen', () => { + it('signs in with a session and out without one', () => { + expect(sessionStatus({ session: leslie })).toEqual({ + kind: 'in', + session: leslie, + }); + expect(sessionStatus({ session: null })).toEqual({ kind: 'out' }); + }); + + it('says it can’t reach Lambda when the check fails, not "sign in"', () => { + expect(sessionStatus({ error: new Error('HTTP 500') })).toEqual({ + kind: 'unreachable', + }); + }); +}); diff --git a/src/app/__tests__/setupClaude.test.ts b/src/app/__tests__/setupClaude.test.ts new file mode 100644 index 0000000..ecbd3c1 --- /dev/null +++ b/src/app/__tests__/setupClaude.test.ts @@ -0,0 +1,40 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { SAVE_FAILED } from '../../setup/connectClaude'; +import type { SetupState } from '../../setup/routes'; +import { ClaudeStep } from '../client/Setup'; + +const state: SetupState = { + steps: { + claude: { + done: true, + run: { + phase: 'failed', + step: 'sign-in', + message: SAVE_FAILED, + lines: [], + }, + }, + project: { done: true }, + tickets: { done: true }, + browser: { phase: 'not-app' }, + }, + claudeExpiresOn: '2027-10-19', + ticketSource: 'markdown', +}; + +describe('the Connect Claude step', () => { + it('says why a Reconnect failed, still showing the sign-in it kept', () => { + const html = renderToStaticMarkup( + createElement(ClaudeStep, { + state, + onContinue: () => undefined, + refresh: () => undefined, + }) + ); + + expect(html).toContain('✓ Claude connected'); + expect(html).toContain(SAVE_FAILED.replace("'", ''')); + }); +}); diff --git a/src/app/__tests__/setupSteps.test.ts b/src/app/__tests__/setupSteps.test.ts new file mode 100644 index 0000000..c7193b5 --- /dev/null +++ b/src/app/__tests__/setupSteps.test.ts @@ -0,0 +1,55 @@ +import type { SetupState } from '../../setup/routes'; +import { stepAfter } from '../client/setupSteps'; + +const withDone = (done: { + claude: boolean; + project: boolean; + tickets: boolean; +}): SetupState => ({ + steps: { + claude: { done: done.claude, run: { phase: 'idle' } }, + project: { done: done.project }, + tickets: { done: done.tickets }, + browser: { phase: 'not-app' }, + }, + claudeExpiresOn: '2027-10-19', + ticketSource: 'markdown', +}); + +describe('where a finished setup step leads', () => { + it('finishes after Reconnect Claude when the other steps are done', () => { + expect( + stepAfter( + 'claude', + withDone({ claude: true, project: true, tickets: true }) + ) + ).toBeNull(); + }); + + it('skips a done step for the next one still to do', () => { + expect( + stepAfter( + 'claude', + withDone({ claude: true, project: true, tickets: false }) + ) + ).toBe('tickets'); + }); + + it('goes back to an earlier step still to do', () => { + expect( + stepAfter( + 'tickets', + withDone({ claude: false, project: true, tickets: true }) + ) + ).toBe('claude'); + }); + + it('walks a first run in order', () => { + expect( + stepAfter( + 'claude', + withDone({ claude: true, project: false, tickets: false }) + ) + ).toBe('project'); + }); +}); diff --git a/src/app/__tests__/setupTickets.test.ts b/src/app/__tests__/setupTickets.test.ts new file mode 100644 index 0000000..ae127b9 --- /dev/null +++ b/src/app/__tests__/setupTickets.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import type { SetupState } from '../../setup/routes'; +import { TicketsStep, WhoAreYou, afterTicketsSaved } from '../client/Setup'; + +const state: SetupState = { + steps: { + claude: { done: true, run: { phase: 'idle' } }, + project: { done: true }, + tickets: { done: false }, + browser: { phase: 'not-app' }, + }, + claudeExpiresOn: '2027-10-19', + ticketSource: 'linear', +}; + +describe("the Tickets step's Connected as line", () => { + it('stays on the step after a checked key, and carries on after markdown', () => { + expect(afterTicketsSaved('Leslie Knope')).toBe('stay'); + expect(afterTicketsSaved(null)).toBe('continue'); + }); + + it('shows who it connected as, with Continue to move on', () => { + const html = renderToStaticMarkup( + createElement(TicketsStep, { + state, + onDone: () => undefined, + connected: 'Leslie Knope', + }) + ); + + expect(html).toContain('✓ Connected as Leslie Knope'); + expect(html).toMatch( + /<button type="button" class="primary">Continue<\/button>/ + ); + }); +}); + +describe('who you are, asked under Connected as', () => { + it('asks whether a login that isn’t yours yet is you, with both answers', () => { + const html = renderToStaticMarkup( + createElement(WhoAreYou, { + ask: { + login: { name: 'Pawnee build bot', email: 'bot@pawnee.gov' }, + yourEmail: 'leslie@pawnee.gov', + }, + tracker: 'Linear', + }) + ); + + expect(html).toContain( + 'Lambda builds only the tickets you report. Is Pawnee build bot (bot@pawnee.gov) you?' + ); + expect(html).toContain('Yes, that's me'); + expect(html).toContain('No, it's this computer's own login'); + }); + + it('names the login alone when the tracker hides its email', () => { + const html = renderToStaticMarkup( + createElement(WhoAreYou, { + ask: { login: { name: 'Ann Perkins', email: null }, yourEmail: null }, + tracker: 'Jira', + }) + ); + + expect(html).toContain('Is Ann Perkins you?'); + }); +}); diff --git a/src/app/__tests__/shippingGroups.test.ts b/src/app/__tests__/shippingGroups.test.ts new file mode 100644 index 0000000..65925e8 --- /dev/null +++ b/src/app/__tests__/shippingGroups.test.ts @@ -0,0 +1,96 @@ +import { type GroupEvent, shippingGroups } from '../shared/shippingGroups'; + +const at = (minute: number) => + `2026-10-23T10:${String(minute).padStart(2, '0')}:00.000Z`; + +const add = (ticket: string, minute: number): GroupEvent => ({ + type: 'group.add', + groupId: 'g1', + ticket, + at: at(minute), +}); + +const added = [add('2', 0), add('3', 1), add('4', 2)]; + +describe('shipping-together groups, from group.* events only', () => { + it('opens a group as tickets are added', () => { + expect(shippingGroups(added).open).toEqual([ + { + id: 'g1', + tickets: ['2', '3', '4'], + held: false, + reason: null, + state: 'open', + }, + ]); + }); + + it('follows reason, hold, release and shipping', () => { + const reason: GroupEvent = { + type: 'group.reason', + groupId: 'g1', + reason: 'Ships when the epic completes, 1 left', + at: at(3), + }; + const hold: GroupEvent = { + type: 'group.hold', + groupId: 'g1', + held: true, + at: at(4), + }; + + expect(shippingGroups([...added, reason]).open[0]?.reason).toBe( + 'Ships when the epic completes, 1 left' + ); + expect(shippingGroups([...added, reason, hold]).open[0]?.held).toBe(true); + expect( + shippingGroups([ + ...added, + hold, + { type: 'group.release', groupId: 'g1', at: at(5) }, + ]).open[0]?.held + ).toBe(false); + expect( + shippingGroups([ + ...added, + { type: 'group.shipping', groupId: 'g1', at: at(6) }, + ]).open[0]?.state + ).toBe('shipping'); + }); + + it('closes on group.shipped and reports its tickets done', () => { + const result = shippingGroups([ + ...added, + { type: 'group.shipped', groupId: 'g1', at: at(7) }, + ]); + + expect(result.open).toEqual([]); + expect(result.shippedTickets).toEqual(['2', '3', '4']); + }); + + it('lists an unknown group type with its line number, changing nothing', () => { + const result = shippingGroups([ + ...added, + { type: 'group.frobnicate', groupId: 'g1', at: at(8), line: 4 }, + ]); + + expect(result.unknown).toEqual([{ type: 'group.frobnicate', line: 4 }]); + expect(result.open).toEqual(shippingGroups(added).open); + }); + + it('has no group without group events', () => { + expect(shippingGroups([]).open).toEqual([]); + expect( + shippingGroups([ + { type: 'stage.start', groupId: 'g1', at: at(0), ticket: '2' }, + ]).open + ).toEqual([]); + }); + + it('knows whether a ticket is in an open group', () => { + const result = shippingGroups(added); + + expect(result.groupOf('3')?.id).toBe('g1'); + expect(result.groupOf('9')).toBeUndefined(); + }); +}); diff --git a/src/app/__tests__/speech.test.ts b/src/app/__tests__/speech.test.ts new file mode 100644 index 0000000..91bde96 --- /dev/null +++ b/src/app/__tests__/speech.test.ts @@ -0,0 +1,1035 @@ +import type { Question } from '../../pod-store'; +import { toHexBytes } from '../client/keys'; +import { + clampRate, + loadUxView, + missingPictureText, + saveUxView, + screenView, + speakable, + spokenSections, +} from '../client/speech'; +import { approveLabel, checkpointGoal, offersKeepOne } from '../shared/phases'; +import { podNumber } from '../shared/podOrder'; +import { + CHECKPOINT_INSTRUCTIONS, + buildScript, + itemSections, + sectionParts, +} from '../shared/speech'; +import type { PodSummary } from '../shared/types'; + +describe('speakable: no ticket numbers read aloud', () => { + it('drops parenthesised tickets and names bare ones', () => { + expect( + speakable('Rangers mark a trail (PAWNEE-70), then close it (PAWNEE-71).') + ).toBe('Rangers mark a trail, then close it.'); + expect(speakable('Split PAWNEE-69 into two children.')).toBe( + 'Split the ticket into two children.' + ); + expect(speakable('q3 is still waiting for you')).toBe( + 'question 3 is still waiting for you' + ); + }); +}); + +describe('checkpoint audio', () => { + const checkpoint = { + id: 'q4', + podId: 'pawnee-a1b2', + kind: 'checkpoint' as const, + phase: 3, + status: 'open' as const, + blocksCheckpoint: true, + dependsOn: [], + createdAt: '2026-10-03T00:00:00.000Z', + summary: 'I split the epic into a marking ticket and a closing ticket.', + options: [], + checkpoint: { cp: 3, decisionIds: ['d2', 'd1'] }, + }; + const decision = (id: string, headline: string, why: string) => ({ + id, + podId: 'pawnee-a1b2', + phase: 3, + decision: headline, + why, + details: 'plotTrailRoute internals — never read aloud', + createdAt: '2026-10-03T00:00:00.000Z', + }); + + it('tells a decision a screen already shows with that screen, keeping its number, and lists only the rest', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: { + ...checkpoint, + checkpoint: { + cp: 4, + decisionIds: ['d1', 'd2'], + screens: [ + { + name: 'Trail map', + change: 'changed' as const, + changes: 'A closed trail has a Reopen trail button.', + tickets: ['3'], + decisions: ['d2'], + }, + ], + }, + }, + thread: [], + podLabel: 'Test', + decisions: [ + decision('d1', 'Refused a marker that is not on the trail', 'No guessing.'), + { + ...decision( + 'd2', + 'Gave side trails the main Reopen button', + 'Same words.' + ), + instead: 'A new dialog for side trails.', + }, + ], + }); + const keys = sections?.map((section) => section.key) ?? []; + + expect(keys).toContain('screen-1'); + expect(keys).toContain('decision-1'); + expect(keys).not.toContain('decision-2'); + expect( + sections?.find((section) => section.key === 'screen-1')?.text + ).toContain( + 'Decision 2, why this way: Same words. Instead of: A new dialog for side trails.' + ); + expect( + sections?.find((section) => section.key === 'screen-1')?.text + ).toContain( + 'Here is the one decision I made for you that no screen shows.' + ); + }); + + it('reads what happened, then each decision in card order (headline + why, never details), then how to respond', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: checkpoint, + thread: [], + podLabel: 'Test', + decisions: [ + decision( + 'd1', + 'Rangers can only close one trail at a time', + 'Closing a whole park is an owner job.' + ), + decision( + 'd2', + 'Rangers get their own marker actions', + 'So they can never delete a trail.' + ), + ], + }); + + expect(sections?.map((section) => section.key)).toEqual([ + 'goal', + 'summary', + 'decision-1', + 'decision-2', + 'instructions', + ]); + expect(sections?.[1]?.text).toContain( + 'Here are the 2 decisions I made for you.' + ); + expect(sections?.[2]?.text).toBe( + 'Number 1. Rangers get their own marker actions. So they can never delete a trail.' + ); + expect(sections?.map((section) => section.text).join(' ')).not.toContain( + 'plotTrailRoute' + ); + expect(sections?.at(-1)?.text).toBe( + `${CHECKPOINT_INSTRUCTIONS} That approves the split.` + ); + + // The dot label "1" numbers the scrubber; it must not be spoken as well + // ("1. Number 1. …"). + const script = buildScript(sections ?? []); + + expect(script.text).toContain( + 'Here are the 2 decisions I made for you.\n\nNumber 1. Rangers' + ); + expect(script.text).not.toMatch(/\d\. Number/); + expect(script.text).toContain('How to respond. To change'); + }); + + it('reads "The plan" between what happened and the decisions', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: { + ...checkpoint, + checkpoint: { + cp: 3, + decisionIds: [], + plan: 'Rangers get a Mark a trail button on the map screen.', + }, + }, + thread: [], + podLabel: 'Test', + decisions: [], + }); + + expect(sections?.map((section) => section.key)).toEqual([ + 'goal', + 'summary', + 'plan', + 'instructions', + ]); + expect(sections?.[1]?.text).toBe( + 'I split the epic into a marking ticket and a closing ticket.' + ); + expect(sections?.[2]?.text).toBe( + 'Rangers get a Mark a trail button on the map screen. I made no decisions on my own this phase.' + ); + }); + + it('reads a decision like a ticket: what it means, why, and what was not chosen', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: { ...checkpoint, checkpoint: { cp: 3, decisionIds: ['d9'] } }, + thread: [], + podLabel: 'Test', + decisions: [ + { + id: 'd9', + podId: 'pawnee-a1b2', + phase: 3, + decision: 'Named the parts of the Add a marker window', + means: + 'Rangers see the trail name first, then a tickbox for closed trails.', + why: 'The ticket gave no order, so I followed its sketch.', + instead: 'Leaving the wording to whoever builds it.', + createdAt: '2026-10-04T00:00:00.000Z', + }, + ], + }); + + expect( + sections?.find((section) => section.key === 'decision-1')?.text + ).toBe( + 'Number 1. Named the parts of the Add a marker window. What it means: Rangers see the trail name first, then a tickbox for closed trails. Why: The ticket gave no order, so I followed its sketch. Instead of: Leaving the wording to whoever builds it.' + ); + }); + + it('reads a product decision Before → After, and leaves engineering calls on the card', () => { + const base = { + podId: 'pawnee-a1b2', + phase: 3, + why: 'It had been missed.', + createdAt: '2026-10-04T00:00:00.000Z', + }; + const sections = itemSections({ + kind: 'checkpoint', + question: { + ...checkpoint, + checkpoint: { cp: 3, decisionIds: ['d1', 'd2'] }, + }, + thread: [], + podLabel: 'Test', + decisions: [ + { + ...base, + id: 'd1', + decision: 'Sorted the trail list by length', + before: 'Trails were listed in the order they were added.', + means: 'The shortest walk is always first.', + }, + { + ...base, + id: 'd2', + kind: 'engineering' as const, + decision: 'Cached the map tiles by default', + before: 'Every visit fetched the tiles again.', + means: 'The map opens from the cache.', + }, + ], + }); + const spoken = sections?.map((section) => section.text).join(' ') ?? ''; + + expect(spoken).toContain( + 'Number 1. Sorted the trail list by length. Before: Trails were listed' + ); + expect(spoken).toContain( + // "red": the past-tense respelling (see PRONOUNCE in speech.ts). + 'One engineering decision is on the card, not red here.' + ); + expect(spoken).not.toContain('Cached the map tiles'); + }); + + it('reads the UX review screen by screen: what changes, then edge cases — never the ASCII', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: { + ...checkpoint, + summary: 'I sketched every screen the tickets change.', + checkpoint: { + cp: 4, + decisionIds: [], + screens: [ + { + name: 'Festival schedule', + change: 'changed', + before: '┌ 3 stalls ┐', + after: '┌ 12 stalls ┐', + changes: 'Visitors see every stall on one page.', + states: [ + 'If two stalls share a slot, Publish stays disabled until the clash is fixed.', + ], + tickets: ['2'], + }, + ], + }, + }, + thread: [], + podLabel: 'Test', + decisions: [], + }); + + expect(sections?.map((section) => section.key)).toEqual([ + 'goal', + 'summary', + 'screen-1', + 'instructions', + ]); + expect(sections?.[2]?.text).toBe( + 'One screen changes. Screen 1: Festival schedule, changed. Visitors see every stall on one page. I made no decisions on my own this phase.' + ); + // Edge cases are on demand only — never in the item's audio. + expect(sections?.map((section) => section.text).join(' ')).not.toContain( + 'Publish stays' + ); + expect(sections?.map((section) => section.text).join(' ')).not.toContain( + '┌' + ); + }); + + it('reads one line per ticket after the plan, by build number, never the id', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: { + ...checkpoint, + checkpoint: { + cp: 3, + decisionIds: [], + plan: 'Rangers mark and close trails themselves.', + planTickets: [ + { + id: 'PAWNEE-70', + title: '1 - Rangers mark a trail', + summary: 'A button on the map screen marks the nearest trail.', + }, + { + id: 'PAWNEE-71', + title: '2 - Rangers close a trail', + summary: 'Any single trail outside the festival weekend.', + }, + ], + }, + }, + thread: [], + podLabel: 'Test', + decisions: [], + }); + + // One section per ticket, so each can be jumped to. + expect(sections?.map((section) => section.key)).toEqual([ + 'goal', + 'summary', + 'plan', + 'ticket-1', + 'ticket-2', + 'instructions', + ]); + expect(sections?.[2]?.text).toBe( + 'Rangers mark and close trails themselves.' + ); + expect(sections?.[3]?.text).toBe( + '2 tickets, in build order. Ticket 1: Rangers mark a trail. A button on the map screen marks the nearest trail.' + ); + expect(sections?.[4]?.text).toBe( + 'Ticket 2: Rangers close a trail. Any single trail outside the festival weekend. I made no decisions on my own this phase.' + ); + expect(sections?.[4]?.label).toBe('Ticket 2'); + expect(sections?.map((section) => section.text).join(' ')).not.toContain( + 'PAWNEE' + ); + + // Today / After, under the plain name (the Linear title is the fallback). + const plain = itemSections({ + kind: 'checkpoint', + question: { + ...checkpoint, + checkpoint: { + cp: 3, + decisionIds: [], + plan: 'Rangers mark trails themselves.', + planTickets: [ + { + id: 'PAWNEE-70', + title: '1 - Staff can close a trail with no inspection', + name: '1 - Trails are inspected before they close', + today: 'A ranger can close a trail alone.', + after: 'Closing it asks for a photo of the trail first.', + }, + ], + }, + }, + thread: [], + podLabel: 'Test', + decisions: [], + }); + + expect(plain?.[3]?.text).toBe( + 'One ticket, in build order. Ticket 1: Trails are inspected before they close. Today: A ranger can close a trail alone. After this ticket: Closing it asks for a photo of the trail first. I made no decisions on my own this phase.' + ); + + // The label numbers the scrubber; the text already says "Ticket 2". + expect(buildScript(sections ?? []).text).not.toContain( + 'Ticket 2. Ticket 2' + ); + }); +}); + +const question = (overrides: Partial<Question> = {}): Question => ({ + id: 'q12', + podId: 'pawnee-a1b2', + kind: 'question', + phase: 3, + status: 'open', + blocksCheckpoint: false, + dependsOn: [], + createdAt: '2026-10-03T00:00:00.000Z', + summary: + 'Show closed trails as their own line on the Ramsett Park map? I recommend A.', + context: 'The map lists trails → markers.', + problem: 'Visitors cannot spot a closed trail.', + options: [ + { + id: 'A', + label: 'Own line', + detail: 'A closed-trail row', + tradeoffs: 'Longer legend', + }, + { + id: 'B', + label: 'Grey it', + detail: 'Dim the trail', + tradeoffs: 'Hides the closure', + }, + ], + recommendation: { optionId: 'A', why: 'visitors should never be surprised.' }, + visual: { + ascii: '┌ Legend ┐\n│ Closed ✕ │ ← new\n└──────────┘', + notes: 'Closed trails get their own line.', + spoken: 'Closed trails now get their own line on the map.', + }, + ...overrides, +}); + +describe('pipeline 2 checkpoints', () => { + const base = { + podId: 'pawnee-a1b2', + kind: 'checkpoint' as const, + phase: 3, + status: 'open' as const, + blocksCheckpoint: true, + dependsOn: [], + createdAt: '2026-10-07T00:00:00.000Z', + options: [], + }; + const sectionsOf = (question: Question) => + itemSections({ + kind: 'checkpoint', + question, + thread: [], + podLabel: 'Test', + decisions: [], + }) ?? []; + + it('reads the split proposal: the counts first, then each ticket with its E2E impact', () => { + const sections = sectionsOf({ + ...base, + id: 'q3', + summary: 'Two tickets.', + checkpoint: { + cp: 3, + decisionIds: [], + proposal: [ + { + number: '1', + title: 'Visitors see the trail map', + what: 'A visitor with no signal gets the cached map.', + files: ['server/src/trails/map.ts:40'], + dependsOn: [], + e2e: { kind: 'new-tests', spec: 'trail-map.spec.ts' }, + docs: [], + overlaps: [], + }, + { + number: '2', + title: 'Visitors see a closure note', + what: 'A trail closed for three days shows a note.', + files: ['server/src/trails/closures.ts:12'], + dependsOn: ['1'], + e2e: { + kind: 'new-file', + spec: 'closure.spec.ts', + why: 'no spec covers closure notes today', + }, + docs: ['trails.md'], + overlaps: [], + }, + ], + }, + }); + + expect(sections.map((section) => section.key)).toEqual([ + 'goal', + 'summary', + 'proposal-1', + 'proposal-2', + 'instructions', + ]); + expect(sections[2]?.text).toBe( + 'The proposed split: 2 tickets, built one at a time in this order; one new E2E spec file; one ticket adds tests to an existing spec. Ticket 1: Visitors see the trail map. A visitor with no signal gets the cached map. It adds tests to an existing E2E spec.' + ); + // Files are on the card, never read. + expect(sections[3]?.text).not.toMatch(/closures\.ts/); + expect(sections[3]?.text).toMatch( + /adds a new E2E spec file, because no spec covers closure notes today/ + ); + }); + + it('opens the UX review with what was understood and what changed since the split', () => { + const sections = sectionsOf({ + ...base, + id: 'q4', + summary: 'One screen changes.', + checkpoint: { + cp: 4, + decisionIds: [], + understood: 'You want the map to work even without a signal.', + changes: [ + { + kind: 'ticket', + text: 'Tickets 3 and 4 became one.', + before: 'Marking and closing a trail were separate tickets.', + why: 'The rebase on Epic 1 made them share one screen.', + }, + { kind: 'engineering', text: 'Renamed the tile helper.' }, + { + kind: 'screen', + text: 'The trail map shows the legend first.', + before: 'It showed the search box first.', + why: 'You chose the legend over search.', + }, + ], + }, + }); + + // Each change is its own section (its own dot and skip). + expect(sections.map((section) => section.key).slice(0, 5)).toEqual([ + 'goal', + 'summary', + 'understood', + 'change-1', + 'change-2', + ]); + expect(sections[3]?.text).toBe( + 'Since you approved the split, 2 things changed. Change 1, a ticket: Tickets 3 and 4 became one. Before: Marking and closing a trail were separate tickets. Why: The rebase on Epic 1 made them share one screen.' + ); + expect(sections[4]?.text).toBe( + 'Change 2, a screen: The trail map shows the legend first. Before: It showed the search box first. Why: You chose the legend over search. Plus one engineering fix, on the card.' + ); + }); + + it('reads each flow intro as its own section, before its first screen', () => { + const screen = (name: string, flow: number) => ({ + name, + change: 'changed' as const, + before: 'old', + after: 'new', + changes: `${name} changes.`, + tickets: ['1'], + flow, + }); + const sections = sectionsOf({ + ...base, + id: 'q8', + summary: 'Three screens.', + checkpoint: { + cp: 4, + decisionIds: [], + flows: [ + { name: 'Finding a trail', summary: 'Ann searches for the ridge loop.' }, + { name: 'Walking', summary: 'Ann follows the markers.' }, + ], + screens: [ + screen('Search page', 0), + screen('Park menu', 0), + screen('Trail map', 1), + ], + }, + }); + + expect(sections.map((section) => section.key)).toEqual([ + 'goal', + 'summary', + 'flow-1', + 'screen-1', + 'screen-2', + 'flow-2', + 'screen-3', + 'instructions', + ]); + expect(sections[2]?.text).toBe( + '3 screens change. Flow 1: Finding a trail. Ann searches for the ridge loop.' + ); + // The screen no longer carries its flow's intro. + expect(sections[3]?.text).toMatch(/^Screen 1: Search page, changed\./); + }); + + it('never reads a picture legend aloud', () => { + const screen = { + name: 'Trail map', + change: 'changed' as const, + before: 'old', + after: 'new', + changes: 'Ann sees why the trail is closed.', + tickets: ['2'], + }; + const question = (withPictures: boolean) => ({ + ...base, + id: 'q9', + summary: 'One screen.', + checkpoint: { + cp: 4, + decisionIds: [], + screens: [ + withPictures + ? { + ...screen, + pictures: [ + { + situation: 'Trail closed', + after: 'designs/trail-map/trail-closed.annotated.png', + legend: [{ n: 1, label: 'Why the trail is closed' }], + }, + ], + prototypeUrl: 'https://claude.ai/design/p/x', + } + : screen, + ], + }, + }); + const sections = sectionsOf(question(true)); + + expect( + sections.some((section) => section.text.includes('Why the trail is closed')) + ).toBe(false); + expect(sections).toEqual(sectionsOf(question(false))); + }); + + it('reads each suggested judgement as its own section, with why it lasts', () => { + const sections = sectionsOf({ + ...base, + id: 'q20', + phase: 5, + summary: 'Reconciled.', + checkpoint: { + cp: 5, + decisionIds: [], + suggestedJudgements: [ + { + kind: 'judgement', + statement: 'Closed trails stay on the map, greyed out.', + why: 'Visitors learn the trail is there and will reopen.', + quote: 'Just grey them out, never hide them.', + }, + { + kind: 'copy', + statement: 'Say trail, never path.', + why: 'One name everywhere.', + quote: 'always trail', + copy: { + use: 'trail', + never: ['path'], + meaning: 'a marked walking route', + }, + }, + ], + }, + }); + const judgements = sections.filter((section) => + section.key.startsWith('judgement-') + ); + + expect(judgements.map((section) => section.key)).toEqual([ + 'judgement-1', + 'judgement-2', + ]); + expect(judgements[0]?.text).toBe( + "I suggest 2 lasting judgements to keep for future tickets. Tick the ones you want before you close the checkpoint. Judgement 1 of 2: Closed trails stay on the map, greyed out. Why it's good long term: Visitors learn the trail is there and will reopen." + ); + expect(judgements[1]?.text).toMatch( + /Judgement 2 of 2: .* That means always saying trail, never path\.$/ + ); + }); + + it('says so when nothing changed since the UX review', () => { + const sections = sectionsOf({ + ...base, + id: 'q9', + phase: 5, + summary: 'Reconciled.', + checkpoint: { cp: 5, decisionIds: [], changes: [] }, + }); + + expect(sections.find((section) => section.key === 'changes')?.text).toBe( + 'Nothing you would see changed since the UX review.' + ); + }); +}); + +describe('speakable', () => { + it('drops file:line references, code spans and URLs', () => { + expect( + speakable( + 'See `buildState` in server/src/state.ts:42 and https://linear.app/x for details.' + ) + ).toBe('See in and for details.'); + }); + + it('reads arrows as words and strips box drawing', () => { + expect(speakable('trails → markers')).toBe('trails to markers'); + expect(speakable('┌──┐ box ──')).toBe('box'); + }); +}); + +describe('spokenSections', () => { + it('reads every section in order, summary first', () => { + expect(spokenSections(question()).map((section) => section.key)).toEqual([ + 'summary', + 'context', + 'problem', + 'options', + 'recommendation', + 'visual', + ]); + }); + + it('speaks the visual notes, never the ASCII', () => { + const visual = spokenSections(question()).find( + (section) => section.key === 'visual' + ); + + expect(visual?.text).toBe( + 'Closed trails now get their own line on the map.' + ); + expect( + spokenSections(question()) + .map((section) => section.text) + .join(' ') + ).not.toMatch(/[┌│└─]/); + }); + + it('formats options and the recommendation as sentences', () => { + const sections = spokenSections(question()); + + expect( + sections.find((section) => section.key === 'options')?.text + ).toContain( + 'Option A: Own line. A closed-trail row. Trade-offs: Longer legend.' + ); + expect( + sections.find((section) => section.key === 'recommendation')?.text + ).toBe('I recommend option A, because visitors should never be surprised.'); + }); + + it('skips empty sections', () => { + const keys = spokenSections( + question({ context: undefined, visual: undefined }) + ).map((section) => section.key); + + expect(keys).not.toContain('context'); + expect(keys).not.toContain('visual'); + }); +}); + +describe('clampRate', () => { + it('keeps the rate in 1–3.5 on quarter steps', () => { + expect(clampRate(9)).toBe(3.5); + expect(clampRate(0.2)).toBe(1); + expect(clampRate(2.4)).toBe(2.5); + }); +}); + +describe('toHexBytes', () => { + it('encodes keys, including escape sequences and multi-byte characters', () => { + expect(toHexBytes('a\r')).toEqual(['61', '0d']); + expect(toHexBytes('\x1b[A')).toEqual(['1b', '5b', '41']); + expect(toHexBytes('é')).toEqual(['c3', 'a9']); + }); +}); + +describe('speakable: markdown is for the eye', () => { + it('drops markers, code blocks and attached images; keeps the words', () => { + expect( + speakable( + [ + '## Why it needs a person', + '- **Ridge loop** is *closed* in winter', + '- See [the runbook](https://example.com/runbook)', + '```', + 'ls trails/', + '```', + '| Step | Who |', + '| --- | --- |', + '| Repaint markers | Ann |', + '[Image: /home/ann/.pods/_uploads/img_a_b.png]', + ].join('\n') + ) + ).toBe( + 'Why it needs a person\nRidge loop is closed in winter\nSee the runbook\nStep, Who\nRepaint markers, Ann' + ); + }); +}); + +describe('speakable: names the voice gets wrong', () => { + it('says past "read" as "red" where the voice says "reed"', () => { + expect( + speakable('Not read for you. Marked as read. Already re-read it.') + ).toBe('Not red for you. Marked as red. Already re-red it.'); + expect(speakable('Do not read it. Read the log. I have read it.')).toBe( + 'Do not read it. Read the log. I have read it.' + ); + }); + + it('says the verb "record" as the verb where the voice says the noun', () => { + expect( + speakable('Record the decision. Then record it. Re-record that clip.') + ).toBe('rekord the decision. Then rekord it. Re-rekord that clip.'); + expect( + speakable('Keep a record. The record shows it. A medical record.') + ).toBe('Keep a record. The record shows it. A medical record.'); + }); +}); + +describe('checkpoint goal', () => { + it("a batch's CP2 says it is triage only, and its button says what closing approves", () => { + expect(checkpointGoal(2, { pipeline: 2, source: 'batch' }).goal).toMatch( + /^Triage only/ + ); + expect(approveLabel(2, { pipeline: 2, source: 'batch' })).toBe( + 'Approve the ticket structure — close checkpoint 2' + ); + expect(approveLabel(5, { pipeline: 2, source: 'idea' })).toBe( + 'Approve the final plan — close checkpoint 5' + ); + }); + + it("a lone ticket's split proposal closes as the split or as one ticket", () => { + const lone = { pipeline: 2, source: 'ticket' } as const; + + expect(offersKeepOne(3, lone)).toBe(true); + expect(approveLabel(3, lone)).toBe('Accept the split — close checkpoint 3'); + expect(checkpointGoal(3, lone).goal).toMatch(/keep it one ticket/); + expect(offersKeepOne(3, { pipeline: 2, source: 'epic' })).toBe(false); + expect(offersKeepOne(5, lone)).toBe(false); + }); +}); + +describe('every item opens by naming its pod', () => { + it('"Lambda 2, Trail map bugs. Checkpoint 4." before anything else', () => { + const sections = itemSections({ + kind: 'checkpoint', + question: { + id: 'q8', + podId: 'p', + kind: 'checkpoint', + phase: 3, + status: 'open', + blocksCheckpoint: true, + dependsOn: [], + createdAt: '2026-10-10T00:00:00.000Z', + options: [], + summary: 'Screens changed.', + checkpoint: { cp: 4, decisionIds: [] }, + }, + thread: [], + podLabel: 'Trail map bugs', + podNumber: 2, + }); + + expect(sections?.[0]).toMatchObject({ + key: 'pod', + text: 'Lambda 2, Trail map bugs. Checkpoint 4.', + }); + }); + + it('says nothing extra without a number', () => { + expect( + itemSections({ + kind: 'stalled', + question: null, + thread: [], + podLabel: 'Trail map bugs', + })?.[0]?.key + ).not.toBe('pod'); + }); +}); + +describe('podNumber', () => { + it("is the pod's place in the sidebar", () => { + const summary = (id: string, createdAt: string): PodSummary => ({ + pod: { + id, + slug: id, + createdAt, + status: 'active', + phase: 1, + completedCheckpoints: [], + worktreePath: '/tmp', + ownsWorktree: false, + managerCwd: '/tmp', + devCwd: '/tmp', + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, + }, + running: true, + status: 'working', + needsYou: 0, + liveForks: [], + chatHold: false, + }); + const pods = [summary('a', '2026-10-01'), summary('b', '2026-10-02')]; + + expect(podNumber(pods, [], 'b')).toBe(2); + expect(podNumber(pods, ['b'], 'b')).toBe(1); + }); +}); + +describe('sectionParts: a long section plays while the rest renders', () => { + it('cuts a section into a short first clip, then longer ones', () => { + const sentence = `${'Leslie '.repeat(13)}wins.`; // 97 characters + const parts = sectionParts({ + key: 'summary', + label: 'Summary', + text: Array(20).fill(sentence).join(' '), + }); + + expect(parts.map((part) => part.key).slice(0, 2)).toEqual([ + 'summary#0', + 'summary#1', + ]); + expect(parts[0]?.text.startsWith('Summary. Leslie')).toBe(true); + expect(parts[0]?.text.length).toBeLessThanOrEqual(250); + expect(parts.every((part) => part.text.length <= 600)).toBe(true); + expect(parts.map((part) => part.text).join(' ')).toBe( + `Summary. ${Array(20).fill(sentence).join(' ')}` + ); + }); + + it('keeps a short section as one clip', () => { + expect( + sectionParts({ key: 'ask', label: 'Ask', text: 'Approve it?' }) + ).toEqual([{ key: 'ask#0', text: 'Ask. Approve it?' }]); + }); +}); + +describe('the UX review view (Pictures | ASCII)', () => { + const withStorage = (storage: Partial<Storage>, body: () => void): void => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + value: { localStorage: storage }, + configurable: true, + }); + + try { + body(); + } finally { + if (saved === undefined) { + Reflect.deleteProperty(globalThis, 'window'); + } else { + Object.defineProperty(globalThis, 'window', saved); + } + } + }; + const memory = (initial: string | null) => { + const items = new Map<string, string>(); + + if (initial !== null) { + items.set('pods.uxView', initial); + } + + return { + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => { + items.set(key, value); + }, + }; + }; + const throwing = { + getItem: () => { + throw new Error('blocked'); + }, + setItem: () => { + throw new Error('blocked'); + }, + }; + + it('loads pictures unless ascii was saved', () => { + withStorage(memory(null), () => expect(loadUxView()).toBe('pictures')); + withStorage(memory('ascii'), () => expect(loadUxView()).toBe('ascii')); + withStorage(memory('banana'), () => expect(loadUxView()).toBe('pictures')); + withStorage(throwing, () => expect(loadUxView()).toBe('pictures')); + }); + + it('saves the choice, and never throws when storage is blocked', () => { + withStorage(memory(null), () => { + saveUxView('ascii'); + expect(loadUxView()).toBe('ascii'); + }); + withStorage(throwing, () => + expect(() => saveUxView('ascii')).not.toThrow() + ); + }); + + it('shows pictures only when chosen and the screen has some', () => { + const picture = { + situation: 'Trail closed', + after: 'designs/trail-map/trail-closed.annotated.png', + legend: [], + }; + + expect(screenView('pictures', { pictures: [picture] })).toBe('pictures'); + expect(screenView('pictures', {})).toBe('ascii'); + expect(screenView('ascii', { pictures: [picture] })).toBe('ascii'); + expect(screenView('pictures', { pictures: [] })).toBe('ascii'); + }); + + it('says a new screen has no today only when it is new', () => { + expect(missingPictureText('before', { change: 'new' })).toBe( + 'New screen — nothing here today.' + ); + expect(missingPictureText('before', { change: 'changed' })).toBe( + 'No screenshot of today.' + ); + expect(missingPictureText('after', { change: 'removed' })).toBe('Removed.'); + }); + + it("says why there's no before when the project has no photo hook", () => { + expect( + missingPictureText('before', { change: 'changed', noPhotoHook: true }) + ).toBe('No "before" picture: this codebase hasn\'t set up app photos.'); + // A new screen has nothing today either way. + expect( + missingPictureText('before', { change: 'new', noPhotoHook: true }) + ).toBe('New screen — nothing here today.'); + }); +}); diff --git a/src/app/__tests__/state.test.ts b/src/app/__tests__/state.test.ts new file mode 100644 index 0000000..c92a333 --- /dev/null +++ b/src/app/__tests__/state.test.ts @@ -0,0 +1,775 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { type Pod, type Question, createPodStore } from '../../pod-store'; +import { orderPods } from '../client/Sidebar'; +import { awaitingAgent, buildState } from '../server/state'; + +const pod = (id: string, createdAt: string): Pod => ({ + id, + slug: id, + createdAt, + status: 'active', + phase: 2, + completedCheckpoints: [], + worktreePath: '/tmp', + ownsWorktree: false, + managerCwd: '/tmp', + devCwd: '/tmp', + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, +}); + +const open = (podId: string, id: string, committedAt: string): Question => ({ + id, + podId, + kind: 'question', + phase: 2, + status: 'open', + blocksCheckpoint: false, + dependsOn: [], + createdAt: committedAt, + committedAt, + summary: 's', + context: 'c', + problem: 'p', + options: [], +}); + +const message = ( + from: 'human' | 'dev' | 'fork', + qid: string, + createdAt: string +) => ({ + id: `m${createdAt.replace(/\D/g, '')}`, + podId: 'pawnee-a1b2', + qid, + from, + mode: 'async' as const, + text: 'x', + createdAt, + delivery: 'received' as const, +}); + +describe('awaitingAgent', () => { + const q = open('pawnee-a1b2', 'q1', '2026-10-03T01:00:00.000Z'); + + it('hides a question the owner has answered until an agent responds', () => { + expect(awaitingAgent(q, [])).toBe(false); + expect( + awaitingAgent(q, [message('human', 'q1', '2026-10-03T02:00:00.000Z')]) + ).toBe(true); + expect( + awaitingAgent(q, [ + message('human', 'q1', '2026-10-03T02:00:00.000Z'), + message('fork', 'q1', '2026-10-03T02:01:00.000Z'), + ]) + ).toBe(false); + }); + + it('ignores messages from before the question was committed (a re-asked question)', () => { + expect( + awaitingAgent(q, [message('human', 'q1', '2026-10-03T00:00:00.000Z')]) + ).toBe(false); + }); +}); + +describe('buildState', () => { + it('orders the inbox by drag priority and leaves out answered-but-unresolved questions', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod(pod('pawnee-a1b2', '2026-10-01T00:00:00.000Z')); + store.writePod(pod('eagleton-c3d4', '2026-10-02T00:00:00.000Z')); + store.writeQuestion(open('pawnee-a1b2', 'q1', '2026-10-03T01:00:00.000Z')); + store.writeQuestion(open('pawnee-a1b2', 'q2', '2026-10-03T01:05:00.000Z')); + store.writeQuestion( + open('eagleton-c3d4', 'q1', '2026-10-03T02:00:00.000Z') + ); + store.appendThread({ + id: 'manswer1', + podId: 'pawnee-a1b2', + qid: 'q1', + from: 'human', + mode: 'async', + text: 'go with B', + createdAt: '2026-10-03T03:00:00.000Z', + }); + store.writeInbox({ priority: ['eagleton-c3d4'] }); + + const state = buildState(store, () => []); + + expect(state.inbox.map(({ podId, qid }) => ({ podId, qid }))).toEqual([ + { podId: 'eagleton-c3d4', qid: 'q1' }, + { podId: 'pawnee-a1b2', qid: 'q2' }, + ]); + // q1 is answered (awaiting the dev), so only q2 still needs the owner. + expect( + state.pods.find((summary) => summary.pod.id === 'pawnee-a1b2')?.needsYou + ).toBe(1); + }); +}); + +describe('structured planning proposed by a freeform pod', () => { + it('comes up as one item, keyed by when it was proposed, and goes once started', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + const freeform: Pod = { + ...pod('pawnee-a1b2', '2026-10-01T00:00:00.000Z'), + mode: 'freeform', + structureProposal: { + source: 'epic', + epicId: 'PAWNEE-69', + why: 'Settled.', + at: '2026-10-03T01:00:00.000Z', + }, + }; + store.writePod(freeform); + // The dev's message offering it asks for a reply: the pitch stands in. + store.appendThread({ + id: 'm-offer', + podId: 'pawnee-a1b2', + from: 'dev', + mode: 'note', + text: 'Press Start planning whenever you are ready.', + expectsReply: true, + createdAt: '2026-10-03T01:00:00.000Z', + }); + + expect(buildState(store, () => []).inbox).toEqual([ + { + podId: 'pawnee-a1b2', + kind: 'structure', + key: 'pawnee-a1b2/structure/2026-10-03T01:00:00.000Z', + }, + ]); + + store.writePod({ + ...freeform, + mode: 'structured', + structureProposal: undefined, + }); + // Starting it answered the offer: no "Your turn" left behind. + store.appendThread({ + id: 'm-start', + podId: 'pawnee-a1b2', + from: 'system', + mode: 'note', + text: 'the owner switched this pod to the structured flow.', + wire: '/pod-structured [m:m-start]: the owner switched this pod.', + target: 'manager', + createdAt: '2026-10-03T02:00:00.000Z', + }); + expect(buildState(store, () => []).inbox).toEqual([]); + }); +}); + +describe('closing a checkpoint answers what the dev asked about it', () => { + it('leaves no "Your turn" behind', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod(pod('pawnee-a1b2', '2026-10-01T00:00:00.000Z')); + store.appendThread({ + id: 'm-ask', + podId: 'pawnee-a1b2', + from: 'dev', + mode: 'note', + text: 'The plan is updated. Anything else before you close it?', + expectsReply: true, + createdAt: '2026-10-03T01:00:00.000Z', + }); + + expect(buildState(store, () => []).inbox.map(({ kind }) => kind)).toEqual([ + 'reply', + ]); + + store.appendThread({ + id: 'm-close', + podId: 'pawnee-a1b2', + from: 'system', + mode: 'note', + text: 'Checkpoint 5 is complete (closed by the owner). Continue the pipeline.', + wire: '/checkpoint-complete [m:m-close]: Checkpoint 5 is complete.', + target: 'manager', + createdAt: '2026-10-03T02:00:00.000Z', + }); + expect(buildState(store, () => []).inbox).toEqual([]); + }); +}); + +describe('attention: every pod either needs the owner or is working', () => { + const seedPod = (): { + root: string; + store: ReturnType<typeof createPodStore>; + } => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-attn-')); + const store = createPodStore(root); + store.writePod(pod('pawnee-a1b2', '2026-10-03T00:00:00.000Z')); + + return { root, store }; + }; + const say = ( + store: ReturnType<typeof createPodStore>, + id: string, + from: 'human' | 'dev' | 'manager', + createdAt: string, + target?: string, + kind: 'ask' | 'fyi' = 'ask' + ) => + store.appendThread({ + id, + podId: 'pawnee-a1b2', + from, + mode: from === 'human' ? 'sync' : 'note', + text: `${from} says`, + ...(target === undefined ? {} : { target, wire: 'x' }), + ...(from === 'human' ? {} : { expectsReply: kind === 'ask' }), + createdAt, + }); + + it('a pod running a work request has nothing in the inbox until it is done', () => { + const { store } = seedPod(); + // An ask that would normally be "Your turn"… + say(store, 'm1', 'dev', '2026-10-03T11:56:00.000Z'); + store.writeRequest({ + id: 'r1', + podId: 'pawnee-a1b2', + summary: 'Rebase on Epic 1', + quote: 'rebase on epic 1', + status: 'working', + createdAt: '2026-10-03T11:57:00.000Z', + }); + + const reworking = buildState(store, () => ['manager', 'dev']); + expect(reworking.inbox).toEqual([]); + expect(reworking.pods[0]?.status).toBe('working'); + expect(reworking.pods[0]?.request?.id).toBe('r1'); + + // Done: the item comes back. + const request = store.readRequest('pawnee-a1b2', 'r1'); + + if (request === null) { + throw new Error('The request was not saved.'); + } + + store.writeRequest({ ...request, status: 'done' }); + expect(buildState(store, () => ['manager', 'dev']).inbox).toHaveLength(1); + }); + + it("a request's own question to the owner still reaches them (never a silent stall)", () => { + const { store } = seedPod(); + store.writeQuestion(open('pawnee-a1b2', 'q1', '2026-10-03T10:00:00.000Z')); + store.writeRequest({ + id: 'r1', + podId: 'pawnee-a1b2', + summary: 'Add the ridge loop to the trail map', + quote: 'add the ridge loop to the trail map', + status: 'working', + createdAt: '2026-10-03T11:57:00.000Z', + }); + // Mid-request, the dev needs their call: A or B? + say(store, 'm1', 'dev', '2026-10-03T11:58:00.000Z'); + + const state = buildState(store, () => ['manager', 'dev']); + + expect(state.inbox.map((item) => item.kind)).toEqual(['reply']); + expect(state.pods[0]?.status).toBe('needs-you'); + }); + + it('⌘⏎ in the Chat panel keeps the pod out of the inbox until the chat answers', () => { + const { store } = seedPod(); + store.writeQuestion(open('pawnee-a1b2', 'q1', '2026-10-03T10:00:00.000Z')); + const chat = ( + id: string, + from: 'human' | 'fork', + createdAt: string, + extra: { hold?: boolean; progress?: boolean } = {} + ) => + store.appendThread({ + id, + podId: 'pawnee-a1b2', + from, + mode: from === 'human' ? 'sync' : 'note', + text: `${from} in the chat`, + chat: true, + ...extra, + ...(from === 'human' ? { target: 'fork-chat', wire: 'x' } : {}), + createdAt, + }); + const at = (iso: string) => + buildState(store, () => [], new Map(), Date.parse(iso)); + + chat('m1', 'human', '2026-10-03T11:00:00.000Z', { hold: true }); + expect(at('2026-10-03T11:01:00.000Z').inbox).toEqual([]); + expect(at('2026-10-03T11:01:00.000Z').pods[0]?.chatHold).toBe(true); + + // A progress note isn't the answer. + chat('m2', 'fork', '2026-10-03T11:02:00.000Z', { progress: true }); + expect(at('2026-10-03T11:03:00.000Z').inbox).toEqual([]); + + // The answer lets go. + chat('m3', 'fork', '2026-10-03T11:04:00.000Z'); + const answered = at('2026-10-03T11:05:00.000Z'); + expect(answered.inbox.map((item) => item.qid)).toEqual(['q1']); + expect(answered.pods[0]).toMatchObject({ + chatHold: false, + chatSentAt: '2026-10-03T11:00:00.000Z', + chatRepliedAt: '2026-10-03T11:04:00.000Z', + }); + + // No answer at all: it lets go after 15 minutes anyway. + chat('m4', 'human', '2026-10-03T12:00:00.000Z', { hold: true }); + expect(at('2026-10-03T12:14:00.000Z').inbox).toEqual([]); + expect(at('2026-10-03T12:16:00.000Z').inbox).toHaveLength(1); + + // A plain ⏎ message never holds. + chat('m5', 'human', '2026-10-03T13:00:00.000Z'); + expect(at('2026-10-03T13:01:00.000Z').inbox).toHaveLength(1); + }); + + it('never surfaces an FYI — an acknowledgement after "Done — carry on" stays in the thread', () => { + const { store } = seedPod(); + say(store, 'm1', 'human', '2026-10-03T11:55:00.000Z', 'dev'); + say(store, 'm2', 'dev', '2026-10-03T11:56:00.000Z', undefined, 'fyi'); + store.patchTurn('pawnee-a1b2', 'dev', { + startedAt: '2026-10-03T11:57:00.000Z', + }); + + const state = buildState(store, () => ['manager', 'dev']); + + expect(state.inbox).toEqual([]); + expect(state.pods[0]?.status).toBe('working'); + }); + + it('keeps an unanswered ask in the inbox even when FYIs follow it', () => { + const { store } = seedPod(); + say(store, 'm1', 'dev', '2026-10-03T11:50:00.000Z', undefined, 'ask'); + say(store, 'm2', 'dev', '2026-10-03T11:51:00.000Z', undefined, 'fyi'); + + expect( + buildState(store, () => ['manager', 'dev']).inbox[0]?.messageId + ).toBe('m1'); + }); + + it('surfaces a dev reply as "Your turn" first, until the owner answers', () => { + const { store } = seedPod(); + store.writeQuestion(open('pawnee-a1b2', 'q1', '2026-10-03T10:00:00.000Z')); + say(store, 'm1', 'human', '2026-10-03T11:00:00.000Z', 'dev'); + say(store, 'm2', 'dev', '2026-10-03T11:01:00.000Z'); + + const state = buildState(store, () => ['manager', 'dev']); + + expect(state.inbox.map((item) => item.kind)).toEqual(['reply', 'question']); + expect(state.inbox[0]?.messageId).toBe('m2'); + expect(state.pods[0]?.status).toBe('needs-you'); + expect(state.pods[0]?.needsYou).toBe(2); + + say(store, 'm3', 'human', '2026-10-03T11:02:00.000Z', 'dev'); + + expect( + buildState(store, () => ['manager', 'dev']).inbox.map((item) => item.kind) + ).toEqual(['question']); + }); + + it('follows the sidebar order: a lower pod\'s "Your turn" waits behind the pods above it', () => { + const { store } = seedPod(); + store.writePod(pod('eagleton-c3d4', '2026-10-04T00:00:00.000Z')); + store.writeQuestion(open('pawnee-a1b2', 'q1', '2026-10-03T10:00:00.000Z')); + store.writeQuestion( + open('eagleton-c3d4', 'q1', '2026-10-04T10:00:00.000Z') + ); + store.appendThread({ + id: 'e1', + podId: 'eagleton-c3d4', + from: 'dev', + mode: 'note', + text: 'Which one?', + expectsReply: true, + createdAt: '2026-10-04T11:00:00.000Z', + }); + + const order = () => + buildState(store, () => ['manager', 'dev']).inbox.map( + (item) => `${item.podId}:${item.kind}` + ); + + // No drag yet: oldest pod first, as the sidebar shows it. + expect(order()).toEqual([ + 'pawnee-a1b2:question', + 'eagleton-c3d4:reply', + 'eagleton-c3d4:question', + ]); + + store.writeInbox({ priority: ['eagleton-c3d4'] }); + + expect(order()).toEqual([ + 'eagleton-c3d4:reply', + 'eagleton-c3d4:question', + 'pawnee-a1b2:question', + ]); + }); + + it('does not treat agent-to-agent traffic as a reply to the owner', () => { + const { store } = seedPod(); + say(store, 'm1', 'manager', '2026-10-03T11:59:00.000Z', 'dev'); + + expect(buildState(store, () => ['manager', 'dev']).inbox).toEqual([]); + }); + + it('is Working while an agent turn is in progress', () => { + const { store } = seedPod(); + store.patchTurn('pawnee-a1b2', 'dev', { + startedAt: '2026-10-03T11:00:00.000Z', + }); + + const state = buildState(store, () => ['manager', 'dev']); + + expect(state.inbox).toEqual([]); + expect(state.pods[0]?.status).toBe('working'); + }); + + it('an idle pod is still Working — the watchdog nudges it before the owner hears', () => { + const { store } = seedPod(); + store.patchTurn('pawnee-a1b2', 'dev', { + startedAt: '2026-10-03T11:00:00.000Z', + endedAt: '2026-10-03T11:30:00.000Z', + }); + + const state = buildState(store, () => ['manager', 'dev']); + + expect(state.inbox).toEqual([]); + expect(state.pods[0]?.status).toBe('working'); + }); + + it('is Stalled, with why, once the watchdog escalates it', () => { + const { store } = seedPod(); + const why = 'I nudged the manager 2 times and it made no progress'; + + const state = buildState( + store, + () => ['manager', 'dev'], + new Map([['pawnee-a1b2', why]]) + ); + + expect(state.inbox).toEqual([ + { + podId: 'pawnee-a1b2', + kind: 'stalled', + key: 'pawnee-a1b2/stalled', + reason: why, + }, + ]); + expect(state.pods[0]?.status).toBe('stalled'); + }); + + it('is Waiting on step 0, not Stalled, when parked on a human ticket', () => { + const { store } = seedPod(); + store.updatePod('pawnee-a1b2', (current) => ({ + ...current, + waitingOnHuman: { + ticketId: 'PAWNEE-90', + since: '2026-10-03T09:00:00.000Z', + }, + })); + + const state = buildState(store, () => []); + + expect(state.inbox).toEqual([]); + expect(state.pods[0]?.status).toBe('waiting-step0'); + }); +}); + +describe('orderPods', () => { + it('puts prioritised pods first, then the rest oldest first, and hides done pods', () => { + const summaries = [ + pod('a-0001', '2026-10-01T00:00:00.000Z'), + pod('b-0002', '2026-10-02T00:00:00.000Z'), + { ...pod('c-0003', '2026-10-03T00:00:00.000Z'), status: 'done' as const }, + pod('d-0004', '2026-09-30T00:00:00.000Z'), + ].map((p) => ({ + pod: p, + running: true, + status: 'working' as const, + needsYou: 0, + liveForks: [], + chatHold: false, + })); + + expect( + orderPods(summaries, ['b-0002']).map((summary) => summary.pod.id) + ).toEqual(['b-0002', 'd-0004', 'a-0001']); + }); +}); + +describe("a building pod's Blocked items", () => { + it('each come to the inbox: two in a row are two items', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod({ + ...pod('build-a1b2', '2026-10-01T00:00:00.000Z'), + kind: 'building', + runnerId: 'r-leslie-mac', + sessions: { 'engineering-manager': 'em', dev: 'd', qa: 'q' }, + }); + ['m5blocked', 'm6blocked'].forEach((id, index) => + store.appendThread({ + id, + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: `**What:** Ticket ${ + index + 5 + }\n**Problem:** …\n**Options:** …\n**Recommendation:** …`, + expectsReply: true, + createdAt: `2026-10-23T10:0${index}:00.000Z`, + }) + ); + + const state = buildState(store, () => []); + + expect( + state.inbox.map(({ kind, messageId }) => ({ kind, messageId })) + ).toEqual([ + { kind: 'reply', messageId: 'm5blocked' }, + { kind: 'reply', messageId: 'm6blocked' }, + ]); + }); + + it('stay in the inbox while the owner chats with the Product manager', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod({ + ...pod('build-a1b2', '2026-10-01T00:00:00.000Z'), + kind: 'building', + runnerId: 'r-leslie-mac', + sessions: { 'engineering-manager': 'em', dev: 'd', qa: 'q' }, + }); + store.appendThread({ + id: 'm5blocked', + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: '**What:** Ticket 5\n**Problem:** …\n**Options:** …\n**Recommendation:** …', + expectsReply: true, + createdAt: '2026-10-23T10:00:00.000Z', + }); + store.appendThread({ + id: 'm6question', + podId: 'build-a1b2', + from: 'human', + target: 'product-manager', + mode: 'sync', + text: 'why did the test fail?', + createdAt: '2026-10-23T10:05:00.000Z', + }); + + expect( + buildState(store, () => []).inbox.map(({ messageId }) => messageId) + ).toEqual(['m5blocked']); + + // An answer through the item (to the Engineering manager) settles it. + store.appendThread({ + id: 'm7answer', + podId: 'build-a1b2', + from: 'human', + target: 'engineering-manager', + mode: 'sync', + text: 'skip it', + createdAt: '2026-10-23T10:06:00.000Z', + }); + + expect(buildState(store, () => []).inbox).toEqual([]); + }); +}); + +describe("a building pod's state, once per build", () => { + it("reads each building pod's building state once", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + ['build-a1b2', 'build-c3d4'].forEach((id) => + store.writePod({ + ...pod(id, '2026-10-01T00:00:00.000Z'), + kind: 'building', + runnerId: 'r-leslie-mac', + sessions: { 'engineering-manager': 'em', dev: 'd', qa: 'q' }, + }) + ); + const readBuilding = jest.fn(store.readBuilding); + + buildState({ ...store, readBuilding }, () => []); + + expect(readBuilding.mock.calls).toEqual([['build-a1b2'], ['build-c3d4']]); + }); +}); + +describe("a building pod's Blocked item answered on its own", () => { + it('settles only the item the answer is about, leaving the one before it', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod({ + ...pod('build-a1b2', '2026-10-01T00:00:00.000Z'), + kind: 'building', + runnerId: 'r-leslie-mac', + sessions: { 'engineering-manager': 'em', dev: 'd', qa: 'q' }, + }); + ['m5blocked', 'm6blocked'].forEach((id, index) => + store.appendThread({ + id, + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: `**What:** Ticket ${index + 5}`, + expectsReply: true, + createdAt: `2026-10-23T10:0${index}:00.000Z`, + }) + ); + store.appendThread({ + id: 'm7answer', + podId: 'build-a1b2', + from: 'human', + target: 'engineering-manager', + about: 'm6blocked', + mode: 'sync', + text: 'add the key', + createdAt: '2026-10-23T10:06:00.000Z', + }); + + expect( + buildState(store, () => []).inbox.map(({ messageId }) => messageId) + ).toEqual(['m5blocked']); + }); +}); + +describe("a building pod's Blocked item once its ticket is unblocked", () => { + const blockedRun = (id: string, messageId: string) => ({ + id, + title: `Ticket ${id}`, + step: 'test', + attempt: 1, + startedAt: '2026-10-23T09:00:00.000Z', + blocked: { + at: '2026-10-23T10:00:00.000Z', + what: `Ticket ${id}`, + problem: 'No progress on Test for 40 minutes', + options: '1) look at the Agents tab 2) set it back to Ready to retry', + recommendation: 'Check the Agents tab.', + messageId, + }, + }); + + it('leaves the inbox when the ticket is back in the queue, or picked up again', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod({ + ...pod('build-a1b2', '2026-10-01T00:00:00.000Z'), + kind: 'building', + runnerId: 'r-leslie-mac', + sessions: { 'engineering-manager': 'em', dev: 'd', qa: 'q' }, + }); + ['5', '6'].forEach((ticketId) => + store.appendThread({ + id: `m${ticketId}blocked`, + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: `**What:** Ticket ${ticketId}\n**Problem:** …\n**Options:** …\n**Recommendation:** …`, + expectsReply: true, + createdAt: `2026-10-23T10:0${ticketId}:00.000Z`, + }) + ); + const asked = () => + buildState(store, () => []).inbox.map(({ messageId }) => messageId); + + store.writeBuilding('build-a1b2', { + runs: [blockedRun('5', 'm5blocked'), blockedRun('6', 'm6blocked')], + upNext: [], + }); + expect(asked()).toEqual(['m5blocked', 'm6blocked']); + + // Ticket 5 set back to Ready: the runner's poll lists it in Up next. + store.updateBuilding('build-a1b2', (state) => ({ + ...state, + upNext: [{ id: '5', title: 'Ticket 5' }], + })); + expect(asked()).toEqual(['m6blocked']); + + // Ticket 6 set back to Ready and picked up again: a later run of it. + store.updateBuilding('build-a1b2', (state) => ({ + ...state, + runs: [ + ...state.runs, + { + id: '6', + title: 'Ticket 6', + step: 'pick-up', + attempt: 1, + startedAt: '2026-10-23T11:00:00.000Z', + }, + ], + })); + expect(asked()).toEqual([]); + }); +}); + +describe("a building pod's items, tagged for Auto and a Lambda's page", () => { + it('names the ticket, and tags a build blocker apart from a review', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pods-app-')); + const store = createPodStore(root); + store.writePod({ + ...pod('build-a1b2', '2026-10-01T00:00:00.000Z'), + kind: 'building', + runnerId: 'r-leslie-mac', + sessions: { 'engineering-manager': 'em', dev: 'd', qa: 'q' }, + }); + store.appendThread({ + id: 'm5blocked', + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: '**What:** Ticket 5\n**Problem:** …\n**Options:** …\n**Recommendation:** …', + expectsReply: true, + createdAt: '2026-10-23T10:05:00.000Z', + }); + store.appendThread({ + id: 'm7review', + podId: 'build-a1b2', + from: 'engineering-manager', + mode: 'note', + text: 'Ticket 7 is built and tested: ready for your review.', + expectsReply: true, + review: { ticketId: '7' }, + createdAt: '2026-10-23T10:07:00.000Z', + }); + store.writeBuilding('build-a1b2', { + runs: [ + { + id: '5', + title: 'Ticket 5', + step: 'test', + attempt: 1, + startedAt: '2026-10-23T09:00:00.000Z', + blocked: { + at: '2026-10-23T10:00:00.000Z', + what: 'Ticket 5', + problem: 'No progress on Test for 40 minutes', + options: '1) look at the Agents tab', + recommendation: 'Check the Agents tab.', + messageId: 'm5blocked', + }, + }, + ], + upNext: [], + }); + + expect( + buildState(store, () => []).inbox.map(({ messageId, ticketId, tag }) => ({ + messageId, + ticketId, + tag, + })) + ).toEqual([ + { messageId: 'm5blocked', ticketId: '5', tag: 'blocked' }, + { messageId: 'm7review', ticketId: '7', tag: 'review' }, + ]); + }); +}); diff --git a/src/app/__tests__/terminalErrors.test.ts b/src/app/__tests__/terminalErrors.test.ts new file mode 100644 index 0000000..28c9ea4 --- /dev/null +++ b/src/app/__tests__/terminalErrors.test.ts @@ -0,0 +1,69 @@ +import { ServerError, api } from '../client/api'; +import { terminalErrors } from '../client/terminalErrors'; + +const OFFLINE = 'Leslie’s Mac mini is offline.'; + +const reporter = () => { + const report = jest.fn<void, [unknown]>(); + + return { + errors: terminalErrors(report), + said: () => + report.mock.calls.map(([caught]) => + caught instanceof Error ? caught.message : String(caught) + ), + }; +}; + +describe('the terminal’s failures', () => { + it('says a keystroke didn’t reach an offline computer', () => { + const { errors, said } = reporter(); + + errors.keys(new ServerError(409, OFFLINE)); + + expect(said()).toEqual([OFFLINE]); + }); + + it('says a poll that the server refused, once until a frame lands again', () => { + const { errors, said } = reporter(); + + errors.poll(new ServerError(409, OFFLINE)); + errors.poll(new ServerError(409, OFFLINE)); + errors.poll(new ServerError(504, 'No answer from Leslie’s Mac mini.')); + expect(said()).toEqual([OFFLINE]); + + errors.polled(); + errors.poll(new ServerError(409, OFFLINE)); + expect(said()).toEqual([OFFLINE, OFFLINE]); + }); + + it('keeps quiet, holding the last frame, when the window is gone', () => { + const { errors, said } = reporter(); + + // The runner's own answer: tmux has no such window (fork closed). + errors.poll(new ServerError(400, "can't find window: fork-q4")); + // The request died on the way: the page's own state stream says so. + errors.poll( + new Error("The inbox server didn't answer /api/term (HTTP 502)") + ); + + expect(said()).toEqual([]); + }); +}); + +describe('a read the server refused', () => { + afterEach(() => jest.restoreAllMocks()); + + it('carries the server’s words and status, not “Unexpected response”', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: false, error: OFFLINE }), { + status: 409, + }) + ); + + const caught = await api.term('pod_pawnee', 'dev').catch((e: unknown) => e); + + expect(caught).toBeInstanceOf(ServerError); + expect(caught).toMatchObject({ status: 409, message: OFFLINE }); + }); +}); diff --git a/src/app/__tests__/ticketBoard.test.ts b/src/app/__tests__/ticketBoard.test.ts new file mode 100644 index 0000000..a03e4bf --- /dev/null +++ b/src/app/__tests__/ticketBoard.test.ts @@ -0,0 +1,338 @@ +import { formatMinutes, stepDisplayName } from '../shared/buildTimeline'; +import type { TicketListItem } from '../shared/building'; +import { sidebarBuildingLine } from '../shared/building'; +import { + type BoardOptions, + PICKABLE_STATUSES, + STATUSES, + boardGroups, + computerChoices, + matchesChips, + realChanges, + ticketActivity, + visibleRows, +} from '../shared/ticketBoard'; + +/** The Tickets screen's list as Linear lays it out, and a ticket's Activity. */ + +const ticket = ( + id: string, + extra: Partial<TicketListItem> = {} +): TicketListItem => ({ + id, + title: `Ticket ${id}`, + status: 'ready', + parent: null, + labels: [], + url: null, + projectId: 'parks', + source: 'markdown', + ...extra, +}); + +const options = (extra: Partial<BoardOptions> = {}): BoardOptions => ({ + groupBy: 'status', + chips: [], + search: '', + shippingTogether: new Set(), + projectName: (projectId) => + ({ parks: 'Pawnee site', bakery: 'Bakery' }[projectId] ?? projectId), + ...extra, +}); + +const TICKETS = [ + ticket('PAWNEE-10', { title: 'Parks site launch', status: 'planning' }), + ticket('PAWNEE-2', { + parent: 'PAWNEE-10', + status: 'building', + priority: 'high', + assignee: "Leslie's MacBook Pro", + }), + ticket('PAWNEE-5', { parent: 'PAWNEE-10', status: 'planning' }), + ticket('PAWNEE-1', { parent: 'PAWNEE-10', status: 'done' }), + ticket('PAWNEE-3', { + priority: 'urgent', + assignee: "Ann's Mac mini", + labels: ['site'], + }), + ticket('PAWNEE-7', { labels: ['bug'] }), + ticket('PAWNEE-9', { status: 'canceled' }), +]; + +describe('the status table', () => { + it('names every status once, and lets a person pick only what the build never sets', () => { + expect(STATUSES.done.name).toBe('Shipped'); + expect(PICKABLE_STATUSES).toEqual([ + 'inbox', + 'planning', + 'ready', + 'blocked', + 'canceled', + ]); + }); +}); + +describe('the list', () => { + it('lists an epic’s tickets under it in the same group, with its progress', () => { + const planning = boardGroups(TICKETS, options()).find( + (group) => group.status === 'planning' + ); + + expect( + planning?.rows.map((row) => [row.item.id, row.depth, row.progress]) + ).toEqual([ + ['PAWNEE-10', 0, { done: 1, total: 3 }], + ['PAWNEE-5', 1, null], + ]); + }); + + it('ands filters on different fields and ors chips on one field', () => { + const ids = (chips: BoardOptions['chips']) => + TICKETS.filter((item) => matchesChips(item, chips, '')).map( + (item) => item.id + ); + + expect( + ids([ + { field: 'priority', value: 'urgent' }, + { field: 'priority', value: 'high' }, + ]) + ).toEqual(['PAWNEE-2', 'PAWNEE-3']); + expect( + ids([ + { field: 'priority', value: 'urgent' }, + { field: 'label', value: 'bug' }, + ]) + ).toEqual([]); + expect(ids([{ field: 'assignee', value: '' }])).toEqual([ + 'PAWNEE-10', + 'PAWNEE-5', + 'PAWNEE-1', + 'PAWNEE-7', + ]); + }); + + it('groups by computer, Unassigned last', () => { + expect( + boardGroups(TICKETS, options({ groupBy: 'assignee' })).map((group) => [ + group.label, + group.rows.length, + ]) + ).toEqual([ + ["Ann's Mac mini", 1], + ["Leslie's MacBook Pro", 1], + ['Unassigned', 4], + ]); + }); + + it('groups by epic, the epic itself in its own group, No epic last', () => { + expect( + boardGroups(TICKETS, options({ groupBy: 'epic' })).map((group) => [ + group.label, + group.rows.map((row) => row.item.id), + ]) + ).toEqual([ + ['Parks site launch', ['PAWNEE-10', 'PAWNEE-2', 'PAWNEE-5', 'PAWNEE-1']], + ['No epic', ['PAWNEE-3', 'PAWNEE-7']], + ]); + }); + + it('leaves a folded group’s rows out of j and k', () => { + const groups = boardGroups(TICKETS, options()); + + expect( + visibleRows(groups, new Set(['Ready', 'Planning'])).map( + (row) => row.item.id + ) + ).toEqual(['PAWNEE-2', 'PAWNEE-1']); + }); +}); + +describe("a ticket's Activity", () => { + const T = Date.parse('2026-10-26T10:00:00.000Z'); + const min = 60_000; + + it('reads edits in words, then the build: picked up, its steps, shipped', () => { + const entries = ticketActivity( + [ + { + type: 'ticket.create', + name: 'created', + result: null, + at: T, + note: 'Park map', + source: 'Leslie', + }, + { + type: 'ticket.edit', + name: 'priority', + result: null, + at: T + min, + note: 'high', + source: 'Leslie', + }, + { + type: 'ticket.edit', + name: 'assignee', + result: null, + at: T + 2 * min, + note: "Leslie's MacBook Pro", + source: 'Leslie', + }, + { type: 'stage.start', name: 'pick-up', result: null, at: T + 3 * min }, + { type: 'stage.end', name: 'pick-up', result: 'pass', at: T + 3 * min }, + { type: 'stage.start', name: 'build', result: null, at: T + 4 * min }, + { type: 'stage.end', name: 'build', result: 'pass', at: T + 13 * min }, + { + type: 'stage.end', + name: 'visual-check', + result: null, + at: T + 13 * min, + skipped: true, + }, + { type: 'stage.start', name: 'test', result: null, at: T + 14 * min }, + ], + stepDisplayName, + formatMinutes + ); + + expect(entries.map((entry) => `${entry.who} ${entry.text}`)).toEqual([ + 'Leslie created it', + 'Leslie set priority to High', + "Leslie assigned it to Leslie's MacBook Pro", + 'Lambda picked it up', + 'Lambda Build ✓ 9m · Test now', + ]); + }); + + it('says when it shipped, and reads an unassign and a move out of the epic', () => { + const entries = ticketActivity( + [ + { + type: 'ticket.edit', + name: 'assignee', + result: null, + at: T, + note: '', + source: 'Leslie', + }, + { + type: 'ticket.edit', + name: 'parent', + result: null, + at: T + 1, + note: '', + source: 'Leslie', + }, + { type: 'stage.end', name: 'ship', result: 'pass', at: T + 2 }, + ], + stepDisplayName, + formatMinutes + ); + + expect(entries.map((entry) => entry.text)).toEqual([ + 'unassigned it', + 'took it out of its epic', + 'picked it up', + 'Ship ✓', + 'shipped it', + ]); + }); +}); + +describe('the computers a ticket can go to', () => { + it('lists those with the project first, each with what it is doing', () => { + expect( + computerChoices( + [ + { name: "Tom's laptop", connected: false, projectIds: ['parks'] }, + { name: "Ann's Mac mini", connected: true, projectIds: ['parks'] }, + { name: 'Bakery iMac', connected: true, projectIds: ['bakery'] }, + { + name: "Leslie's MacBook Pro", + connected: true, + projectIds: ['parks'], + }, + ], + [{ computer: "Leslie's MacBook Pro", state: 'testing' }], + 'parks' + ).map((one) => [one.name, one.state, one.hasProject]) + ).toEqual([ + ["Ann's Mac mini", 'idle', true], + ["Leslie's MacBook Pro", 'testing', true], + ["Tom's laptop", 'offline', true], + ['Bakery iMac', 'idle', false], + ]); + }); +}); + +describe('the sidebar’s building line', () => { + const now = Date.parse('2026-10-26T10:04:00.000Z'); + const base = { + current: null, + position: null, + idleReason: 'Nothing ready to build', + lastShipped: null, + }; + + it('names the ticket while on one, and the last ship while idle', () => { + expect( + sidebarBuildingLine( + { + ...base, + state: 'testing', + current: { + id: 'PAWNEE-2', + title: 'Park map', + step: 'test', + attempt: 1, + }, + }, + now + ) + ).toBe('testing · PAWNEE-2'); + expect( + sidebarBuildingLine( + { + ...base, + state: 'idle', + lastShipped: { id: 'PAWNEE-1', at: '2026-10-26T10:00:00.000Z' }, + }, + now + ) + ).toBe('idle · shipped PAWNEE-1 4m ago'); + expect(sidebarBuildingLine({ ...base, state: 'paused' }, now)).toBe( + 'paused' + ); + expect(sidebarBuildingLine({ ...base, state: 'idle' }, now)).toBe( + 'idle · Nothing ready to build' + ); + }); +}); + +describe('what a change really changes', () => { + it('sends only what differs, and nothing for a pick of the same value', () => { + const item = ticket('PAWNEE-3', { + priority: 'urgent', + assignee: "Ann's Mac mini", + labels: ['site'], + }); + + expect( + realChanges(item, 'Old words.', { + priority: 'urgent', + assignee: "Ann's Mac mini", + labels: ['site'], + status: 'ready', + description: 'Old words.', + }) + ).toBeNull(); + expect( + realChanges(item, 'Old words.', { + priority: 'low', + labels: ['site', 'bug'], + removeLabels: ['nope'], + }) + ).toEqual({ priority: 'low', labels: ['bug'] }); + }); +}); diff --git a/src/app/__tests__/ticketJourney.test.ts b/src/app/__tests__/ticketJourney.test.ts new file mode 100644 index 0000000..580889c --- /dev/null +++ b/src/app/__tests__/ticketJourney.test.ts @@ -0,0 +1,107 @@ +import type { TicketPage, TimelineEvent } from '../shared/building'; +import { shippingGroups } from '../shared/shippingGroups'; +import { ticketJourney } from '../shared/ticketJourney'; + +/** + * A ticket page's journey: planned → built Nm → checked → shipping together + * → shipped. A ticket that shipped together with others keeps that step + * once its group has shipped. + */ + +const MINUTE = 60_000; +const T0 = Date.parse('2026-10-23T10:00:00.000Z'); + +const stage = ( + type: string, + minute: number, + name = 'build' +): TimelineEvent => ({ + type, + name, + ticket: '3', + attempt: 1, + pod: 'build-a1b2', + result: null, + at: T0 + minute * MINUTE, +}); + +const page = (status: 'building' | 'done'): TicketPage => ({ + projectId: 'pawnee', + ticket: { + id: '3', + title: 'Trail closures', + status, + parent: null, + labels: [], + url: null, + projectId: 'pawnee', + source: 'markdown', + description: "Hikers can't see which trails are closed.", + }, + summary: { state: 'none' }, + plannedIn: null, + verdict: 'COMPLETE', + events: [stage('stage.start', 1), stage('stage.end', 13)], +}); + +const add = { type: 'group.add', groupId: 'g1', ticket: '3', at: T0 }; + +describe("a ticket's journey", () => { + it('shows shipping together while its group is open', () => { + expect(ticketJourney(page('building'), shippingGroups([add]))).toEqual([ + 'planned', + 'built 12m', + 'checked', + 'shipping together', + ]); + }); + + it('keeps shipping together once the group has shipped', () => { + const shipped = shippingGroups([ + add, + { type: 'group.shipped', groupId: 'g1', at: T0 + 30 * MINUTE }, + ]); + + expect(ticketJourney(page('done'), shipped)).toEqual([ + 'planned', + 'built 12m', + 'checked', + 'shipping together', + 'shipped', + ]); + }); + + it("isn't built while its build is still running", () => { + const building = { ...page('building'), events: [stage('stage.start', 1)] }; + + expect(ticketJourney(building, shippingGroups([]))).toEqual([ + 'planned', + 'checked', + ]); + }); + + it('is built once a later step started, though its build never reported its end', () => { + const movedOn = { + ...page('building'), + events: [ + stage('stage.start', 1), + stage('stage.start', 13, 'self-review'), + ], + }; + + expect(ticketJourney(movedOn, shippingGroups([]))).toEqual([ + 'planned', + 'built 12m', + 'checked', + ]); + }); + + it('has no shipping together for a ticket that shipped alone', () => { + expect(ticketJourney(page('done'), shippingGroups([]))).toEqual([ + 'planned', + 'built 12m', + 'checked', + 'shipped', + ]); + }); +}); diff --git a/src/app/__tests__/ticketList.test.ts b/src/app/__tests__/ticketList.test.ts new file mode 100644 index 0000000..d7fdfb8 --- /dev/null +++ b/src/app/__tests__/ticketList.test.ts @@ -0,0 +1,220 @@ +import type { TicketListItem } from '../shared/building'; +import { type GroupEvent } from '../shared/shippingGroups'; +import { + type BoardOptions, + type FilterChip, + boardGroups, +} from '../shared/ticketBoard'; +import { + linkOf, + openGroupKeys, + openGroupKeysByProject, + sourceLabel, + talkToSend, +} from '../shared/ticketList'; + +/** The board by status, as the Tickets screen opens. */ +const listTickets = ( + items: TicketListItem[], + shippingTogether: Set<string>, + options: Partial<BoardOptions> = {} +) => + boardGroups(items, { + groupBy: 'status', + chips: [], + search: '', + shippingTogether, + projectName: (projectId) => projectId, + ...options, + }).map((group) => ({ + name: group.label, + tickets: group.rows.map((row) => row.item), + })); + +const chip = (field: FilterChip['field'], value: string): FilterChip => ({ + field, + value, +}); + +const ticket = ( + id: string, + title: string, + status: TicketListItem['status'], + extra: Partial<TicketListItem> = {} +): TicketListItem => ({ + id, + title, + status, + parent: null, + labels: [], + url: null, + projectId: 'pawnee', + source: 'markdown', + ...extra, +}); + +/** Eight tickets: one Linear, one Jira, the rest markdown. */ +const EIGHT: TicketListItem[] = [ + ticket('3', 'Trail closures', 'building', { parent: 'E1' }), + ticket('2', 'Ramsett Park map', 'ready', { parent: 'E1' }), + ticket('5', 'Lot 48 photos', 'ready', { + source: 'linear', + url: 'https://linear.app/pawnee/issue/PAW-5', + }), + ticket('6', 'Harvest festival banner', 'ready', { + source: 'jira', + url: 'https://pawnee.atlassian.net/browse/PAW-6', + }), + ticket('7', 'Pit cleanup', 'planning'), + ticket('8', 'Gazebo paint', 'blocked'), + ticket('1', 'Park hours page', 'done', { projectId: 'bakery' }), + ticket('9', 'Old mural', 'canceled'), +]; + +/** One pod-made entry with the same id as one of the eight. */ +const POD_MADE = ticket('7', 'Pit cleanup', 'planning', { + source: 'pod', + podId: 'pit-a1b2', +}); + +const G1: GroupEvent[] = [ + { type: 'group.add', groupId: 'g1', ticket: '2', at: 1 }, +]; + +const groupsOf = (chips: FilterChip[] = []) => + listTickets([...EIGHT, POD_MADE], openGroupKeys(G1, 'pawnee'), { + chips, + }).map((group) => [group.name, group.tickets.length]); + +describe('All tickets', () => { + it('groups by where each ticket is, as Linear orders them, Canceled hidden', () => { + expect(groupsOf()).toEqual([ + ['Building', 1], + ['Shipping together', 1], + ['Ready', 2], + ['Blocked', 1], + ['Planning', 1], + ['Shipped', 1], + ]); + }); + + it('lists a pod-made ticket the source also has once', () => { + const planning = listTickets( + [...EIGHT, POD_MADE], + openGroupKeys(G1, 'pawnee') + ).find((group) => group.name === 'Planning'); + + expect(planning?.tickets.map((one) => [one.id, one.source])).toEqual([ + ['7', 'markdown'], + ]); + }); + + it('carries Linear and Jira tickets’ source addresses, and none for markdown', () => { + const listed = listTickets( + [...EIGHT, POD_MADE], + openGroupKeys(G1, 'pawnee') + ).flatMap((group) => group.tickets); + const urlOf = (id: string) => listed.find((one) => one.id === id)?.url; + + expect(urlOf('5')).toBe('https://linear.app/pawnee/issue/PAW-5'); + expect(urlOf('6')).toBe('https://pawnee.atlassian.net/browse/PAW-6'); + expect(urlOf('3')).toBeNull(); + }); + + it('names a link out by its own source, and no other', () => { + expect(sourceLabel('linear')).toBe('Linear'); + expect(sourceLabel('jira')).toBe('Jira'); + expect(sourceLabel('markdown')).toBeNull(); + expect(sourceLabel('pod')).toBeNull(); + }); + + it('links a ticket out only to an http or https address', () => { + expect(linkOf('https://linear.app/pawnee/issue/PAWNEE-5')).toBe( + 'https://linear.app/pawnee/issue/PAWNEE-5' + ); + expect(linkOf('javascript:alert(1)')).toBeNull(); + expect(linkOf('not a url')).toBeNull(); + expect(linkOf(null)).toBeNull(); + }); + + it('shows Canceled tickets in their own group when the Status filter picks it', () => { + expect(groupsOf([chip('status', 'canceled')])).toEqual([['Canceled', 1]]); + }); + + it('searches titles, ignoring case', () => { + expect( + listTickets(EIGHT, new Set(), { search: 'CLOSU' }).flatMap((group) => + group.tickets.map((one) => one.title) + ) + ).toEqual(['Trail closures']); + }); + + it('filters by epic and by codebase', () => { + const ids = (chips: FilterChip[]) => + listTickets(EIGHT, new Set(), { chips }).flatMap((group) => + group.tickets.map((one) => one.id) + ); + + expect(ids([chip('epic', 'E1')])).toEqual(['3', '2']); + expect(ids([chip('codebase', 'bakery')])).toEqual(['1']); + }); + + it('keeps a ticket in a shipped group out of Shipping together', () => { + const shipped: GroupEvent[] = [ + ...G1, + { type: 'group.shipped', groupId: 'g1', at: 2 }, + ]; + + expect( + listTickets(EIGHT, openGroupKeys(shipped, 'pawnee')).find( + (group) => group.name === 'Shipping together' + ) + ).toBeUndefined(); + }); +}); + +describe('Shipping together across projects', () => { + // Two projects each have a ticket "3" and a group "g1". + const events = [ + { + type: 'group.add', + groupId: 'g1', + ticket: '3', + at: 1, + projectId: 'pawnee', + }, + { + type: 'group.add', + groupId: 'g1', + ticket: '4', + at: 1, + projectId: 'bakery', + }, + { type: 'group.shipped', groupId: 'g1', at: 2, projectId: 'bakery' }, + ]; + const tickets = [ + ticket('3', 'Trail closures', 'building'), + ticket('3', 'Croissant menu', 'building', { projectId: 'bakery' }), + ]; + + it("keeps one project's group to that project's tickets", () => { + const groups = listTickets(tickets, openGroupKeysByProject(events)); + const titlesIn = (name: string) => + groups + .find((group) => group.name === name) + ?.tickets.map((one) => one.title) ?? []; + + // Bakery's g1 shipping doesn't close Pawnee's g1, and Pawnee's "3" + // doesn't pull Bakery's "3" in with it. + expect(titlesIn('Shipping together')).toEqual(['Trail closures']); + expect(titlesIn('Building')).toEqual(['Croissant menu']); + }); +}); + +describe('Talk about this ticket', () => { + it('sends the trimmed text once: nothing while a send is in flight', () => { + expect(talkToSend(' move the pin ', false)).toBe('move the pin'); + expect(talkToSend('move the pin', true)).toBeNull(); + expect(talkToSend(' ', false)).toBeNull(); + }); +}); diff --git a/src/app/__tests__/ticketSummary.test.ts b/src/app/__tests__/ticketSummary.test.ts new file mode 100644 index 0000000..04d4db4 --- /dev/null +++ b/src/app/__tests__/ticketSummary.test.ts @@ -0,0 +1,542 @@ +import * as crypto from 'crypto'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { oneShotLaunch } from '../../pod/launch'; +import { + SUMMARY_TIMEOUT_MS, + type SummaryRun, + answerIn, + claudeSummaryRun, + createTicketSummaries, + summarySource, +} from '../server/ticketSummary'; + +/** + * A ticket page's plain summary: from the checkpoint that planned it, else + * written once by a one-shot Product manager run and stored, rewritten when + * the description changes. + */ + +const TICKET = { + id: '3', + title: 'Trail closures', + description: "Hikers can't see which trails are closed.", +}; + +const ANSWER = JSON.stringify({ + today: "Hikers can't see which trails are closed.", + after: "Each park page shows today's closed trails.", +}); + +const PAWNEE = '/tmp/pawnee'; + +const ignore = (): void => undefined; + +const sha256 = (text: string) => + crypto.createHash('sha256').update(text).digest('hex'); + +/** A run that waits until the test settles it. */ +const controlled = () => { + const calls: { + ticket: { id: string }; + settle: (result: { + exitCode: number; + stdout: string; + stderr?: string; + }) => void; + signal: AbortSignal; + }[] = []; + const run: SummaryRun = (ticket, signal) => + new Promise((resolve) => { + calls.splice(calls.length, 0, { ticket, settle: resolve, signal }); + }); + + return { run, calls }; +}; + +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('ticket summaries', () => { + const dirs: string[] = []; + const dir = () => { + const made = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-summaries-')); + dirs.splice(dirs.length, 0, made); + + return made; + }; + + afterEach(() => { + jest.useRealTimers(); + dirs + .splice(0) + .forEach((made) => fs.rmSync(made, { recursive: true, force: true })); + }); + + it("uses a planned ticket's checkpoint name, today and after, running nothing", () => { + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + + expect( + summaries.stateOf(TICKET, { + name: '3 - Trail closures', + today: 'Today.', + after: 'After.', + podId: 'pickup-a1b2', + }) + ).toEqual({ + state: 'ready', + name: '3 - Trail closures', + today: 'Today.', + after: 'After.', + }); + expect(calls).toEqual([]); + }); + + it('writes an unplanned one once, stores it with the description’s hash, then reads it back', async () => { + const root = dir(); + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root, + project: PAWNEE, + run, + log: ignore, + }); + + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'writing' }); + calls[0]?.settle({ exitCode: 0, stdout: `Here it is:\n${ANSWER}\n` }); + await flush(); + + expect(summaries.stateOf(TICKET, null)).toEqual({ + state: 'ready', + name: 'Trail closures', + today: "Hikers can't see which trails are closed.", + after: "Each park page shows today's closed trails.", + }); + expect(JSON.parse(fs.readFileSync(summaries.file('3'), 'utf8'))).toEqual({ + ticketId: '3', + name: 'Trail closures', + today: "Hikers can't see which trails are closed.", + after: "Each park page shows today's closed trails.", + sourceHash: sha256(TICKET.description), + by: 'product-manager', + }); + expect(calls).toHaveLength(1); + }); + + it('rewrites a stored summary once the description changes', async () => { + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + summaries.stateOf(TICKET, null); + calls[0]?.settle({ exitCode: 0, stdout: ANSWER }); + await flush(); + + expect( + summaries.stateOf({ ...TICKET, description: 'Now with Lot 48.' }, null) + ).toEqual({ state: 'writing' }); + expect(calls).toHaveLength(2); + }); + + it('starts one run for two opens at once', () => { + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'writing' }); + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'writing' }); + expect(calls).toHaveLength(1); + }); + + it.each([ + ['exits 1', { exitCode: 1, stdout: ANSWER }], + [ + 'returns no after', + { exitCode: 0, stdout: JSON.stringify({ today: 'Today.' }) }, + ], + ['returns no JSON', { exitCode: 0, stdout: 'I could not read it.' }], + ])( + 'stores nothing when the run %s, says "No summary yet.", and tries again next open', + async (_label, result) => { + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + summaries.stateOf(TICKET, null); + calls[0]?.settle(result); + await flush(); + + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'none' }); + expect(fs.existsSync(summaries.file('3'))).toBe(false); + // The open page re-reads it: still "No summary yet.", and no new run. + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'none' }); + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'none' }); + expect(calls).toHaveLength(1); + // Opened again: one new try. + expect(summaries.stateOf(TICKET, null, { opened: true })).toEqual({ + state: 'writing', + }); + expect(calls).toHaveLength(2); + } + ); + + it('tries again on a refresh once a failed ticket’s description changes', async () => { + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + summaries.stateOf(TICKET, null); + calls[0]?.settle({ exitCode: 1, stdout: '' }); + await flush(); + const changed = { + ...TICKET, + description: 'Hikers can’t see when Lot 48 closes.', + }; + + expect(summaries.stateOf(changed, null)).toEqual({ state: 'writing' }); + expect(calls).toHaveLength(2); + }); + + it('says "No summary yet." and logs why when the summary cannot be stored', async () => { + const { run, calls } = controlled(); + const logged: string[] = []; + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: (line) => logged.splice(logged.length, 0, line), + write: () => { + throw new Error('ENOSPC: no space left on device'); + }, + }); + summaries.stateOf(TICKET, null); + calls[0]?.settle({ exitCode: 0, stdout: ANSWER }); + await flush(); + + expect(logged).toEqual([ + 'No summary for 3: ENOSPC: no space left on device', + ]); + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'none' }); + expect(calls).toHaveLength(1); + }); + + it('gives up on a run past 2 minutes, storing nothing', async () => { + jest.useFakeTimers(); + const { run, calls } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + summaries.stateOf(TICKET, null); + + jest.advanceTimersByTime(SUMMARY_TIMEOUT_MS + 1); + await flush(); + + expect(SUMMARY_TIMEOUT_MS).toBe(2 * 60_000); + expect(calls[0]?.signal.aborted).toBe(true); + expect(summaries.stateOf(TICKET, null)).toEqual({ state: 'none' }); + + // A late answer from the run it gave up on is not stored. + calls[0]?.settle({ exitCode: 0, stdout: ANSWER }); + await flush(); + + expect(fs.existsSync(summaries.file('3'))).toBe(false); + // The next open tries again. + expect(summaries.stateOf(TICKET, null, { opened: true })).toEqual({ + state: 'writing', + }); + expect(calls).toHaveLength(2); + }); + + it("logs the run's own last words and exit code when it fails", async () => { + const { run, calls } = controlled(); + const logged: string[] = []; + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: (line) => logged.splice(logged.length, 0, line), + }); + summaries.stateOf(TICKET, null); + calls[0]?.settle({ + exitCode: 1, + stdout: '', + stderr: `${'x'.repeat(5000)}\nInvalid API key. Run /login.\n`, + }); + await flush(); + + expect(logged).toHaveLength(1); + expect(logged[0]).toMatch( + /^No summary for 3: it exited 1: x+\nInvalid API key\. Run \/login\.$/ + ); + // Only the tail: a run that printed pages doesn't flood the log. + expect(logged[0]?.length).toBeLessThan(1000); + }); + + it("keeps two projects' summaries of the same ticket id apart under one pods root", async () => { + const root = dir(); + const { run, calls } = controlled(); + const pawnee = createTicketSummaries({ + root, + project: PAWNEE, + run, + log: ignore, + }); + const eagleton = createTicketSummaries({ + root, + project: '/tmp/eagleton', + run, + log: ignore, + }); + pawnee.stateOf(TICKET, null); + calls[0]?.settle({ exitCode: 0, stdout: ANSWER }); + await flush(); + + expect(eagleton.file('3')).not.toBe(pawnee.file('3')); + // Eagleton's ticket "3" with the same description is not Pawnee's. + expect(eagleton.stateOf(TICKET, null)).toEqual({ state: 'writing' }); + expect(calls).toHaveLength(2); + }); + + it('refuses a ticket id that would leave the summaries folder', () => { + const { run } = controlled(); + const summaries = createTicketSummaries({ + root: dir(), + project: PAWNEE, + run, + log: ignore, + }); + + expect(() => summaries.file('../../lambda')).toThrow( + `"../../lambda" isn't a ticket id` + ); + }); +}); + +describe('a ticket whose description changes while its summary is written', () => { + it('stops the run for the old description', () => { + const { run, calls } = controlled(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-summaries-')); + const summaries = createTicketSummaries({ + root, + project: PAWNEE, + run, + log: ignore, + }); + + summaries.stateOf(TICKET, null); + summaries.stateOf( + { ...TICKET, description: 'Now with the ridge loop.' }, + null + ); + + expect(calls).toHaveLength(2); + expect(calls[0]?.signal.aborted).toBe(true); + expect(calls[1]?.signal.aborted).toBe(false); + fs.rmSync(root, { recursive: true, force: true }); + }); +}); + +describe("the run's answer", () => { + it('reads past thousands of braces that never close, quickly and without a stack overflow', () => { + const started = Date.now(); + + expect(answerIn(`${'{'.repeat(12_000)}\n${ANSWER}\n`)).toEqual( + JSON.parse(ANSWER) + ); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it('reads past thousands of {placeholder} spans, quickly', () => { + const started = Date.now(); + + expect(answerIn(`${'{spec} '.repeat(1_500)}\n${ANSWER}\n`)).toEqual( + JSON.parse(ANSWER) + ); + expect(Date.now() - started).toBeLessThan(1000); + }, 30_000); + + it('reads an answer whose text has braces in it', () => { + expect( + answerIn( + 'Done.\n{"today":"x","after":"The {spec} placeholder is filled in"}\n' + ) + ).toEqual({ today: 'x', after: 'The {spec} placeholder is filled in' }); + }); +}); + +describe("a ticket's summary source", () => { + const planned = { + podId: 'pickup-c3d4', + name: '3 - Trail closures', + today: "Hikers can't see which trails are closed.", + after: "Each park page shows today's closed trails.", + }; + const stored = { + ticketId: '3', + name: 'Trail closures', + today: 'Before.', + after: 'After.', + sourceHash: sha256(TICKET.description), + by: 'product-manager' as const, + }; + + it('is the checkpoint for a planned ticket', () => { + expect(summarySource(TICKET, planned, null)).toEqual({ + kind: 'planned', + name: planned.name, + today: planned.today, + after: planned.after, + }); + }); + + it('is "needs summary" for an unplanned one with nothing stored', () => { + expect(summarySource(TICKET, null, null)).toEqual({ + kind: 'needs-summary', + hash: sha256(TICKET.description), + }); + }); + + it('is the stored one while the description is the same', () => { + expect(summarySource(TICKET, null, stored)).toEqual({ + kind: 'stored', + name: 'Trail closures', + today: 'Before.', + after: 'After.', + }); + }); + + it('is "needs summary" again once the description changes (a new SHA-256)', () => { + const changed = { + ...TICKET, + description: 'Hikers see a banner at closing.', + }; + + expect(summarySource(changed, null, stored)).toEqual({ + kind: 'needs-summary', + hash: sha256('Hikers see a banner at closing.'), + }); + }); +}); + +describe("the summary writer's Claude run", () => { + it('starts through the pod launcher, read-only, with nothing of its own', async () => { + const spawned: { command: string; args: string[]; env?: unknown }[] = []; + const run = claudeSummaryRun({ + cwd: '/tmp/pawnee', + instructionsFile: '/lambda/instructions/building/product-manager.md', + settingsFile: '/tmp/pods/_product-manager/settings.json', + spawn: (command, args, options) => { + spawned.splice(spawned.length, 0, { command, args, env: options.env }); + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + setImmediate(() => { + child.stdout.emit('data', Buffer.from(ANSWER)); + child.emit('close', 0); + }); + + return child; + }, + }); + + const result = await run(TICKET, new AbortController().signal); + const launch = oneShotLaunch({ + prompt: spawned[0]?.args[1] ?? '', + settingsFile: '/tmp/pods/_product-manager/settings.json', + }); + + expect(result).toEqual({ exitCode: 0, stdout: ANSWER, stderr: '' }); + expect(spawned).toEqual([ + { command: launch.command, args: launch.args, env: launch.env }, + ]); + expect(launch.args).toEqual( + expect.arrayContaining([ + '-p', + '--settings', + '/tmp/pods/_product-manager/settings.json', + '--strict-mcp-config', + ]) + ); + expect(launch.args).not.toContain('auto'); + }); + + it('answers the tail of what it printed to stderr when it fails', async () => { + const run = claudeSummaryRun({ + cwd: '/tmp/pawnee', + instructionsFile: '/lambda/instructions/building/product-manager.md', + settingsFile: '/tmp/pods/_product-manager/settings.json', + spawn: () => { + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + setImmediate(() => { + child.stderr.emit('data', Buffer.from('Invalid API key.\n')); + child.emit('close', 1); + }); + + return child; + }, + }); + + await expect(run(TICKET, new AbortController().signal)).resolves.toEqual({ + exitCode: 1, + stdout: '', + stderr: 'Invalid API key.', + }); + }); + + it("names why it couldn't start", async () => { + const run = claudeSummaryRun({ + cwd: '/tmp/pawnee', + instructionsFile: '/lambda/instructions/building/product-manager.md', + settingsFile: '/tmp/pods/_product-manager/settings.json', + spawn: () => { + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + setImmediate(() => + child.emit('error', new Error('spawn claude ENOENT')) + ); + + return child; + }, + }); + + await expect(run(TICKET, new AbortController().signal)).resolves.toEqual({ + exitCode: 1, + stdout: '', + stderr: "couldn't start: spawn claude ENOENT", + }); + }); +}); diff --git a/src/app/__tests__/timeLog.test.ts b/src/app/__tests__/timeLog.test.ts new file mode 100644 index 0000000..2f64b5e --- /dev/null +++ b/src/app/__tests__/timeLog.test.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { addTime, timeSummary } from '../server/timeLog'; + +const file = (): string => + path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pods-time-')), '_time.json'); + +describe('time log', () => { + it("splits a card's time by the version on screen", () => { + const log = file(); + + addTime( + log, + [ + { podId: 'pawnee-a1b2', qid: 'q4', version: 1, ms: 20_000 }, + // The Plan rail was closed: the card's time, but no version's. + { podId: 'pawnee-a1b2', qid: 'q4', ms: 5_000 }, + ], + '2026-10-17' + ); + addTime( + log, + [{ podId: 'pawnee-a1b2', qid: 'q4', version: 2, ms: 7_000 }], + '2026-10-17' + ); + + expect(timeSummary(log, '2026-10-17').pods['pawnee-a1b2']).toEqual({ + totalMs: 32_000, + items: { q4: 32_000 }, + versions: { q4: { 1: 20_000, 2: 7_000 } }, + cards: {}, + }); + }); + + it('keeps time per card inside a checkpoint, per version', () => { + const log = file(); + const card = 'screen:Bench list'; + + addTime( + log, + [ + { podId: 'pawnee-a1b2', qid: 'q4', version: 1, card, ms: 9_000 }, + { podId: 'pawnee-a1b2', qid: 'q4', version: 2, card, ms: 4_000 }, + // A card key that can't be a title is dropped, not the time. + { + podId: 'pawnee-a1b2', + qid: 'q4', + version: 2, + card: 'x'.repeat(301), + ms: 1_000, + }, + ], + '2026-10-17' + ); + + const pod = timeSummary(log, '2026-10-17').pods['pawnee-a1b2']; + expect(pod?.cards).toEqual({ q4: { [card]: { 1: 9_000, 2: 4_000 } } }); + expect(pod?.items).toEqual({ q4: 14_000 }); + }); + + it('adds active time per day, per pod and per item', () => { + const log = file(); + + addTime( + log, + [ + { podId: 'pawnee-a1b2', qid: 'q1', ms: 15_000 }, + { podId: 'pawnee-a1b2', ms: 5_000 }, + { ms: 2_000 }, + ], + '2026-10-04' + ); + addTime( + log, + [{ podId: 'pawnee-a1b2', qid: 'q1', ms: 10_000 }], + '2026-10-04' + ); + addTime( + log, + [{ podId: 'pawnee-a1b2', qid: 'q2', ms: 1_000 }], + '2026-10-05' + ); + + expect(timeSummary(log, '2026-10-04')).toEqual({ + day: '2026-10-04', + todayMs: 32_000, + days: { '2026-10-04': 32_000, '2026-10-05': 1_000 }, + pods: { + 'pawnee-a1b2': { + totalMs: 31_000, + items: { q1: 25_000, q2: 1_000 }, + versions: {}, + cards: {}, + }, + }, + }); + expect(timeSummary(log, '2026-10-05').todayMs).toBe(1_000); + }); + + it('ignores nonsense and caps a single report', () => { + const log = file(); + + addTime(log, [{ ms: -5 }, { ms: 0 }, { ms: 60 * 60_000 }], '2026-10-04'); + + expect(timeSummary(log, '2026-10-04').todayMs).toBe(5 * 60_000); + }); + + it('credits one batch 5 minutes at most, however many entries it has', () => { + const log = file(); + + addTime( + log, + Array.from({ length: 1000 }, () => ({ ms: 5 * 60_000 })), + '2026-10-04' + ); + + expect(timeSummary(log, '2026-10-04').todayMs).toBe(5 * 60_000); + }); + + it('keeps only real pod and item ids, so the file can’t grow a key per guess', () => { + const log = file(); + + addTime( + log, + [ + { podId: '../etc', ms: 1_000 }, + { podId: 'dog-park', qid: 'q'.repeat(500), ms: 1_000 }, + { podId: 'dog-park', qid: 'q1', ms: 2_000 }, + ], + '2026-10-04' + ); + const { pods, todayMs } = timeSummary(log, '2026-10-04'); + + expect(Object.keys(pods)).toEqual(['dog-park']); + expect(pods['dog-park']?.items).toEqual({ q1: 2_000 }); + expect(todayMs).toBe(2_000); + }); + + it('moves an unreadable log aside, and says so, instead of writing over it', () => { + const log = file(); + fs.writeFileSync(log, '{"days": {"2026-10-03": 3600000}, "pods": {'); + const moved: { to: string[] } = { to: [] }; + + addTime(log, [{ ms: 1_000 }], '2026-10-04', (to) => { + moved.to = [...moved.to, to]; + }); + + expect(moved.to).toHaveLength(1); + expect(fs.readFileSync(moved.to[0] ?? '', 'utf8')).toBe( + '{"days": {"2026-10-03": 3600000}, "pods": {' + ); + expect(timeSummary(log, '2026-10-04').todayMs).toBe(1_000); + }); + + it('starts empty without a file', () => { + expect(timeSummary(file(), '2026-10-04')).toEqual({ + day: '2026-10-04', + todayMs: 0, + days: {}, + pods: {}, + }); + }); +}); diff --git a/src/app/__tests__/tts.test.ts b/src/app/__tests__/tts.test.ts new file mode 100644 index 0000000..964174d --- /dev/null +++ b/src/app/__tests__/tts.test.ts @@ -0,0 +1,624 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { formatTime, sectionIndexAt } from '../client/timeline'; +import { createBudget } from '../server/tts/budget'; +import { type Rendered } from '../server/tts/elevenlabs'; +import { MAX_CHUNK_CHARS, chunkForKokoro } from '../server/tts/kokoro'; +import { comparePriority } from '../server/tts/pieceQueue'; +import { createRenderer } from '../server/tts/renderer'; +import { audioHash, parseRange, sectionTimes } from '../server/tts/timing'; +import { buildScript } from '../shared/speech'; + +const script = buildScript([ + { key: 'summary', label: 'Summary', text: 'Closed trails get their own line.' }, + { + key: 'context', + label: 'Context', + text: 'Ron Swanson closed the ridge loop on October fifth.', + }, +]); + +/** One section's clip, as `sectionSpeech` produces it. */ +const clipText = 'Summary. Closed trails get their own line.'; + +/** A fake ElevenLabs alignment: every character takes 0.05 s. */ +const fakeAlignment = (text: string) => ({ + starts: Array.from(text, (_char, index) => index * 0.05), + ends: Array.from(text, (_char, index) => (index + 1) * 0.05), +}); + +const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'pods-tts-')); + +describe('buildScript', () => { + it('joins labelled sections and records exact character offsets', () => { + expect(script.text).toBe( + 'Summary. Closed trails get their own line.\n\nContext. Ron Swanson closed the ridge loop on October fifth.' + ); + const [first, second] = script.sections; + + expect(script.text.slice(first?.start, first?.end)).toBe( + 'Summary. Closed trails get their own line.' + ); + expect(script.text.slice(second?.start, second?.end)).toBe( + 'Context. Ron Swanson closed the ridge loop on October fifth.' + ); + }); +}); + +describe('sectionTimes', () => { + it('places each section at the timestamp of its first character', () => { + const { sections, duration } = sectionTimes( + script, + fakeAlignment(script.text) + ); + + expect(sections[0]?.start).toBe(0); + expect(sections[1]?.start).toBeCloseTo( + (script.sections[1]?.start ?? 0) * 0.05 + ); + expect(sections[0]?.end).toBe(sections[1]?.start); + expect(sections[1]?.end).toBe(duration); + expect(duration).toBeCloseTo(script.text.length * 0.05); + }); + + it('maps proportionally when the alignment length differs from the script', () => { + const half = fakeAlignment( + script.text.slice(0, Math.floor(script.text.length / 2)) + ); + const { sections } = sectionTimes(script, half); + + expect(sections[1]?.start).toBeGreaterThan(0); + expect(sections[1]?.start).toBeLessThanOrEqual(Math.max(...half.ends)); + }); +}); + +describe('audioHash', () => { + it('is stable for the same voice/model/text and changes with any of them', () => { + const base = audioHash('rachel', 'flash', 'Hello Leslie'); + + expect(audioHash('rachel', 'flash', 'Hello Leslie')).toBe(base); + expect(audioHash('rachel', 'flash', 'Hello Ron')).not.toBe(base); + expect(audioHash('other', 'flash', 'Hello Leslie')).not.toBe(base); + expect(base).toMatch(/^[a-f0-9]{24}$/); + }); +}); + +describe('parseRange', () => { + it('handles the forms browsers send when seeking', () => { + expect(parseRange(undefined, 1000)).toBeNull(); + expect(parseRange('bytes=0-', 1000)).toEqual({ start: 0, end: 999 }); + expect(parseRange('bytes=100-199', 1000)).toEqual({ start: 100, end: 199 }); + expect(parseRange('bytes=-200', 1000)).toEqual({ start: 800, end: 999 }); + expect(parseRange('bytes=900-5000', 1000)).toEqual({ + start: 900, + end: 999, + }); + expect(parseRange('bytes=2000-', 1000)).toBe('unsatisfiable'); + }); +}); + +describe('budget', () => { + it('accumulates, blocks past the cap, and resets in a new month', () => { + const file = path.join(tmp(), 'usage.json'); + const clock = { now: new Date('2026-10-03T12:00:00Z') }; + const budget = createBudget({ + file, + usdPer1kChars: 1, + capUsd: 10, + now: () => clock.now, + }); + + expect(budget.allows(9_000)).toBe(true); + budget.record(9_000); + expect(budget.status().usd).toBeCloseTo(9); + expect(budget.allows(2_000)).toBe(false); + expect(budget.allows(1_000)).toBe(true); + budget.record(1_000); + expect(budget.status().overBudget).toBe(true); + + clock.now = new Date('2026-11-01T00:00:01Z'); + expect(budget.status()).toMatchObject({ + month: '2026-11', + usd: 0, + overBudget: false, + }); + expect(budget.allows(9_000)).toBe(true); + }); +}); + +describe('budget file', () => { + it('is written in a folder that does not exist yet (~/.lambda on a new machine)', () => { + const file = path.join(tmp(), 'not-yet', 'tts-usage.json'); + const budget = createBudget({ file, usdPer1kChars: 1, capUsd: 10 }); + + budget.record(1_000); + + expect(budget.status().usd).toBeCloseTo(1); + }); + + it('refuses to spend when its file is unreadable, rather than starting at $0', () => { + const file = path.join(tmp(), 'tts-usage.json'); + fs.writeFileSync(file, '{"month": "2026-10", "characters": 90'); + const budget = createBudget({ + file, + usdPer1kChars: 1, + capUsd: 10, + now: () => new Date('2026-10-03T12:00:00Z'), + }); + + expect(budget.allows(1)).toBe(false); + // What every pod snapshot shows: over the cap and why, not a crash. + expect(budget.status()).toMatchObject({ + usd: 0, + overBudget: true, + problem: `The audio budget in ${file} can't be read, so nothing is spent until it's fixed or deleted.`, + }); + }); +}); + +describe('chunkForKokoro', () => { + const sentence = + 'Ron Swanson builds the bench, and Leslie Knope paints the mural.'; + + it('keeps every word, in order, with no chunk over the limit', () => { + const text = Array.from({ length: 20 }, () => sentence).join(' '); + const chunks = chunkForKokoro(text); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= MAX_CHUNK_CHARS)).toBe(true); + expect(chunks.join(' ')).toBe(text); + }); + + it('packs whole sentences, and breaks only an over-long one', () => { + const long = Array.from({ length: 12 }, () => 'a pause, then more').join( + ' ' + ); + const chunks = chunkForKokoro(`Short one. ${long}.`, 60); + + expect(chunks[0]).toBe('Short one.'); + expect(chunks.every((chunk) => chunk.length <= 60)).toBe(true); + expect(chunks.slice(1).join(' ')).toBe(`${long}.`); + }); + + it('leaves a short section whole', () => { + expect(chunkForKokoro(sentence)).toEqual([sentence]); + }); +}); + +describe('renderer', () => { + const setup = ( + render: (text: string) => Promise<Rendered>, + capUsd = 100, + cancel?: (text: string) => void + ) => { + const root = tmp(); + const scheduled: (() => void)[] = []; + const renderer = createRenderer({ + audioDir: (podId) => path.join(root, podId, 'audio'), + render, + cancel, + budget: createBudget({ + file: path.join(root, 'usage.json'), + usdPer1kChars: 1, + capUsd, + }), + enabled: () => true, + voiceId: 'rachel', + modelId: 'flash', + retryDelaysMs: [1, 1], + settleMs: 0, + schedule: (fn) => { + scheduled.splice(scheduled.length, 0, fn); + }, + }); + + return { renderer, scheduled }; + }; + + const flush = (): Promise<void> => + new Promise((resolve) => setImmediate(resolve)); + + it('waits for text to stop changing before paying for a render', async () => { + const root = tmp(); + const clock = { now: 0 }; + const calls: { texts: string[] } = { texts: [] }; + const renderer = createRenderer({ + audioDir: (podId) => path.join(root, podId, 'audio'), + render: async (text) => { + calls.texts.splice(calls.texts.length, 0, text); + + return { audio: Buffer.from('mp3'), alignment: fakeAlignment(text) }; + }, + budget: createBudget({ + file: path.join(root, 'usage.json'), + usdPer1kChars: 1, + capUsd: 100, + }), + enabled: () => true, + voiceId: 'rachel', + modelId: 'flash', + settleMs: 15_000, + now: () => clock.now, + schedule: () => undefined, + }); + const pod = 'pawnee-a1b2'; + + // A dev mid-edit: each version is seen once, then replaced. + renderer.prefetch([{ podId: pod, text: 'Summary. Half written.' }]); + clock.now = 5_000; + renderer.prefetch([{ podId: pod, text: clipText }]); + clock.now = 10_000; + renderer.prefetch([{ podId: pod, text: clipText }]); + await flush(); + expect(calls.texts).toEqual([]); + + // Unchanged for 15 s: now it renders — only the final text. + clock.now = 21_000; + renderer.prefetch([{ podId: pod, text: clipText }]); + await flush(); + expect(calls.texts).toEqual([clipText]); + }); + + it('renders a clip, stores it, and reports its duration', async () => { + const { renderer } = setup(async (text) => ({ + audio: Buffer.from('mp3'), + alignment: fakeAlignment(text), + })); + + expect(renderer.clip('pawnee-a1b2', clipText)).toEqual({ status: 'none' }); + renderer.prefetch([{ podId: 'pawnee-a1b2', text: clipText }]); + expect(renderer.clip('pawnee-a1b2', clipText).status).toBe('pending'); + await flush(); + + expect(renderer.clip('pawnee-a1b2', clipText)).toMatchObject({ + status: 'ready', + duration: clipText.length * 0.05, + }); + }); + + it('uses a directly reported duration when there is no alignment (Kokoro)', async () => { + const { renderer } = setup(async () => ({ + audio: Buffer.from('mp3'), + alignment: { starts: [], ends: [] }, + duration: 4.25, + })); + + await renderer.renderNow('pawnee-a1b2', clipText); + + expect(renderer.clip('pawnee-a1b2', clipText)).toMatchObject({ + status: 'ready', + duration: 4.25, + }); + }); + + it('renders on demand at once — and never pays twice for the same text', async () => { + const calls = { count: 0 }; + const { renderer } = setup(async (text) => { + calls.count += 1; + + return { audio: Buffer.from('mp3'), alignment: fakeAlignment(text) }; + }); + + // Listen to it, listen to something else, jump back: one render. + const first = await renderer.renderNow('pawnee-a1b2', clipText); + await renderer.renderNow('pawnee-a1b2', 'Context. Something else.'); + const again = await renderer.renderNow('pawnee-a1b2', clipText); + renderer.prefetch([{ podId: 'pawnee-a1b2', text: clipText }]); + await flush(); + + expect(first.status).toBe('ready'); + expect(again).toEqual(first); + expect(calls.count).toBe(2); + }); + + it('never renders (or pays) again because recording the spend failed', async () => { + const root = tmp(); + const calls = { count: 0 }; + const events: Record<string, unknown>[] = []; + const budget = createBudget({ + file: path.join(root, 'usage.json'), + usdPer1kChars: 1, + capUsd: 100, + }); + const scheduled: (() => void)[] = []; + const renderer = createRenderer({ + audioDir: (podId) => path.join(root, podId, 'audio'), + render: async (text) => { + calls.count += 1; + + return { audio: Buffer.from('mp3'), alignment: fakeAlignment(text) }; + }, + budget: { + ...budget, + record: () => { + throw new Error('EACCES: permission denied'); + }, + }, + enabled: () => true, + voiceId: 'rachel', + modelId: 'flash', + retryDelaysMs: [1, 1], + settleMs: 0, + schedule: (fn) => { + scheduled.splice(scheduled.length, 0, fn); + }, + log: (event) => { + events.splice(events.length, 0, event); + }, + }); + + renderer.prefetch([{ podId: 'pawnee-a1b2', text: clipText }]); + await flush(); + scheduled.splice(0).forEach((fn) => fn()); + await flush(); + + expect(calls.count).toBe(1); + expect(renderer.clip('pawnee-a1b2', clipText).status).toBe('ready'); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'budget-not-recorded', + reason: 'EACCES: permission denied', + }) + ); + }); + + it('joins a render already in flight instead of starting another', async () => { + const calls = { count: 0 }; + const { renderer } = setup(async (text) => { + calls.count += 1; + + return { audio: Buffer.from('mp3'), alignment: fakeAlignment(text) }; + }); + + const [a, b] = await Promise.all([ + renderer.renderNow('pawnee-a1b2', clipText), + renderer.renderNow('pawnee-a1b2', clipText), + ]); + + expect(a.status).toBe('ready'); + expect(b.status).toBe('ready'); + expect(calls.count).toBe(1); + }); + + it('logs each render and how long the player waited (.lambda/pods/_tts.log)', async () => { + const root = tmp(); + const clock = { now: 1_000 }; + const events: Record<string, unknown>[] = []; + const renderer = createRenderer({ + audioDir: (podId) => path.join(root, podId, 'audio'), + render: async (text) => { + clock.now += 4_000; + + return { + audio: Buffer.from('mp3'), + alignment: { starts: [], ends: [] }, + duration: 8, + }; + }, + budget: createBudget({ + file: path.join(root, 'usage.json'), + usdPer1kChars: 1, + capUsd: 100, + }), + enabled: () => true, + voiceId: 'rachel', + modelId: 'flash', + settleMs: 0, + now: () => clock.now, + schedule: () => undefined, + log: (event) => { + events.splice(events.length, 0, event); + }, + }); + + await renderer.renderNow('pawnee-a1b2', clipText, 'now'); + + expect(events).toEqual([ + expect.objectContaining({ + event: 'rendered', + podId: 'pawnee-a1b2', + chars: clipText.length, + queuedMs: 0, + renderMs: 4_000, + audioSec: 8, + speed: 2, + }), + expect.objectContaining({ + event: 'player-wait', + need: 'now', + waitedMs: 4_000, + joined: false, + status: 'ready', + }), + ]); + }); + + describe('priority — what Kokoro renders next', () => { + const never = (): Promise<Rendered> => new Promise(() => undefined); + + it('the section they are waiting on beats look-ahead, and the newest wait wins', () => { + const { renderer } = setup(never); + renderer.setFocus('pawnee-a1b2/q/q7'); + + renderer + .renderNow('pawnee-a1b2', 'Section three.', 'now') + .catch(() => undefined); + renderer + .renderNow('pawnee-a1b2', 'Section four.', 'soon') + .catch(() => undefined); + // They jump to section twelve. + renderer + .renderNow('pawnee-a1b2', 'Section twelve.', 'now') + .catch(() => undefined); + + const ranked = ['Section three.', 'Section four.', 'Section twelve.'] + .map((text) => ({ text, priority: renderer.priorityOf(text) })) + .sort((a, b) => comparePriority(a.priority, b.priority)) + .map(({ text }) => text); + + expect(ranked).toEqual([ + 'Section twelve.', + 'Section three.', + 'Section four.', + ]); + }); + + it('a look-ahead request never downgrades a clip already wanted now', () => { + const { renderer } = setup(never); + renderer.setFocus('pawnee-a1b2/q/q7'); + + renderer + .renderNow('pawnee-a1b2', 'Section three.', 'now') + .catch(() => undefined); + renderer + .renderNow('pawnee-a1b2', 'Section three.', 'soon') + .catch(() => undefined); + + expect(renderer.priorityOf('Section three.')[0]).toBe(0); + }); + + it('clicking away demotes what the player asked for to the background order', () => { + const { renderer } = setup(never); + renderer.setFocus('pawnee-a1b2/q/q7'); + renderer.prefetch([ + { podId: 'pawnee-a1b2', text: 'Eagleton opening.' }, + { podId: 'pawnee-a1b2', text: 'Section three.' }, + ]); + renderer + .renderNow('pawnee-a1b2', 'Section three.', 'now') + .catch(() => undefined); + expect(renderer.priorityOf('Section three.')[0]).toBe(0); + + renderer.setFocus('eagleton-c3d4/q/q2'); + + expect(renderer.priorityOf('Section three.')).toEqual([2, 1]); + expect(renderer.priorityOf('Eagleton opening.')).toEqual([2, 0]); + }); + + it('the background follows the latest order, and drops what left the inbox', () => { + const cancelled: string[] = []; + const { renderer } = setup(never, 100, (text) => + cancelled.splice(cancelled.length, 0, text) + ); + + renderer.prefetch([ + { podId: 'pawnee-a1b2', text: 'Old item.' }, + { podId: 'pawnee-a1b2', text: 'Kept item.' }, + ]); + // Both were handed to the provider (rendering). Then "Old item" leaves + // the inbox and the order changes. + renderer.prefetch([{ podId: 'pawnee-a1b2', text: 'Kept item.' }]); + + expect(renderer.priorityOf('Kept item.')).toEqual([2, 0]); + expect(renderer.priorityOf('Old item.')).toEqual([3]); + expect(cancelled).toEqual(['Old item.']); + }); + + it('the item on screen skips the settling wait', () => { + const root = tmp(); + const renders: string[] = []; + const renderer = createRenderer({ + audioDir: (podId) => path.join(root, podId, 'audio'), + render: (text) => { + renders.splice(renders.length, 0, text); + + return never(); + }, + budget: createBudget({ + file: path.join(root, 'usage.json'), + usdPer1kChars: 1, + capUsd: 100, + }), + enabled: () => true, + voiceId: 'rachel', + modelId: 'flash', + settleMs: 15_000, + concurrency: Number.POSITIVE_INFINITY, + schedule: () => undefined, + }); + + renderer.prefetch([ + { podId: 'pawnee-a1b2', text: 'On screen.', settle: false }, + { podId: 'pawnee-a1b2', text: 'Elsewhere.' }, + ]); + + expect(renders).toEqual(['On screen.']); + }); + }); + + it('retries in the background, then gives up and falls back', async () => { + const calls = { count: 0 }; + const { renderer, scheduled } = setup(async () => { + calls.count += 1; + + throw new Error('ElevenLabs 500'); + }); + + renderer.prefetch([{ podId: 'pawnee-a1b2', text: clipText }]); + await flush(); + expect(renderer.clip('pawnee-a1b2', clipText).status).toBe('pending'); + + scheduled.shift()?.(); + await flush(); + scheduled.shift()?.(); + await flush(); + + expect(calls.count).toBe(3); + expect(renderer.clip('pawnee-a1b2', clipText)).toEqual({ + status: 'unavailable', + reason: 'ElevenLabs 500', + }); + }); + + it('on demand tries once — the player falls back to the browser voice', async () => { + const calls = { count: 0 }; + const { renderer } = setup(async () => { + calls.count += 1; + + throw new Error('ElevenLabs 500'); + }); + + expect(await renderer.renderNow('pawnee-a1b2', clipText)).toEqual({ + status: 'unavailable', + reason: 'ElevenLabs 500', + }); + expect(calls.count).toBe(1); + }); + + it('refuses to render past the monthly cap', async () => { + const { renderer } = setup(async () => { + throw new Error('should not be called'); + }, 0.001); + + renderer.prefetch([{ podId: 'pawnee-a1b2', text: clipText }]); + + expect(renderer.clip('pawnee-a1b2', clipText)).toEqual({ + status: 'unavailable', + reason: 'over-budget', + }); + expect( + await renderer.renderNow('pawnee-a1b2', 'Context. A different clip.') + ).toEqual({ status: 'unavailable', reason: 'over-budget' }); + }); +}); + +describe('timeline', () => { + it('finds the section playing at a time', () => { + const sections = [ + { key: 'summary', label: 'Summary', start: 0, end: 4 }, + { key: 'context', label: 'Context', start: 4, end: 10 }, + ]; + + expect(sectionIndexAt(sections, 0)).toBe(0); + expect(sectionIndexAt(sections, 3.9)).toBe(0); + expect(sectionIndexAt(sections, 4)).toBe(1); + expect(sectionIndexAt(sections, 99)).toBe(1); + }); + + it('formats times as m:ss', () => { + expect(formatTime(0)).toBe('0:00'); + expect(formatTime(75.9)).toBe('1:15'); + }); +}); diff --git a/src/app/__tests__/uiLog.test.ts b/src/app/__tests__/uiLog.test.ts new file mode 100644 index 0000000..77cad30 --- /dev/null +++ b/src/app/__tests__/uiLog.test.ts @@ -0,0 +1,55 @@ +import { setUiLogSignedIn, startUiLog, uiLog } from '../client/uiLog'; + +/** Just enough of the browser for `startUiLog`: its timer, kept to call. */ +const fakeBrowser = () => { + const timer: { tick: () => void } = { tick: () => undefined }; + Object.assign(globalThis, { + window: { + location: { pathname: '/lambda/dog-park-map' }, + setInterval: (tick: () => void) => { + timer.tick = tick; + + return 1; + }, + addEventListener: () => undefined, + }, + document: { addEventListener: () => undefined, visibilityState: 'visible' }, + }); + + return timer; +}; + +describe('the page’s trail', () => { + afterEach(() => { + jest.restoreAllMocks(); + setUiLogSignedIn(false); + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, 'document'); + }); + + it('keeps only the newest 500 entries while it can’t send them', async () => { + const bodies: string[] = []; + jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { + bodies.splice(bodies.length, 0, String(init?.body)); + + return new Response('{}'); + }); + const timer = fakeBrowser(); + startUiLog(); + + // Signed out (the sign-in screen, or after a 401): nothing is sent. + Array.from({ length: 600 }, (_, n) => uiLog('click', { n })); + timer.tick(); + expect(bodies).toEqual([]); + + setUiLogSignedIn(true); + timer.tick(); + + const { entries }: { entries: { n: number }[] } = JSON.parse( + bodies[0] ?? '{}' + ); + expect(entries).toHaveLength(500); + expect(entries[0]?.n).toBe(100); + expect(entries.at(-1)?.n).toBe(599); + }); +}); diff --git a/src/app/__tests__/uniqueKeys.test.ts b/src/app/__tests__/uniqueKeys.test.ts new file mode 100644 index 0000000..ac9acff --- /dev/null +++ b/src/app/__tests__/uniqueKeys.test.ts @@ -0,0 +1,38 @@ +import { withKeys } from '../client/uniqueKeys'; + +describe('a list’s React keys', () => { + it('stays unique when two rows read the same', () => { + // Two bad lines in events.jsonl carry the same warning text; with the + // text as the key React drops or duplicates one of them. + const rows = withKeys( + [ + 'A line in events.jsonl that isn’t an event.', + 'A line in events.jsonl that isn’t an event.', + 'PAWNEE-12 never finished', + ], + (warning) => warning + ); + + expect(rows.map((row) => row.key)).toEqual([ + 'A line in events.jsonl that isn’t an event.', + 'A line in events.jsonl that isn’t an event.#2', + 'PAWNEE-12 never finished', + ]); + expect(rows.map((row) => row.item)).toEqual([ + 'A line in events.jsonl that isn’t an event.', + 'A line in events.jsonl that isn’t an event.', + 'PAWNEE-12 never finished', + ]); + }); + + it('keeps each row’s key when a later duplicate appears', () => { + const before = withKeys([{ name: 'PAWNEE-12' }], (row) => row.name); + const after = withKeys( + [{ name: 'PAWNEE-12' }, { name: 'PAWNEE-12' }], + (row) => row.name + ); + + expect(after[0]?.key).toBe(before[0]?.key); + expect(after[1]?.key).toBe('PAWNEE-12#2'); + }); +}); diff --git a/src/app/__tests__/untilFound.test.ts b/src/app/__tests__/untilFound.test.ts new file mode 100644 index 0000000..7538991 --- /dev/null +++ b/src/app/__tests__/untilFound.test.ts @@ -0,0 +1,39 @@ +import { ServerError } from '../client/api'; +import { untilFound } from '../client/untilFound'; + +const noWait = async (): Promise<void> => undefined; + +describe("a just-made pod's page", () => { + it('asks again while the server has no such pod yet, then shows it', async () => { + const get = jest + .fn<Promise<{ id: string }>, []>() + .mockRejectedValueOnce(new ServerError(404, 'No pod ice-town-1a2b')) + .mockRejectedValueOnce(new ServerError(404, 'No pod ice-town-1a2b')) + .mockResolvedValueOnce({ id: 'ice-town-1a2b' }); + + await expect(untilFound(get, { wait: noWait })).resolves.toEqual({ + id: 'ice-town-1a2b', + }); + expect(get).toHaveBeenCalledTimes(3); + }); + + it('gives up after its tries, with the 404 itself', async () => { + const get = jest.fn(() => + Promise.reject(new ServerError(404, 'No pod ice-town-1a2b')) + ); + + await expect(untilFound(get, { tries: 3, wait: noWait })).rejects.toThrow( + 'No pod ice-town-1a2b' + ); + expect(get).toHaveBeenCalledTimes(3); + }); + + it('fails at once on anything else', async () => { + const get = jest.fn(() => + Promise.reject(new ServerError(409, "Leslie's Mac mini is offline.")) + ); + + await expect(untilFound(get, { wait: noWait })).rejects.toThrow('offline'); + expect(get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/__tests__/visualRoutes.test.ts b/src/app/__tests__/visualRoutes.test.ts new file mode 100644 index 0000000..c8c7f8a --- /dev/null +++ b/src/app/__tests__/visualRoutes.test.ts @@ -0,0 +1,197 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { required } from '../../__tests__/required'; +import { + type FakeRunner, + type Harness, + connectRunner, + holdProject, + podSnapshot, + startHarness, +} from '../../mothership/__tests__/harness'; +import { storeSnapshots } from '../../mothership/pods'; +import { podFileRequestOf, servePodFile } from '../../runner/podFiles'; + +/** + * The visual check's pictures in the app: `/api/pod/<pod>/visual/…` sits + * behind the sign-in, is forwarded to the runner that has the pod with one + * `pod-file` message (the mothership reads none of a pod's files), and the + * runner serves only PNGs and each round's manifest.json inside `visual/`. + */ + +// Spy-able: the mothership's file reads are counted. +jest.mock('fs', () => ({ ...jest.requireActual('fs') })); + +const POD = 'pawnee-map-a1b2'; +const FOLDER = '/home/leslie/pawnee-parks'; +/** One tiny valid PNG (1×1). */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); +const ROUND = `/api/pod/${POD}/visual/PAWNEE-5/1`; + +/** A pods root on "the runner": one recorded round and the pod's own files. */ +const seedPodsRoot = (): string => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-visual-routes-')) + ); + const round = path.join(root, POD, 'visual', 'PAWNEE-5', '1'); + fs.mkdirSync(round, { recursive: true }); + fs.writeFileSync(path.join(round, 'expected-trail-closed.png'), PNG); + fs.writeFileSync(path.join(round, 'fake.png'), 'not a picture'); + fs.writeFileSync(path.join(round, 'manifest.json'), '[]\n'); + fs.writeFileSync(path.join(round, 'notes.json'), '{"secret":true}'); + fs.writeFileSync(path.join(root, POD, 'config.json'), '{"secret":true}'); + const outside = path.join(root, 'outside.png'); + fs.writeFileSync(outside, PNG); + fs.symlinkSync(outside, path.join(round, 'link.png')); + + return root; +}; + +describe('the visual picture route', () => { + const fixture: { harness?: Harness; runner?: FakeRunner; root?: string } = {}; + const harness = (): Harness => required(fixture.harness, 'harness'); + const runner = (): FakeRunner => required(fixture.runner, 'runner'); + const asked: { files: unknown[] } = { files: [] }; + + beforeEach(async () => { + asked.files = []; + fixture.root = seedPodsRoot(); + fixture.harness = await startHarness(); + fixture.runner = await connectRunner(harness(), { + name: "Ann's MacBook", + }); + await runner().next('owner'); + holdProject(harness(), runner().runnerId, { folder: FOLDER }); + storeSnapshots( + harness().db, + runner().runnerId, + FOLDER, + [podSnapshot(POD)], + [], + harness().clock.now + ); + }); + + afterEach(async () => { + await runner() + .close() + .catch(() => undefined); + await harness().close(); + fs.rmSync(required(fixture.root, 'root'), { recursive: true, force: true }); + }); + + it('is refused without a session, and the runner is never asked', async () => { + runner().on('pod-file', (message) => { + asked.files = [...asked.files, message]; + + return null; + }); + const response = await harness().request( + 'GET', + `${ROUND}/expected-trail-closed.png` + ); + + expect(response.status).toBe(401); + expect(asked.files).toEqual([]); + }); + + it('is in the route table the sign-in check walks', () => { + expect( + harness().mothership.routes.some((route) => + route.sample.startsWith(`/api/pod/p1/visual/`) + ) + ).toBe(true); + }); + + it("sends one pod-file message to the pod's runner, and the mothership reads no file", async () => { + runner().on('pod-file', (message) => { + asked.files = [...asked.files, message]; + + return { base64: PNG.toString('base64'), headers: {} }; + }); + const cookie = harness().signIn(); + const realFs = jest.requireMock<typeof fs>('fs'); + const reads = [ + jest.spyOn(realFs, 'readFileSync'), + jest.spyOn(realFs, 'createReadStream'), + jest.spyOn(realFs, 'openSync'), + jest.spyOn(realFs.promises, 'readFile'), + ]; + + try { + const response = await harness().request( + 'GET', + `${ROUND}/expected-trail-closed.png`, + { cookie } + ); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('image/png'); + reads.forEach((read) => expect(read).toHaveBeenCalledTimes(0)); + } finally { + reads.forEach((read) => read.mockRestore()); + } + + expect(asked.files).toEqual([ + expect.objectContaining({ + type: 'pod-file', + podId: POD, + folder: 'visual', + path: 'PAWNEE-5/1/expected-trail-closed.png', + }), + ]); + }); + + it("serves the round's PNGs and manifest through the runner's checks, and nothing else", async () => { + const root = required(fixture.root, 'root'); + runner().on('pod-file', (message) => + servePodFile(root, podFileRequestOf(message)) + ); + const cookie = harness().signIn(); + const get = (urlPath: string) => + harness().request('GET', urlPath, { cookie }); + + const picture = await get(`${ROUND}/expected-trail-closed.png`); + expect(picture.status).toBe(200); + expect(picture.headers['content-type']).toBe('image/png'); + + const manifest = await get(`${ROUND}/manifest.json`); + expect(manifest.status).toBe(200); + expect(manifest.headers['content-type']).toBe('application/json'); + + const statuses = await Promise.all( + [ + `/api/pod/${POD}/visual/../../config.json`, + `/api/pod/${POD}/visual/%2e%2e/%2e%2e/config.json`, + `/api/pod/${POD}/visual/${encodeURIComponent('/etc/passwd')}`, + `${ROUND}/notes.json`, + `${ROUND}/link.png`, + `${ROUND}/fake.png`, + `${ROUND}/missing.png`, + ].map(async (urlPath) => (await get(urlPath)).status) + ); + + expect(statuses).toEqual([404, 404, 404, 404, 404, 404, 404]); + }); + + it('answers 409 "<computer name> is offline." when the pod\'s runner is disconnected', async () => { + const cookie = harness().signIn(); + await runner().close(); + // The mothership notices the socket closing. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const response = await harness().request( + 'GET', + `${ROUND}/expected-trail-closed.png`, + { cookie } + ); + + expect(response.status).toBe(409); + expect(response.text).toContain("Ann's MacBook is offline."); + }); +}); diff --git a/src/app/__tests__/waitingNote.test.ts b/src/app/__tests__/waitingNote.test.ts new file mode 100644 index 0000000..70491a3 --- /dev/null +++ b/src/app/__tests__/waitingNote.test.ts @@ -0,0 +1,51 @@ +import { waitingFailureNote } from '../shared/waitingNote'; + +const NOW = Date.parse('2026-10-21T12:00:00.000Z'); + +describe("a waiting pod's failed start", () => { + it('says why, and when Lambda tries again', () => { + expect( + waitingFailureNote( + { + error: "Leslie's MacBook didn't answer. Try again.", + retryAt: new Date(NOW + 4 * 60_000 + 10_000).toISOString(), + }, + NOW + ) + ).toBe( + "Didn't start: Leslie's MacBook didn't answer. Try again. Lambda tries again in 5 min." + ); + }); + + it('ends the reason with a full stop, once', () => { + expect( + waitingFailureNote( + { + error: 'lambda pod new exited 1', + retryAt: new Date(NOW + 60_000).toISOString(), + }, + NOW + ) + ).toBe( + "Didn't start: lambda pod new exited 1. Lambda tries again in 1 min." + ); + }); + + it('says it is trying again once the wait is over', () => { + expect( + waitingFailureNote( + { + error: 'lambda pod new exited 1', + retryAt: new Date(NOW - 1000).toISOString(), + }, + NOW + ) + ).toBe("Didn't start: lambda pod new exited 1. Trying again now."); + }); + + it('never says "NaN min" or an empty reason', () => { + expect(waitingFailureNote({ error: ' ', retryAt: 'soon' }, NOW)).toBe( + "Didn't start, for no reason given. Lambda tries again shortly." + ); + }); +}); diff --git a/src/app/__tests__/waitingPod.test.ts b/src/app/__tests__/waitingPod.test.ts new file mode 100644 index 0000000..b910116 --- /dev/null +++ b/src/app/__tests__/waitingPod.test.ts @@ -0,0 +1,10 @@ +import { isWaitingPodId, waitingPodId } from '../shared/waitingPod'; + +describe('a waiting pod’s id', () => { + it('is told from a pod’s by the one prefix both sides share', () => { + expect(waitingPodId('wait_ab12')).toBe('waiting:wait_ab12'); + expect(isWaitingPodId(waitingPodId('wait_ab12'))).toBe(true); + expect(isWaitingPodId('dog-park-map')).toBe(false); + expect(isWaitingPodId('waitingroom')).toBe(false); + }); +}); diff --git a/src/app/__tests__/watchdog.test.ts b/src/app/__tests__/watchdog.test.ts new file mode 100644 index 0000000..518aa3f --- /dev/null +++ b/src/app/__tests__/watchdog.test.ts @@ -0,0 +1,303 @@ +import type { Pod, ThreadMessage, Turn } from '../../pod-store'; +import { + type Episode, + HANG_MS, + LOST_MS, + NUDGE_GAP_MS, + type PodSignals, + STALL_GRACE_MS, + judge, +} from '../server/watchdog'; +import { sameEscalation } from '../server/watchdogRunner'; + +const T0 = Date.parse('2026-10-06T10:00:00.000Z'); +const iso = (ms: number): string => new Date(ms).toISOString(); + +const pod: Pod = { + id: 'pawnee-a1b2', + slug: 'pawnee', + createdAt: iso(T0 - 3_600_000), + status: 'active', + phase: 4, + completedCheckpoints: [1, 2, 3, 4], + worktreePath: '/tmp', + ownsWorktree: true, + managerCwd: '/tmp', + devCwd: '/tmp', + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, +}; + +const ended = (at: number): Turn => ({ + sentAt: iso(at - 60_000), + startedAt: iso(at - 59_000), + endedAt: iso(at), +}); +const working = (since: number): Turn => ({ + sentAt: iso(since - 1), + startedAt: iso(since), + endedAt: iso(since - 120_000), +}); + +const signals = (overrides: Partial<PodSignals> = {}): PodSignals => ({ + pod, + running: true, + hasInboxItems: false, + turns: { manager: ended(T0), dev: ended(T0) }, + thread: [], + questions: [], + steps: [], + transcriptAt: {}, + screens: {}, + ...overrides, +}); + +/** Run ticks in order, carrying the episode; returns every verdict. */ +const run = (ticks: { at: number; signals: PodSignals }[]) => + ticks.reduce<{ verdicts: string[]; episode?: Episode }>( + (acc, tick) => { + const { verdict, episode } = judge(tick.signals, acc.episode, tick.at); + + return { + verdicts: [ + ...acc.verdicts, + verdict.kind === 'nudge' ? `nudge ${verdict.attempt}` : verdict.kind, + ], + episode, + }; + }, + { verdicts: [] } + ).verdicts; + +describe('the watchdog', () => { + it('leaves a working pod, and one waiting on the owner, alone', () => { + expect( + run([ + { + at: T0 + 600_000, + signals: signals({ turns: { manager: working(T0), dev: ended(T0) } }), + }, + { at: T0 + 600_000, signals: signals({ hasInboxItems: true }) }, + ]) + ).toEqual(['ok', 'ok']); + }); + + it('does not nudge a manager that was just handed something (a work request)', () => { + const idleSince = (ms: number): Turn => ({ + ...ended(ms), + sentAt: iso(ms - 60_000), + }); + const justSent = { + ...ended(T0), + sentAt: iso(T0 + 10 * 60_000), + }; + + expect( + run([ + { + // Idle for 10 minutes, but a message reached the manager just now. + at: T0 + 10 * 60_000 + 5_000, + signals: signals({ + turns: { manager: justSent, dev: idleSince(T0) }, + }), + }, + ]) + ).toEqual(['ok']); + }); + + it('waits out the handoff grace, then nudges', () => { + expect( + run([ + { at: T0 + STALL_GRACE_MS - 1_000, signals: signals() }, + { at: T0 + STALL_GRACE_MS, signals: signals() }, + ]) + ).toEqual(['ok', 'nudge 1']); + }); + + it('a nudge that only causes "nothing to do" turns does not reset the count — twice, then the inbox', () => { + const after = (ms: number) => T0 + STALL_GRACE_MS + ms; + + expect( + run([ + { at: after(0), signals: signals() }, + // The nudge makes the manager take a turn… + { + at: after(10_000), + signals: signals({ + turns: { manager: working(after(5_000)), dev: ended(T0) }, + }), + }, + // …which ends with nothing done. + { + at: after(NUDGE_GAP_MS), + signals: signals({ + turns: { manager: ended(after(20_000)), dev: ended(T0) }, + }), + }, + { + at: after(2 * NUDGE_GAP_MS + 1), + signals: signals({ + turns: { manager: ended(after(20_000)), dev: ended(T0) }, + }), + }, + ]) + ).toEqual(['nudge 1', 'ok', 'nudge 2', 'escalate']); + }); + + it('real progress starts a fresh count', () => { + const after = (ms: number) => T0 + STALL_GRACE_MS + ms; + const progressed = signals({ + steps: [{ at: iso(after(30_000)), step: 'e2e-plans', event: 'done' }], + }); + + expect( + run([ + { at: after(0), signals: signals() }, + { at: after(NUDGE_GAP_MS), signals: signals() }, + // The second nudge worked: a step was reported, then it stalled again. + { at: after(2 * NUDGE_GAP_MS + 1), signals: progressed }, + ]) + ).toEqual(['nudge 1', 'nudge 2', 'nudge 1']); + }); + + it('a permission prompt goes straight to the owner', () => { + expect( + run([ + { + at: T0 + 5_000, + signals: signals({ + turns: { manager: working(T0), dev: ended(T0) }, + screens: { + manager: + 'Bash command\n tmux send-keys -t x C-u\n Do you want to proceed?\n ❯ 1. Yes', + }, + }), + }, + ]) + ).toEqual(['escalate']); + }); + + it('the usage limit goes straight to the owner, with when it resets', () => { + const limited = signals({ + turns: { manager: working(T0), dev: ended(T0) }, + screens: { + manager: + " ⎿ You've hit your weekly limit · resets 9am (America/Indiana/Indianapolis)\n⏺ Usage limit reached · continuing automatically at 9am", + }, + }); + + expect(judge(limited, undefined, T0 + 5_000).verdict).toEqual({ + kind: 'escalate', + reason: + 'The manager hit the Claude usage limit (resets 9am). Log in to another account and restart the pod, or wait', + }); + }); + + it('a turn that has written nothing for 15 minutes is hung', () => { + expect( + run([ + { + at: T0 + HANG_MS + 1, + signals: signals({ + turns: { manager: ended(T0), dev: working(T0) }, + transcriptAt: { dev: T0 }, + }), + }, + ]) + ).toEqual(['escalate']); + }); + + describe('a message an agent never took in', () => { + /** The owner's q4 answer, typed into a busy dev and lost over a reboot. */ + const answer = (overrides: Partial<ThreadMessage> = {}): ThreadMessage => ({ + id: 'mq4', + podId: pod.id, + qid: 'q4', + from: 'human', + mode: 'async', + text: 'Either switch turns it on.', + wire: '/human-async q4 [m:mq4]', + target: 'dev', + createdAt: iso(T0 - LOST_MS), + delivery: 'delivered', + ...overrides, + }); + // The dev is idle; the owner has nothing in the inbox, so nobody else looks. + const lost = signals({ thread: [answer()], hasInboxItems: false }); + + it('is re-sent once, then goes to the owner if it is still not taken', () => { + expect( + run([ + { at: T0 + LOST_MS, signals: lost }, + { at: T0 + LOST_MS + 15_000, signals: lost }, + ]) + ).toEqual(['resend', 'escalate']); + expect(judge(lost, undefined, T0 + LOST_MS).verdict).toEqual({ + kind: 'resend', + msgIds: ['mq4'], + }); + }); + + it('waits while the agent is mid-turn (a busy agent queues it)', () => { + expect( + run([ + { + at: T0 + LOST_MS * 5, + signals: signals({ + thread: [answer()], + turns: { manager: ended(T0), dev: working(T0) }, + }), + }, + ]) + ).toEqual(['ok']); + }); + + it('waits until the agent has been idle a while, and leaves taken ones alone', () => { + expect( + run([ + // Something else in the inbox keeps the stall nudges out of it. + { at: T0 + LOST_MS - 1, signals: { ...lost, hasInboxItems: true } }, + { + at: T0 + LOST_MS, + signals: signals({ + thread: [answer({ delivery: 'received' })], + hasInboxItems: true, + }), + }, + ]) + ).toEqual(['ok', 'ok']); + }); + + it('is still noticed after a send that was never taken (sentAt after the last turn)', () => { + expect( + run([ + { + at: T0 + LOST_MS, + signals: signals({ + thread: [answer()], + turns: { + manager: ended(T0), + dev: { ...ended(T0 - LOST_MS), sentAt: iso(T0 - LOST_MS) }, + }, + }), + }, + ]) + ).toEqual(['resend']); + }); + }); + + it('counts "hung for 17 minutes" and "for 18 minutes" as one escalation', () => { + expect( + sameEscalation( + 'The manager has been mid-turn for 17 minutes without writing anything', + 'The manager has been mid-turn for 18 minutes without writing anything' + ) + ).toBe(true); + expect( + sameEscalation( + 'The manager has been mid-turn for 17 minutes without writing anything', + 'The dev has been mid-turn for 17 minutes without writing anything' + ) + ).toBe(false); + }); +}); diff --git a/src/app/__tests__/windowPicker.test.ts b/src/app/__tests__/windowPicker.test.ts new file mode 100644 index 0000000..b353cef --- /dev/null +++ b/src/app/__tests__/windowPicker.test.ts @@ -0,0 +1,92 @@ +import type { Panes } from '../shared/types'; +import { chosenOption, pickerGroups } from '../shared/windowPicker'; + +const labelOf = (window: string): string => + ({ 'engineering-manager': 'Engineering manager', dev: 'Dev', qa: 'QA' }[ + window + ] ?? window); + +const panes: Panes = { + others: [ + { id: '@3', name: 'web', group: 'project', typable: false }, + { id: '@4', name: 'scratchPad', group: 'project', typable: true }, + { id: '@9', name: 'mothership', group: 'lambda', typable: false }, + { id: '@10', name: 'runner', group: 'lambda', typable: false }, + ], + working: ['dev'], +}; + +describe("the Agents panel's window list", () => { + it("lists the agents first, marked working or idle, then the codebase's windows, read-only but for the scratchPad", () => { + const groups = pickerGroups({ + agents: ['engineering-manager', 'dev', 'qa'], + labelOf, + panes, + showLambda: false, + }); + + expect(groups).toEqual([ + { + label: 'Agents', + options: [ + { + value: 'engineering-manager', + label: '○ Engineering manager', + readOnly: false, + }, + { value: 'dev', label: '● Dev', readOnly: false }, + { value: 'qa', label: '○ QA', readOnly: false }, + ], + }, + { + label: 'Codebase', + options: [ + { value: '@3', label: 'web · read-only', readOnly: true }, + { value: '@4', label: 'scratchPad', readOnly: false }, + ], + }, + ]); + }); + + it("adds Lambda's own windows only when asked", () => { + const groups = pickerGroups({ + agents: ['dev'], + labelOf, + panes, + showLambda: true, + }); + + expect(groups.map((group) => group.label)).toEqual([ + 'Agents', + 'Codebase', + 'Lambda · read-only', + ]); + }); + + it('shows the agents before the list has loaded', () => { + expect( + pickerGroups({ agents: ['dev'], labelOf, panes: null, showLambda: true }) + ).toEqual([ + { + label: 'Agents', + options: [{ value: 'dev', label: '○ Dev', readOnly: false }], + }, + ]); + }); + + it('keeps the pick while it is there, else falls back to the agent named', () => { + const groups = pickerGroups({ + agents: ['engineering-manager', 'dev'], + labelOf, + panes, + showLambda: false, + }); + + expect(chosenOption(groups, '@4', 'dev')?.value).toBe('@4'); + // A Lambda window picked while they were shown, now hidden. + expect(chosenOption(groups, '@9', 'dev')?.value).toBe('dev'); + expect(chosenOption(groups, null, 'engineering-manager')?.value).toBe( + 'engineering-manager' + ); + }); +}); diff --git a/src/app/__tests__/workflowView.test.ts b/src/app/__tests__/workflowView.test.ts new file mode 100644 index 0000000..4ec6e29 --- /dev/null +++ b/src/app/__tests__/workflowView.test.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { type Pod, createPodStore } from '../../pod-store'; +import { buildWorkflowView } from '../server/workflowView'; + +const pod = (id: string, overrides: Partial<Pod> = {}): Pod => ({ + id, + slug: id, + createdAt: '2026-10-19T00:00:00.000Z', + status: 'active', + phase: 3, + completedCheckpoints: [], + worktreePath: `/tmp/${id}`, + ownsWorktree: false, + managerCwd: `/tmp/${id}`, + devCwd: `/tmp/${id}`, + agentCommand: 'claude', + sessions: { manager: 'm', dev: 'd' }, + pipeline: 2, + source: 'idea', + ...overrides, +}); + +describe('buildWorkflowView', () => { + it('carries the pod render mode for the Render switch, full when unset on a computer with Claude Design', () => { + const store = createPodStore( + fs.mkdtempSync(path.join(os.tmpdir(), 'pods-workflow-')) + ); + store.writePod(pod('pawnee-a1b2', { renderMode: 'ascii' })); + store.writePod(pod('eagleton-c3d4')); + const time = { day: '2026-10-19', todayMs: 0, days: {}, pods: {} }; + const available = { design: { available: true } }; + + expect( + buildWorkflowView(store, 'pawnee-a1b2', time, available).renderMode + ).toBe('ascii'); + expect( + buildWorkflowView(store, 'eagleton-c3d4', time, available).renderMode + ).toBe('full'); + }); +}); diff --git a/src/app/client/AnalyticsPage.tsx b/src/app/client/AnalyticsPage.tsx new file mode 100644 index 0000000..abbf5a2 --- /dev/null +++ b/src/app/client/AnalyticsPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { LambdaInfo } from '../shared/types'; +import { ProjectPicker } from './ProjectPicker'; +import { type Analytics, api } from './api'; +import { latestLoader } from './loading'; +import { chosenProjectId } from './projects'; +import { withKeys } from './uniqueKeys'; + +/** A time in whole minutes: "<1 min" under one, "—" when there's none. */ +export const minutes = (ms: number | null): string => { + if (ms === null) { + return '—'; + } + + return ms < 60_000 ? '<1 min' : `${Math.round(ms / 60_000)} min`; +}; + +/** + * Analytics, one project at a time: average time per planning step and per + * ship stage, and per ship its total, runs until green and Fixing (from a + * failed attempt's end to the next attempt's start). Warnings never hide. + */ +export const AnalyticsPage = ({ + lambda, + tick, +}: { + lambda: LambdaInfo | undefined; + tick: number; +}) => { + const [picked, setPicked] = useState<string | null>(null); + const projectId = chosenProjectId(picked, lambda); + const [analytics, setAnalytics] = useState<Analytics | null>(null); + const [error, setError] = useState<string | null>(null); + const load = useMemo( + () => latestLoader<Analytics>({ value: setAnalytics, error: setError }), + [] + ); + + useEffect(() => { + if (projectId === null) { + return; + } + + void load(api.analytics(projectId)); + }, [load, projectId, tick]); + + if (projectId === null) { + return <p className="muted">Add a codebase first.</p>; + } + + return ( + <section className="account-page"> + <ProjectPicker lambda={lambda} value={projectId} onChange={setPicked} /> + {error !== null && <p className="error">{error}</p>} + {analytics !== null && analytics.notice !== null && ( + <p className="muted">{analytics.notice}</p> + )} + {analytics !== null && !analytics.empty && ( + <> + {withKeys(analytics.warnings, (warning) => warning).map( + ({ key, item: warning }) => ( + <p key={key} className="analytics__warning" role="alert"> + ⚠ {warning} + </p> + ) + )} + <table className="analytics__table"> + <thead> + <tr> + <th>Planning steps</th> + <th>average</th> + </tr> + </thead> + <tbody> + {analytics.steps.map((row) => ( + <tr key={row.name}> + <td>{row.name}</td> + <td>{minutes(row.averageMs)}</td> + </tr> + ))} + </tbody> + </table> + <table className="analytics__table"> + <thead> + <tr> + <th>Ship stages</th> + <th>average</th> + </tr> + </thead> + <tbody> + {analytics.stages.map((row) => ( + <tr key={row.name}> + <td>{row.name}</td> + <td>{minutes(row.averageMs)}</td> + </tr> + ))} + </tbody> + </table> + <table className="analytics__table"> + <thead> + <tr> + <th>Per ship</th> + <th>total</th> + <th>runs until green</th> + <th>Fixing</th> + </tr> + </thead> + <tbody> + {withKeys(analytics.ships, (row) => row.name).map( + ({ key, item: row }) => ( + <tr key={key}> + <td>{row.name}</td> + <td>{minutes(row.totalMs)}</td> + <td>{row.runsUntilGreen}</td> + <td>{minutes(row.fixingMs)}</td> + </tr> + ) + )} + </tbody> + </table> + </> + )} + </section> + ); +}; diff --git a/src/app/client/App.tsx b/src/app/client/App.tsx new file mode 100644 index 0000000..86b2630 --- /dev/null +++ b/src/app/client/App.tsx @@ -0,0 +1,3942 @@ +import { + lazy, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { Question } from '../../pod-store'; +import { buildingScreenMode } from '../shared/building'; +import { buildTimeline } from '../shared/cardHistory'; +import { caughtUpLine, launchItems } from '../shared/caughtUp'; +import { CHAT_HOLD_MS, owesReply } from '../shared/conversation'; +import { iconEmoji } from '../shared/emoji'; +import { withWords, wordsOf } from '../shared/images'; +import { itemChat, itemStatus, itemTitle } from '../shared/items'; +import { + type Lambda, + anchorOf, + lambdaOfEntry, + lambdasOf, + sidebarSections, +} from '../shared/lambdas'; +import { offersKeepOne } from '../shared/phases'; +import { podName } from '../shared/podName'; +import { orderPods } from '../shared/podOrder'; +import { podNumber } from '../shared/podOrder'; +import { + type Route, + formatRoute, + itemSlug, + lambdaFromSlug, + lambdaSlugs, + parseRoute, + questionFromSlug, +} from '../shared/route'; +import { favoriteRef, splitFavoriteRef } from '../shared/savedViews'; +import { + firstRailOf, + planningRailOf, + planningRailsOf, +} from '../shared/screenChoice'; +import { + type AppState, + type InboxEntry, + type PodDetail, + isWorking, +} from '../shared/types'; +import { isWaitingPodId } from '../shared/waitingPod'; +import { CaughtUp, LambdaMark } from './CaughtUp'; +import type { PendingChat } from './ChatPanel'; +import { Lazy } from './ChunkBoundary'; +import { ImageDrop } from './ImageDrop'; +import { LambdaTicketsRail } from './LambdaTickets'; +import { + NewPodForm, + clearNewDraft, + startNewAbout, + startNewDraft, +} from './NewPodForm'; +import { PhaseStrip } from './PhaseStrip'; +import { + BUILDING_RAILS, + MIN_CHAT, + MiniPlayer, + RailHost, + RailSwitcher, + useRails, +} from './Rails'; +import { type FavoriteRow, Sidebar } from './Sidebar'; +import type { OpenTicket, TicketsScope } from './TicketsPage'; +import { ViewerProvider } from './Viewer'; +import { api } from './api'; +import { playChatChime, playReadyChime } from './chime'; +import { isTypingTarget } from './keys'; +import { type Page, accountPageOf, pageOfRoute, settingsTabOf } from './page'; +import { + type ScrollAnchor, + anchorAt, + loadPlace, + savePlace, + scrollToAnchor, +} from './planPlace'; +import { ACCOUNT_PAGE_TITLES, withProjectTag } from './projects'; +import { itemSections } from './speech'; +import { uiLog } from './uiLog'; +import { untilFound } from './untilFound'; +import { formatDuration, useActiveTime } from './useActiveTime'; +import { usePlayer } from './usePlayer'; +import { useWorkspace } from './useWorkspace'; + +// Each page is its own chunk: the first load doesn't ship them all. +const TimePage = lazy(() => + import('./TimePage').then((module) => ({ default: module.TimePage })) +); +const ComputersPage = lazy(() => + import('./ComputersPage').then((module) => ({ + default: module.ComputersPage, + })) +); +const AnalyticsPage = lazy(() => + import('./AnalyticsPage').then((module) => ({ + default: module.AnalyticsPage, + })) +); +const SettingsPage = lazy(() => + import('./SettingsPage').then((module) => ({ default: module.SettingsPage })) +); +const TicketsPage = lazy(() => + import('./TicketsPage').then((module) => ({ default: module.TicketsPage })) +); +const ProjectsPage = lazy(() => + import('./ProjectsPage').then((module) => ({ + default: module.ProjectsPage, + })) +); +const ViewsPage = lazy(() => + import('./ViewsPage').then((module) => ({ default: module.ViewsPage })) +); +// A building pod's page: its own chunk, like the pod screen. +const BuildingPodScreen = lazy(() => + import('./BuildingPodScreen').then((module) => ({ + default: module.BuildingPodScreen, + })) +); + +// A Lambda's page past planning: Ready, Building, QA and Merged. +const LambdaScreen = lazy(() => + import('./LambdaScreen').then((module) => ({ + default: module.LambdaScreen, + })) +); + +// The pod screen — a Lambda's chat, its cards, workflow, terminals and what +// it made — is its own chunk too (PodScreen.tsx); this file is the shell +// around it (sidebar, queue, player, pages). +const podScreen = () => import('./PodScreen'); +const ChatPanel = lazy(() => + podScreen().then((module) => ({ default: module.ChatPanel })) +); +const QuestionCard = lazy(() => + podScreen().then((module) => ({ default: module.QuestionCard })) +); +const WorkflowPanel = lazy(() => + podScreen().then((module) => ({ default: module.WorkflowPanel })) +); +const WindowPicker = lazy(() => + podScreen().then((module) => ({ default: module.WindowPicker })) +); + +/** A planning pod's window as the Agents panel names it. */ +const planningWindowName = (window: string): string => + window === 'dev' + ? 'Dev' + : window === 'manager' + ? 'Manager' + : `Fork ${window.slice(5)}`; +const PlanPlayer = lazy(() => + podScreen().then((module) => ({ default: module.PlanPlayer })) +); +const PodIdle = lazy(() => + podScreen().then((module) => ({ default: module.PodIdle })) +); +const StructureCard = lazy(() => + podScreen().then((module) => ({ default: module.StructureCard })) +); +const YourTurnCard = lazy(() => + podScreen().then((module) => ({ default: module.YourTurnCard })) +); +const StalledCard = lazy(() => + podScreen().then((module) => ({ default: module.StalledCard })) +); +const OldVersionNote = lazy(() => + podScreen().then((module) => ({ default: module.OldVersionNote })) +); +const VersionMenu = lazy(() => + podScreen().then((module) => ({ default: module.VersionMenu })) +); +const MadeRail = lazy(() => + podScreen().then((module) => ({ default: module.MadeRail })) +); + +/** How long a remembered scroll place keeps being put back while the card lays out. */ +const RESTORE_MS = 2_000; +/** The shortest time the λ loading screen shows. */ +const MIN_LOADING_MS = 1_300; +/** The loading screen's fade out (matches .loading--out in styles.css). */ +const LOADING_FADE_MS = 400; +/** How long the sidebar takes to slide open or shut (see styles.css). */ +const SIDEBAR_SLIDE_MS = 220; + +const keyOf = (entry: InboxEntry): string => entry.key; + +/** Why this item has no audio, in words the owner can act on. */ +const audioUnavailableReason = (audioMeta: InboxEntry['audio']): string => { + // Shown only when no clip can be rendered: the first one says why. + const [audio] = Object.values(audioMeta?.clips ?? {}); + + if (audio === undefined || audio.status !== 'unavailable') { + return 'No audio'; + } + + if (audio.reason === 'no-key') { + return 'No audio — no ElevenLabs key found'; + } + + if (audio.reason === 'over-budget') { + return 'No audio — monthly audio budget reached'; + } + + if (audio.reason.includes('quota')) { + return 'No audio — ElevenLabs credits used up'; + } + + if (audio.reason.includes('text_to_speech')) { + return 'No audio — the ElevenLabs key lacks the text-to-speech permission'; + } + + return `No audio — the render failed (${audio.reason.slice(0, 80)})`; +}; + +/** + * Drafts survive reloads and app restarts: saved per item as you type, + * restored when the item comes up, cleared once sent. (Browser storage can be + * unavailable — then drafts just aren't kept.) + */ +const DRAFT_PREFIX = 'pods.draft.'; + +const saveDraft = (key: string | null, value: string): void => { + if (key === null) { + return; + } + + try { + if (value.trim() === '') { + window.localStorage.removeItem(DRAFT_PREFIX + key); + + return; + } + + window.localStorage.setItem(DRAFT_PREFIX + key, value); + } catch { + // Not kept — the box still works. + } +}; + +const loadDraft = (key: string): string => { + try { + return window.localStorage.getItem(DRAFT_PREFIX + key) ?? ''; + } catch { + return ''; + } +}; + +/** + * Every message sent from this browser, kept 48 h (whatever happens to the + * box): if one never shows up in its pod's saved thread, the page offers it + * back. Drafts are cleared on send; these are not. + */ +const SENT_KEY = 'pods.sent'; +const SENT_KEEP_MS = 48 * 60 * 60_000; +/** How long a send may take to show up in the thread before it counts as lost. */ +const ARRIVAL_GRACE_MS = 30_000; + +interface SentMessage { + id: string; + podId: string; + /** The inbox item it was typed on (for putting it back in the box). */ + itemKey: string | null; + text: string; + at: number; + dismissed?: boolean; +} + +const isSentMessage = (value: unknown): value is SentMessage => + typeof value === 'object' && + value !== null && + 'id' in value && + typeof value.id === 'string' && + 'podId' in value && + typeof value.podId === 'string' && + 'text' in value && + typeof value.text === 'string' && + 'at' in value && + typeof value.at === 'number'; + +const loadSent = (): SentMessage[] => { + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(SENT_KEY) ?? '[]' + ); + + return Array.isArray(parsed) + ? parsed + .filter(isSentMessage) + .filter((message) => Date.now() - message.at < SENT_KEEP_MS) + : []; + } catch { + return []; + } +}; + +const storeSent = (messages: SentMessage[]): void => { + try { + window.localStorage.setItem(SENT_KEY, JSON.stringify(messages)); + } catch { + // Not kept — sending still works. + } +}; + +/** + * "Back when done": an item set aside until its pod's dev finishes the turn + * they're waiting on. Kept in this browser, so a reload doesn't bring it back. + */ +interface AsideItem { + key: string; + podId: string; + at: number; +} + +const ASIDE_KEY = 'pods.aside'; + +const isAsideItem = (value: unknown): value is AsideItem => + typeof value === 'object' && + value !== null && + 'key' in value && + typeof value.key === 'string' && + 'podId' in value && + typeof value.podId === 'string' && + 'at' in value && + typeof value.at === 'number'; + +const loadAside = (): AsideItem[] => { + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(ASIDE_KEY) ?? '[]' + ); + + return Array.isArray(parsed) ? parsed.filter(isAsideItem) : []; + } catch { + return []; + } +}; + +const storeAside = (items: AsideItem[]): void => { + try { + window.localStorage.setItem(ASIDE_KEY, JSON.stringify(items)); + } catch { + // Not kept: a reload brings set-aside items straight back. + } +}; + +export const App = () => { + const [state, setState] = useState<AppState | null>(null); + const [detail, setDetail] = useState<PodDetail | null>(null); + const [tick, setTick] = useState(0); + const [error, setError] = useState<string | null>(null); + /** + * null = Auto mode: the pod on screen follows the queue (every pod's items, + * by priority). A pod id = that pod, picked in the sidebar (Auto is off). + */ + const [view, setViewNow] = useState<string | null>(null); + /** Every view change is logged with what caused it (see uiLog). */ + const setView = useCallback((next: string | null, why = 'unknown') => { + setViewNow((from) => { + if (from !== next) { + uiLog('view', { from, to: next, why }); + } + + return next; + }); + }, []); + /** + * A Lambda's page picked in the sidebar (or a /work URL): its key, and the + * pod `view` was set to with it. Shown while `view` is still that pod, so + * picking anything else leaves it. + */ + const [lambdaOpen, setLambdaOpen] = useState<{ + key: string; + podId: string; + } | null>(null); + /** The item being worked on — stays put during a conversation. */ + const [pinned, setPinned] = useState<InboxEntry | null>(null); + /** Answered locally; hidden until the server shows it caught up. */ + const [hidden, setHidden] = useState<Set<string>>(() => new Set()); + const [conversation, setConversation] = useState<string | null>(null); + const [draft, setDraft] = useState(''); + const [chatDraft, setChatDraft] = useState(''); + /** An option they clicked on the item up (else Done goes with the rec). */ + const [pick, setPick] = useState<{ key: string; optionId: string } | null>( + null + ); + // The λ loading screen stays up at least MIN_LOADING_MS, so a fast load + // doesn't flash it. + const [loadingShown, setLoadingShown] = useState(false); + /** The loading screen has finished fading out. */ + const [loadingGone, setLoadingGone] = useState(false); + useEffect(() => { + const id = window.setTimeout(() => setLoadingShown(true), MIN_LOADING_MS); + + return () => window.clearTimeout(id); + }, []); + /** The chat box's text when no item is up (an item's is `draft`). */ + const [podDraft, setPodDraft] = useState(''); + // The sidebar can fold to an icon strip, as in Claude Code. Remembered. + const [sidebarCollapsed, setSidebarCollapsedNow] = useState(() => { + try { + return window.localStorage.getItem('pods.sidebar') === 'collapsed'; + } catch { + return false; + } + }); + const setSidebarCollapsed = (next: boolean): void => { + setSidebarCollapsedNow(next); + + try { + window.localStorage.setItem('pods.sidebar', next ? 'collapsed' : 'open'); + } catch { + // Private mode: it just isn't remembered. + } + }; + /** + * Opening or closing the sidebar slides it: the column narrows or widens + * while the sidebar stays laid out open inside it, clipped, so nothing + * re-wraps. Closing keeps the open sidebar until the slide ends. + */ + const [sidebarSlide, setSidebarSlide] = useState< + 'opening' | 'closing' | null + >(null); + const slideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + useEffect( + () => () => { + if (slideTimer.current !== null) { + clearTimeout(slideTimer.current); + } + }, + [] + ); + const toggleSidebar = (close: boolean): void => { + if (slideTimer.current !== null) { + return; + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setSidebarCollapsed(close); + + return; + } + + setSidebarSlide(close ? 'closing' : 'opening'); + + if (!close) { + setSidebarCollapsed(false); + } + + slideTimer.current = setTimeout(() => { + slideTimer.current = null; + + if (close) { + setSidebarCollapsed(true); + } + + setSidebarSlide(null); + }, SIDEBAR_SLIDE_MS); + }; + const sidebarNarrow = sidebarCollapsed || sidebarSlide === 'closing'; + // The chat is always in the center; one rail at a time opens beside it. + const rails = useRails(sidebarNarrow ? 56 : 288); + const [chatPending, setChatPending] = useState<PendingChat[]>([]); + const [rightTab, setRightTab] = useState('dev'); + // The screen starts as the URL says (shared/route.ts); a Lambda or an item + // in it is resolved once the state has loaded. + const startRoute = useRef( + parseRoute(window.location.pathname, window.location.search) + ); + /** The page up instead of the inbox or a Lambda (page.ts), if any. */ + const [page, setPage] = useState<Page | null>(() => + pageOfRoute(startRoute.current) + ); + const creating = page === 'new'; + /** The Time page (opened from the stopwatch). */ + const showTime = page === 'time'; + /** The Tickets page: Computers, All tickets and a ticket's own page. */ + const showTickets = page === 'tickets'; + /** Tickets, Projects or Views: full-width pages of their own, Linear's way. */ + const listPage = showTickets || page === 'projects' || page === 'views'; + /** The ticket page up on the Tickets screen, if any. */ + const [openTicket, setOpenTicket] = useState<OpenTicket | null>(null); + /** The Tickets list: all of it, a tracker project's, or a saved view's. */ + const [ticketsScope, setTicketsScope] = useState<TicketsScope>({ + kind: 'all', + }); + /** A project's or view's unsaved filters, as the address keeps them. */ + const [ticketsFilter, setTicketsFilter] = useState<string | undefined>( + undefined + ); + /** The project "Plan with Lambda" picked for the New page, if any. */ + const [newProjectId, setNewProjectId] = useState<string | null>(null); + /** An account-menu page: Computers, Analytics or Settings. */ + const accountPage = accountPageOf(page); + const otherPage = page !== null; + /** Show one page, or none of them (the inbox, or a Lambda). */ + const showPage = setPage; + /** The inbox item being worked on (its key) — held until dealt with or skipped. */ + const [held, setHeld] = useState<string | null>(null); + /** Set aside with "Back when done" — out of the queue until the dev's done. */ + const [aside, setAsideNow] = useState<AsideItem[]>(loadAside); + const setAside = (update: (previous: AsideItem[]) => AsideItem[]): void => + setAsideNow((previous) => { + const next = update(previous); + storeAside(next); + + return next; + }); + /** Back from "Back when done": first in the queue, ahead of priority. */ + const [returned, setReturned] = useState<string[]>([]); + /** What was already waiting when the current item came up. */ + const [seen, setSeen] = useState<Set<string>>(() => new Set()); + /** + * Items opened from a link ("How we got here", "This led to", a pod's item + * list) — a stack, so "← Back" retraces the chain. Top of it overrides the + * inbox until closed. + */ + const [trail, setTrail] = useState<{ podId: string; qid: string }[]>([]); + + const fail = useCallback((caught: unknown) => { + setError(caught instanceof Error ? caught.message : String(caught)); + }, []); + // Saved views, favorites and the trackers' projects. + const workspace = useWorkspace(tick, fail); + + // ── data ──────────────────────────────────────────────────────────────── + // Fetch the pod screen's chunk while the state loads, so a Lambda opens + // without waiting on it. + useEffect(() => { + // A failed prefetch isn't lost: the lazy screen's own import fails the + // same way and its ChunkBoundary says so. + podScreen().catch(() => undefined); + }, []); + + const stateFailures = useRef(0); + + useEffect(() => { + const source = new EventSource('/api/events'); + source.onmessage = () => setTick((value) => value + 1); + source.onerror = () => + uiLog('events-error', { readyState: source.readyState }); + source.onopen = () => uiLog('events-open'); + // Belt and braces: the event stream runs through the Tailscale proxy and + // can go quiet without closing, leaving the page on an old state (an + // answer sat behind "Working…" that way). Re-read every 20s while visible. + const poll = window.setInterval(() => { + if (document.visibilityState === 'visible') { + setTick((value) => value + 1); + } + }, 20_000); + + return () => { + source.close(); + window.clearInterval(poll); + }; + }, []); + + useEffect(() => { + // A failed read retries shortly rather than leaving the page stale until + // the next change happens to arrive. + const retry = { timer: 0 }; + api + .state() + .then((next) => { + if (stateFailures.current > 0) { + uiLog('state-recovered', { after: stateFailures.current }); + stateFailures.current = 0; + } + + setState(next); + }) + .catch((caught: unknown) => { + stateFailures.current += 1; + uiLog('state-failed', { + n: stateFailures.current, + error: caught instanceof Error ? caught.message : String(caught), + }); + retry.timer = window.setTimeout( + () => setTick((value) => value + 1), + 3_000 + ); + }); + + return () => window.clearTimeout(retry.timer); + }, [tick]); + + /** A pod just started here, until the state has it. */ + const justCreated = useRef<string | null>(null); + + // A remembered pod that no longer exists (deleted elsewhere): back to Auto. + // One just created isn't gone — the state hasn't caught up with it yet. + useEffect(() => { + if (state === null || view === null) { + return; + } + + if (state.pods.some((summary) => summary.pod.id === view)) { + if (justCreated.current === view) { + justCreated.current = null; + } + + return; + } + + if (justCreated.current !== view) { + setView(null, 'pod-gone'); + } + }, [state, view, setView]); + + // Drop local hides the server has caught up with, so an item can come back + // later (e.g. a parked conversation the fork has since replied to). + useEffect(() => { + if (state === null) { + return; + } + + const live = new Set(state.inbox.map(keyOf)); + setHidden( + (previous) => new Set([...previous].filter((key) => live.has(key))) + ); + }, [state]); + + // Empty inbox → something to review: chime. Never on first load (that's + // not a change), and counted across all pods, whatever page is open. + const waiting = + state === null + ? null + : state.inbox.filter((entry) => !hidden.has(keyOf(entry))).length; + const lastWaiting = useRef<number | null>(null); + + useEffect(() => { + if (waiting === null) { + return; + } + + if (lastWaiting.current === 0 && waiting > 0) { + playReadyChime(); + } + + lastWaiting.current = waiting; + }, [waiting]); + + // ⌘⏎ in the Chat panel hides the pod at once; the server holds it from the + // moment its message is in the thread (summary.chatSentAt) until the reply. + const holdingNow = useMemo( + () => + new Set( + chatPending + .filter((sent) => { + const sentAt = state?.pods.find( + (summary) => summary.pod.id === sent.podId + )?.chatSentAt; + const serverHasIt = + sentAt !== undefined && Date.parse(sentAt) >= sent.at - 60_000; + + return ( + sent.hold === true && + sent.failed !== true && + !serverHasIt && + Date.now() - sent.at < CHAT_HOLD_MS + ); + }) + .map((sent) => sent.podId) + ), + [chatPending, state] + ); + + /** + * What's waiting on them, every pod. A "Your turn" (the dev asked them + * something) is the latest message in that pod's chat: when the pod also + * has a real item up, it isn't a stop of its own — they see the item, and + * answer the dev in the chat beside it. + */ + const stops = useMemo(() => { + // A pod set aside is busy answering them: all of it waits, not just the + // item they left — its other items come back with it when it's done. + const live = (state?.inbox ?? []).filter( + (entry) => + !hidden.has(keyOf(entry)) && + !aside.some((item) => item.podId === entry.podId) + ); + + return live.filter( + (entry) => + entry.kind !== 'reply' || + !live.some( + (other) => + other.podId === entry.podId && + (other.kind === 'question' || other.kind === 'checkpoint') + ) + ); + }, [state, hidden, aside]); + + const visibleInbox = useMemo( + () => + stops.filter( + (entry) => + !holdingNow.has(entry.podId) && + (view === null || entry.podId === view) + ), + [stops, holdingNow, view] + ); + + // Every chat reply, from any pod, gets the chat chime — not on first load. + const chatReplied = useRef<Map<string, string> | null>(null); + + useEffect(() => { + if (state === null) { + return; + } + + const latest = new Map( + state.pods.flatMap((summary) => + summary.chatRepliedAt === undefined + ? [] + : [[summary.pod.id, summary.chatRepliedAt] as const] + ) + ); + const before = chatReplied.current; + + if ( + before !== null && + [...latest].some(([podId, at]) => (before.get(podId) ?? '') < at) + ) { + playChatChime(); + } + + chatReplied.current = latest; + }, [state]); + + const opened = trail.at(-1) ?? null; + const openedQuestion = + opened === null + ? undefined + : state?.questions[opened.podId]?.find( + (candidate) => candidate.id === opened.qid + ); + const openedEntry: InboxEntry | null = + opened === null || openedQuestion === undefined + ? null + : { + podId: opened.podId, + kind: + openedQuestion.kind === 'checkpoint' ? 'checkpoint' : 'question', + key: `${opened.podId}/q/${opened.qid}`, + qid: opened.qid, + // Its pre-rendered audio, when it is also in the inbox. + audio: state?.inbox.find( + (entry) => entry.key === `${opened.podId}/q/${opened.qid}` + )?.audio, + }; + // The queue: the server's priority order, with anything back from "Back + // when done" first — it was already their turn on it. + const queue = useMemo( + () => [ + ...returned.flatMap((key) => + visibleInbox.filter((entry) => keyOf(entry) === key) + ), + ...visibleInbox.filter((entry) => !returned.includes(keyOf(entry))), + ], + [visibleInbox, returned] + ); + + // The item being worked on STAYS until it's dealt with or skipped — a new + // arrival never replaces it. (The live entry, so its audio stays fresh.) + const heldEntry = + held === null + ? undefined + : visibleInbox.find((entry) => keyOf(entry) === held); + const current: InboxEntry | null = + openedEntry ?? + (pinned !== null && (view === null || pinned.podId === view) + ? pinned + : heldEntry ?? queue[0] ?? null); + const currentKey = current === null ? null : keyOf(current); + + // Hold whatever came up, and remember what was already waiting at that + // moment: anything that arrives after is "new" (the Up-next strip, badges). + useEffect(() => { + if (openedEntry !== null || pinned !== null) { + return; + } + + const head = heldEntry ?? queue[0]; + const key = head === undefined ? null : keyOf(head); + + if (key !== held) { + setHeld(key); + setSeen(new Set((state?.inbox ?? []).map(keyOf))); + } + }, [heldEntry, queue, held, openedEntry, pinned, state]); + + // Sent messages, kept 48 h, so one that never arrives can be put back. + const [sentLog, setSentLog] = useState<SentMessage[]>(loadSent); + const recordSent = useCallback( + (podId: string, itemKey: string | null, text: string): void => { + if (text.trim() === '') { + return; + } + + setSentLog((previous) => { + const next = [ + ...previous.filter( + (message) => Date.now() - message.at < SENT_KEEP_MS + ), + { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + podId, + itemKey, + text: text.trim(), + at: Date.now(), + }, + ]; + storeSent(next); + + return next; + }); + }, + [] + ); + const dismissSent = useCallback((id: string): void => { + setSentLog((previous) => { + const next = previous.map((message) => + message.id === id ? { ...message, dismissed: true } : message + ); + storeSent(next); + + return next; + }); + }, []); + + // Whatever you'd typed for this item comes back with it. + useEffect(() => { + if (currentKey !== null) { + setDraft(loadDraft(currentKey)); + } + }, [currentKey]); + + // "Back when done": a pod set aside comes back once its dev is done — it has + // answered them and its turn has ended — first in the queue, with a chime. + // The item they left may have left the inbox meanwhile (their chat reply + // answered it): the pod still waits for the turn to end, then its other + // items come back. Checked on every state change. + useEffect(() => { + if (state === null) { + return undefined; + } + + const live = new Set(state.inbox.map(keyOf)); + const podsNow = new Set(state.pods.map((summary) => summary.pod.id)); + const kept = aside.filter((item) => podsNow.has(item.podId)); + + setReturned((previous) => + previous.every((key) => live.has(key)) + ? previous + : previous.filter((key) => live.has(key)) + ); + + if (kept.length !== aside.length) { + setAside(() => kept); + } + + if (kept.length === 0) { + return undefined; + } + + const cancelled = { now: false }; + const podIds = [...new Set(kept.map((item) => item.podId))]; + + Promise.all( + podIds.map(async (podId) => { + const summary = state.pods.find((each) => each.pod.id === podId); + const podDetail = await api.pod(podId); + const done = summary?.devBusy !== true && !owesReply(podDetail.thread); + + return done ? podId : null; + }) + ) + .then((results) => { + const donePods = new Set(results.filter((id) => id !== null)); + + if (cancelled.now || donePods.size === 0) { + return; + } + + // The item they left, first in line — or, when it's gone (their reply + // answered it, or they stepped away from the chat itself), what the + // pod has waiting now: its answer. + const ready = podIds + .filter((podId) => donePods.has(podId)) + .flatMap((podId) => { + const left = kept + .filter((item) => item.podId === podId && live.has(item.key)) + .map((item) => item.key); + + return left.length > 0 + ? left + : state.inbox.filter((entry) => entry.podId === podId).map(keyOf); + }); + setAside((previous) => + previous.filter((item) => !donePods.has(item.podId)) + ); + setReturned((previous) => [ + ...previous, + ...ready.filter((key) => !previous.includes(key)), + ]); + playReadyChime(); + }) + .catch(() => undefined); + + return () => { + cancelled.now = true; + }; + }, [state]); + + /** + * Arrived since the current item came up — by priority (sidebar) order. + * Only what's really up: a pod set aside answering them has nothing new. + */ + const fresh = useMemo( + () => + stops.filter( + (entry) => !seen.has(keyOf(entry)) && keyOf(entry) !== currentKey + ), + [stops, seen, currentKey] + ); + const freshPods = useMemo( + () => new Set(fresh.map((entry) => entry.podId)), + [fresh] + ); + /** The strip offers only what this view can open (a pod page: its own items). */ + const freshHere = useMemo( + () => fresh.filter((entry) => view === null || entry.podId === view), + [fresh, view] + ); + // The chat keeps the pod it was on when nothing is on screen (all caught up), + // so sending a message never makes it vanish. + const [lastPod, setLastPod] = useState<string | null>(null); + const onScreenPod = current?.podId ?? view; + + /** The pod last on screen — never let go, for turning Auto off from all caught up. */ + const lastSeenPod = useRef<string | null>(null); + + useEffect(() => { + if (onScreenPod !== null) { + setLastPod(onScreenPod); + lastSeenPod.current = onScreenPod; + } + }, [onScreenPod]); + + const detailPodId = onScreenPod ?? lastPod; + + // Every Lambda across its whole life, and the sidebar's sections of them. + const lambdas = useMemo( + () => (state === null ? [] : lambdasOf(state)), + [state] + ); + const lambdaSections = useMemo( + // A shipped one leaves after a day: sorted afresh with each new state. + () => sidebarSections(lambdas, stops, Date.now()), + [lambdas, stops] + ); + /** + * The Lambda page on screen: one picked (while `view` is still its pod), + * or in Auto, the Lambda past planning whose build blocker or review is up. + */ + const pickedLambda = + lambdaOpen !== null && view === lambdaOpen.podId + ? lambdas.find((one) => one.key === lambdaOpen.key) + : undefined; + const autoLambda = + view === null && current !== null && current.tag !== undefined + ? lambdaOfEntry(lambdas, current) + : undefined; + /** The Lambda of the planning chat on screen: its tickets, its ticket link. */ + const detailLambda = lambdas.find( + (one) => one.planningPodId !== null && one.planningPodId === detailPodId + ); + const lambdaShown: Lambda | undefined = + page !== null + ? undefined + : pickedLambda !== undefined && pickedLambda.phase !== 'planning' + ? pickedLambda + : autoLambda !== undefined && autoLambda.phase !== 'planning' + ? autoLambda + : undefined; + + // Tell the server what's on screen the moment it changes: the audio queue + // re-prioritizes around it (an item, else a pod page) at Kokoro's next piece. + useEffect(() => { + api.focus(current?.podId ?? view, currentKey).catch(() => undefined); + }, [current?.podId, currentKey, view]); + + useEffect(() => { + if (detailPodId === null) { + setDetail(null); + + return undefined; + } + + // Retrying, so a pod left for another must not land on its page. + const live = { current: true }; + + untilFound(() => api.pod(detailPodId)) + .then((found) => { + if (live.current) { + setDetail(found); + } + }) + .catch((error: unknown) => { + if (live.current) { + fail(error); + } + }); + + return () => { + live.current = false; + }; + }, [detailPodId, tick, fail]); + + const question = + current === null + ? null + : state?.questions[current.podId]?.find( + (candidate) => candidate.id === current.qid + ) ?? null; + const pod = + state?.pods.find((summary) => summary.pod.id === current?.podId)?.pod ?? + null; + const inConversation = currentKey !== null && conversation === currentKey; + const podSummary = + state?.pods.find((summary) => summary.pod.id === current?.podId) ?? null; + + // A checkpoint's earlier versions (re-fetched when it gets a new one): its + // cards' drafts come from them (cardHistory.ts). + const cardPodId = question?.kind === 'checkpoint' ? question.podId : null; + const cardQid = question?.kind === 'checkpoint' ? question.id : null; + const cardVersion = question?.checkpoint?.version ?? 1; + const [cardVersions, setCardVersions] = useState<{ + qid: string; + list: Question[]; + } | null>(null); + + useEffect(() => { + if (cardPodId === null || cardQid === null) { + return; + } + + api + .versions(cardPodId, cardQid) + .then((list) => setCardVersions({ qid: cardQid, list })) + // An inbox server older than versions: there are none to show. + .catch(() => setCardVersions({ qid: cardQid, list: [] })); + }, [cardPodId, cardQid, cardVersion]); + + const olderVersions = + cardVersions !== null && cardVersions.qid === cardQid + ? cardVersions.list + : []; + // The version on screen: the current one unless they picked another (from + // the checkpoint's list or any card's). One version at a time, for the + // whole checkpoint. + const [viewingVersion, setViewingVersion] = useState<{ + qid: string; + version: number; + } | null>(null); + const oldCard = + viewingVersion !== null && viewingVersion.qid === cardQid + ? olderVersions.find( + (version) => + (version.checkpoint?.version ?? 1) === viewingVersion.version + ) ?? null + : null; + const shownCard = oldCard ?? question; + // The current version's decisions as worded now (a card posted before + // versions kept a copy has none of its own). + const liveDecisions = detail?.autonomous ?? []; + const currentCard = + question?.checkpoint === undefined || + question.checkpoint.decisionSnapshot !== undefined + ? question + : { + ...question, + checkpoint: { + ...question.checkpoint, + decisionSnapshot: liveDecisions.filter((decision) => + question.checkpoint?.decisionIds.includes(decision.id) + ), + }, + }; + const timeline = useMemo( + () => + buildTimeline( + currentCard === null || cardQid === null + ? [] + : [...olderVersions, currentCard] + ), + [olderVersions, currentCard, cardQid] + ); + /** The version the Plan rail is showing, for the timer (none: not shown). */ + const versionOnScreen = + cardQid !== null && rails.rail === 'plan' && !otherPage + ? shownCard?.checkpoint?.version ?? cardVersion + : undefined; + /** + * Switching versions from a card keeps that card where it was on screen: + * where it sat, read before the switch and restored after it. + */ + const anchor = useRef<{ card: string; top: number } | null>(null); + const viewVersion = (version: number | null, card?: string): void => { + const box = scrollRef.current; + const element = + card === undefined + ? null + : box?.querySelector<HTMLElement>(`[data-card="${CSS.escape(card)}"]`); + anchor.current = + box === null || + box === undefined || + element === null || + element === undefined || + card === undefined + ? null + : { + card, + top: + element.getBoundingClientRect().top - + box.getBoundingClientRect().top, + }; + pushNext.current = true; + setViewingVersion( + version === null || cardQid === null ? null : { qid: cardQid, version } + ); + }; + + // ── the address bar (shared/route.ts) ───────────────────────────────── + /** The next URL change is a step they took inside the same place: a new entry. */ + const pushNext = useRef(false); + /** The change came from Back/Forward: the URL is already right. */ + const fromHistory = useRef(false); + /** The URL the start route has been applied (once the state is in). */ + const [routeReady, setRouteReady] = useState(false); + /** Where they were, for "is this a new place?" (a page, Auto, or a Lambda). */ + const lastStop = useRef<string | null>(null); + + const lambdasNow = useMemo( + () => + (state?.pods ?? []).map((summary) => ({ + id: summary.pod.id, + name: podName(summary.pod), + slug: summary.pod.slug, + })), + [state?.pods] + ); + const slugs = useMemo(() => lambdaSlugs(lambdasNow), [lambdasNow]); + const cpOf = (entry: InboxEntry): number | undefined => + entry.qid === undefined + ? undefined + : state?.questions[entry.podId]?.find( + (candidate) => candidate.id === entry.qid + )?.checkpoint?.cp; + + /** A pod just made (from the box, or New project): open it. */ + const openCreated = (podId: string): void => { + // Its computer is busy on another project: it waits in the sidebar and + // starts by itself. + if (isWaitingPodId(podId)) { + showPage(null); + setTick((value) => value + 1); + + return; + } + + justCreated.current = podId; + showPage(null); + setView(podId, 'new-pod-created'); + setPinned(null); + setTick((value) => value + 1); + }; + + /** Show what a URL describes (on load, and on Back/Forward). */ + const applyRoute = (route: Route): void => { + if (state === null) { + return; + } + + showPage(pageOfRoute(route)); + setPinned(null); + setTrail([]); + setViewingVersion(null); + + if (route.page === 'tickets') { + if (route.project === undefined || route.ticket === undefined) { + setOpenTicket(null); + setTicketsScope({ kind: 'all' }); + setTicketsFilter(undefined); + + return; + } + + setOpenTicket({ projectId: route.project, ticketId: route.ticket }); + + return; + } + + if (route.page === 'project' || route.page === 'view') { + setOpenTicket(null); + setTicketsScope( + route.page === 'project' + ? { kind: 'project', codebaseId: route.codebase, name: route.name } + : { kind: 'view', viewId: route.id } + ); + setTicketsFilter(route.filter); + + return; + } + + if (route.page === 'work') { + const named = lambdasOf(state).find((one) => one.key === route.lambda); + const anchor = named === undefined ? null : anchorOf(named); + + if (named === undefined || anchor === null) { + showPage('new'); + + return; + } + + setView(anchor, 'url'); + setLambdaOpen({ key: named.key, podId: anchor }); + + return; + } + + if (route.page !== 'auto' && route.page !== 'lambda') { + return; + } + + const podId = + route.lambda === undefined + ? undefined + : lambdaFromSlug(route.lambda, lambdasNow); + + if (route.page === 'lambda' && podId === undefined) { + // A Lambda that's gone: the front door. + showPage('new'); + + return; + } + + setView(route.page === 'auto' ? null : podId ?? null, 'url'); + + if (route.page === 'auto') { + setHeld(null); + setLastPod(null); + } + + if (podId === undefined || route.item === undefined) { + return; + } + + const entry = state.inbox.find( + (candidate) => + candidate.podId === podId && + itemSlug(candidate, cpOf(candidate)) === route.item + ); + const questionNamed = questionFromSlug( + route.item, + state.questions[podId] ?? [] + ); + + if (entry !== undefined) { + setPinned(entry); + } else if (route.page === 'lambda' && questionNamed !== undefined) { + setTrail([{ podId, qid: questionNamed.id }]); + } + + if (route.version !== undefined && questionNamed !== undefined) { + setViewingVersion({ qid: questionNamed.id, version: route.version }); + } + }; + const applyRouteRef = useRef(applyRoute); + applyRouteRef.current = applyRoute; + + // The start URL, once there's something to resolve it against. + useEffect(() => { + if (state !== null && !routeReady) { + applyRoute(startRoute.current); + setRouteReady(true); + } + }, [state, routeReady]); + + // Back / Forward. + useEffect(() => { + const onPop = (): void => { + fromHistory.current = true; + applyRouteRef.current( + parseRoute(window.location.pathname, window.location.search) + ); + }; + window.addEventListener('popstate', onPop); + + return () => window.removeEventListener('popstate', onPop); + }, []); + + /** The URL for what's on screen now. */ + const itemPart = + current === null ? undefined : itemSlug(current, cpOf(current)); + const versionPart = + oldCard === null ? undefined : viewingVersion?.version ?? undefined; + const routeNow: Route | null = + state === null || !routeReady + ? null + : creating + ? { page: 'new' } + : showTime + ? { page: 'time' } + : showTickets + ? openTicket !== null + ? { + page: 'tickets', + project: openTicket.projectId, + ticket: openTicket.ticketId, + } + : ticketsScope.kind === 'project' + ? { + page: 'project', + codebase: ticketsScope.codebaseId, + name: ticketsScope.name, + filter: ticketsFilter, + } + : ticketsScope.kind === 'view' + ? { page: 'view', id: ticketsScope.viewId, filter: ticketsFilter } + : { page: 'tickets' } + : page === 'projects' + ? { page: 'projects' } + : page === 'views' + ? { page: 'views' } + : accountPage !== null + ? { page: 'account', which: accountPage } + : pickedLambda !== undefined && lambdaShown === pickedLambda + ? { page: 'work', lambda: pickedLambda.key } + : view === null + ? current === null + ? { page: 'auto' } + : { + page: 'auto', + lambda: slugs.get(current.podId) ?? current.podId, + item: itemPart, + version: versionPart, + } + : { + page: 'lambda', + lambda: slugs.get(view) ?? view, + item: + current !== null && current.podId === view ? itemPart : undefined, + version: versionPart, + }; + const urlNow = routeNow === null ? null : formatRoute(routeNow); + const stopNow = creating + ? 'new' + : showTime + ? 'time' + : showTickets + ? `tickets:${openTicket?.projectId ?? ''}:${openTicket?.ticketId ?? ''}:${ + ticketsScope.kind === 'project' + ? `${ticketsScope.codebaseId}/${ticketsScope.name}` + : ticketsScope.kind === 'view' + ? ticketsScope.viewId + : '' + }` + : page === 'projects' || page === 'views' + ? page + : accountPage !== null + ? accountPage + : pickedLambda !== undefined && lambdaShown === pickedLambda + ? `work:${pickedLambda.key}` + : view === null + ? 'auto' + : `lambda:${view}`; + + // Keep the address bar on what's on screen: a new place (a page, Auto, a + // Lambda) or a step they took is a new entry; Auto moving along the queue + // replaces the one they're on, so Back never lands on something done. + useEffect(() => { + if (urlNow === null) { + return; + } + + const here = window.location.pathname + window.location.search; + const push = + !fromHistory.current && + lastStop.current !== null && + (lastStop.current !== stopNow || pushNext.current); + fromHistory.current = false; + pushNext.current = false; + lastStop.current = stopNow; + + if (urlNow === here) { + return; + } + + if (push) { + window.history.pushState(null, '', urlNow); + + return; + } + + window.history.replaceState(null, '', urlNow); + }, [urlNow, stopNow]); + + useLayoutEffect(() => { + const held = anchor.current; + const box = scrollRef.current; + anchor.current = null; + + if (held === null || box === null) { + return; + } + + const element = box.querySelector<HTMLElement>( + `[data-card="${CSS.escape(held.card)}"]` + ); + + if (element !== null) { + box.scrollTop += + element.getBoundingClientRect().top - + box.getBoundingClientRect().top - + held.top; + } + }, [viewingVersion]); + + // What this item reads aloud — built exactly as the server pre-renders it. + const podLabel = podSummary === null ? 'A pod' : podName(podSummary.pod); + const detailMatches = detail !== null && detail.pod.id === current?.podId; + const sections = useMemo( + () => + current === null + ? null + : itemSections({ + kind: current.kind, + question, + thread: detailMatches ? detail.thread : [], + podLabel, + decisions: detailMatches ? detail.autonomous : [], + questions: state?.questions[current.podId] ?? [], + humanDecisions: detailMatches ? detail.human : [], + stalledReason: current.reason, + structureProposal: state?.pods.find( + (summary) => summary.pod.id === current.podId + )?.pod.structureProposal, + pod: state?.pods.find((summary) => summary.pod.id === current.podId) + ?.pod, + podNumber: + state === null + ? undefined + : podNumber(state.pods, state.priority, current.podId), + }), + [current, question, detail, detailMatches, podLabel, state] + ); + + const player = usePlayer({ + itemKey: currentKey, + podId: current?.podId ?? null, + sections, + audio: current?.audio, + title: sections?.[0]?.text.slice(0, 120) ?? 'Lambda', + artist: podLabel, + }); + + // ── the page's log (uiLog): what was on screen, and why it changed ────── + useEffect(() => { + uiLog('current', { + key: currentKey, + view, + pinned: pinned === null ? null : keyOf(pinned), + opened: openedEntry === null ? null : keyOf(openedEntry), + held, + queueHead: queue[0] === undefined ? null : keyOf(queue[0]), + queueLength: queue.length, + }); + // Only when the item itself changes — the rest is its context. + }, [currentKey]); + + const loggedInbox = useRef<string[] | null>(null); + useEffect(() => { + if (state === null) { + return; + } + + const keys = state.inbox.map(keyOf); + const before = loggedInbox.current; + loggedInbox.current = keys; + + if (before === null) { + uiLog('inbox', { keys }); + + return; + } + + const added = keys.filter((key) => !before.includes(key)); + const removed = before.filter((key) => !keys.includes(key)); + + if (added.length > 0 || removed.length > 0) { + uiLog('inbox', { added, removed, count: keys.length }); + } + }, [state]); + + useEffect(() => { + uiLog(player.playing ? 'audio-play' : 'audio-stop', { key: currentKey }); + }, [player.playing]); + + const sendToChat = ( + about: string | null, + text: string, + podId: string | null = detailPodId, + hold = false + ): void => { + if (podId === null) { + return; + } + + const id = `${Date.now()}`; + setChatPending((previous) => [ + ...previous.filter( + (sent) => sent.failed === true || Date.now() - sent.at < 10 * 60_000 + ), + { id, podId, about, text, at: Date.now(), ...(hold ? { hold } : {}) }, + ]); + // Straight to the dev (lambda pod chat), as Claude Code talks to its agent. + // A failed send (e.g. the app restarting) keeps the text, marked "not + // sent" with a Retry — never a "sending…" that waits forever. + api + .chat(podId, text, about) + .catch(() => + setChatPending((previous) => + previous.map((sent) => + sent.id === id ? { ...sent, failed: true } : sent + ) + ) + ); + }; + + const retryChat = (sent: PendingChat): void => { + setChatPending((previous) => + previous.filter((candidate) => candidate.id !== sent.id) + ); + // To the pod it was first sent to, whichever pod is open now. + sendToChat(sent.about, sent.text, sent.podId, sent.hold === true); + }; + + // Chat replies are read aloud (Autoplay on) as they land. + const chatSpoken = useRef<Set<string> | null>(null); + + useEffect(() => { + const replies = (detail?.thread ?? []).filter( + (message) => message.chat === true && message.from === 'fork' + ); + + // The first time: everything already there counts as heard. + if (chatSpoken.current === null) { + chatSpoken.current = new Set(replies.map((message) => message.id)); + + return; + } + + const heard = chatSpoken.current; + const fresh = replies.filter((message) => !heard.has(message.id)); + fresh.forEach((message) => heard.add(message.id)); + + if (player.autoplay) { + fresh.forEach((message) => player.sayReply(message.podId, message.id)); + } + }, [detail, player]); + + // Speak the fork's replies as they land. + const spoken = useRef<Set<string>>(new Set()); + const conversationMessages = useMemo( + () => + (detail?.thread ?? []).filter( + (message) => + current !== null && + current.qid !== undefined && + message.podId === current.podId && + message.qid === current.qid && + message.mode === 'sync' && + (message.from === 'human' || message.from === 'fork') + ), + [detail, current] + ); + + useEffect(() => { + const fresh = conversationMessages.filter( + (message) => message.from === 'fork' && !spoken.current.has(message.id) + ); + fresh.forEach((message) => spoken.current.add(message.id)); + + if (inConversation) { + fresh.forEach((message) => player.sayReply(message.podId, message.id)); + } + }, [conversationMessages, inConversation, player]); + + // Chat-like views (a "Your turn" item, a pod's thread) sit at the newest + // message, right above the composer; a question opens at its summary. + const scrollRef = useRef<HTMLDivElement>(null); + const chatLike = + current?.kind === 'reply' || (view !== null && current === null); + const threadLength = detail?.thread.length ?? 0; + + // A new item opens where they left it (planPlace.ts), or at its top. + const placeKey = useRef<string | null>(null); + placeKey.current = chatLike ? null : currentKey; + /** Hand scrolls (wheel/touch) — the audio's follow-along and restoring both yield. */ + const scrolledByHandAt = useRef(0); + /** While a place is being restored, the scrolls it causes aren't saved. */ + const restoringUntil = useRef(0); + /** Where they are in the item now, as a card and a distance into it. */ + const liveAnchor = useRef<ScrollAnchor | undefined>(undefined); + const shellReady = state !== null && loadingShown; + + // Once the app is up, the loading screen fades (styles.css) and goes. + useEffect(() => { + if (!shellReady) { + return undefined; + } + + const id = window.setTimeout(() => setLoadingGone(true), LOADING_FADE_MS); + + return () => window.clearTimeout(id); + }, [shellReady]); + + useEffect(() => { + const element = scrollRef.current; + + if (element === null || chatLike) { + return undefined; + } + + const saved = currentKey === null ? undefined : loadPlace(currentKey); + const top = saved?.scrollTop ?? 0; + liveAnchor.current = saved?.anchor; + // The card can still be laying out (its versions, times, images): keep + // putting them back on their card until it has held still a few frames, or + // they scroll themselves. Pixels only when there's no card to go by. + const startedAt = Date.now(); + restoringUntil.current = startedAt + RESTORE_MS; + const frame = { id: 0 }; + const still = { frames: 0, at: -1 }; + const place = (): void => { + const late = Date.now() - startedAt > RESTORE_MS; + const found = + saved?.anchor !== undefined && scrollToAnchor(element, saved.anchor); + + if (!found && (saved?.anchor === undefined || late)) { + element.scrollTop = top; + } + + still.frames = element.scrollTop === still.at ? still.frames + 1 : 0; + still.at = element.scrollTop; + + if (still.frames >= 3 || scrolledByHandAt.current > startedAt || late) { + restoringUntil.current = 0; + + return; + } + + frame.id = requestAnimationFrame(place); + }; + place(); + + return () => { + cancelAnimationFrame(frame.id); + restoringUntil.current = 0; + + // Leaving it (Next item, Auto…): keep where they were now, even if they + // scrolled a moment ago and the delayed save hasn't run. + if ( + currentKey !== null && + !chatLike && + liveAnchor.current !== undefined + ) { + savePlace(currentKey, { anchor: liveAnchor.current }); + } + }; + // `rail`: the Plan rail opening again comes back to the same place. + }, [currentKey, chatLike, view, rails.rail, shellReady]); + + // A new version of the card on screen (or anything else that reflows it) + // keeps them on the same card, the same distance in, before it's painted. + useLayoutEffect(() => { + const element = scrollRef.current; + const anchor = liveAnchor.current; + + if (element === null || chatLike || anchor === undefined) { + return; + } + + if (Date.now() >= restoringUntil.current) { + scrollToAnchor(element, anchor); + } + }, [shownCard, chatLike]); + + // Anything that changes the plan's height while they're reading — a version + // arriving, version marks or times filling in, an image loading — keeps them + // on their card. (Not while they're scrolling themselves.) + useEffect(() => { + const element = scrollRef.current; + + if (element === null) { + return undefined; + } + + const hold = new ResizeObserver(() => { + const anchor = liveAnchor.current; + + if ( + placeKey.current === null || + anchor === undefined || + Date.now() - scrolledByHandAt.current < 400 + ) { + return; + } + + scrollToAnchor(element, anchor); + }); + const watchAll = (): void => { + hold.disconnect(); + [...element.children].forEach((child) => hold.observe(child)); + }; + watchAll(); + const added = new MutationObserver(watchAll); + added.observe(element, { childList: true }); + + return () => { + hold.disconnect(); + added.disconnect(); + }; + }, [shellReady]); + + // A chat-like view follows its newest message. (A question card must NOT + // jump to the top on every thread change — that yanks the owner mid-listen.) + useEffect(() => { + const element = scrollRef.current; + + if (element !== null && chatLike) { + element.scrollTop = element.scrollHeight; + } + }, [currentKey, chatLike, threadLength, view]); + + // Follow the audio: each new section scrolls up to near the top — unless + // The owner scrolled by hand in the last few seconds (don't fight them). + + useEffect(() => { + const element = scrollRef.current; + + if (element === null) { + return undefined; + } + + const mark = (): void => { + scrolledByHandAt.current = Date.now(); + }; + const saving = { timer: 0 }; + const track = (): void => { + nearBottom.current = + element.scrollHeight - element.scrollTop - element.clientHeight < 80; + const key = placeKey.current; + + if (key === null || Date.now() < restoringUntil.current) { + return; + } + + liveAnchor.current = anchorAt(element); + window.clearTimeout(saving.timer); + saving.timer = window.setTimeout(() => { + if (placeKey.current === key && Date.now() >= restoringUntil.current) { + savePlace(key, { + scrollTop: element.scrollTop, + anchor: anchorAt(element), + }); + } + }, 300); + }; + element.addEventListener('wheel', mark, { passive: true }); + element.addEventListener('touchmove', mark, { passive: true }); + element.addEventListener('scroll', track, { passive: true }); + + return () => { + element.removeEventListener('wheel', mark); + element.removeEventListener('touchmove', mark); + element.removeEventListener('scroll', track); + window.clearTimeout(saving.timer); + }; + }, [shellReady]); + + // Live conversation: stay pinned to the newest message while the owner is at + // the bottom (a reply, or the thinking dots, arriving). Scrolled up to + // reread? Leave them there. Starting a conversation takes them down. + const nearBottom = useRef(true); + const liveChatLength = + inConversation && question !== null && detailMatches + ? itemChat(detail.thread, question.id).length + : 0; + + useEffect(() => { + const element = scrollRef.current; + + if (element !== null && inConversation) { + element.scrollTop = element.scrollHeight; + nearBottom.current = true; + } + }, [inConversation]); + + useEffect(() => { + const element = scrollRef.current; + + if (element !== null && inConversation && nearBottom.current) { + element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' }); + } + }, [liveChatLength, inConversation]); + + // Active planning time: credited to what's on screen; listening to audio or + // waiting on a live reply counts as active even without input. + const awaitingLiveReply = + inConversation && + question !== null && + detailMatches && + itemChat(detail.thread, question.id).at(-1)?.from === 'human'; + /** + * The card inside the item that has their attention, asked each second by the + * timer: the one being read aloud, or else the one taking up the most of the + * Plan rail. None when the rail isn't showing the item, or no card is in view. + */ + const cardAt = (): string | undefined => { + const box = scrollRef.current; + + if (versionOnScreen === undefined || box === null) { + return undefined; + } + + const reading = + player.playing && player.activeKey !== null + ? box + .querySelector(`[data-section="${CSS.escape(player.activeKey)}"]`) + ?.closest<HTMLElement>('[data-card]')?.dataset.card + : undefined; + + if (reading !== undefined) { + return reading; + } + + const view = box.getBoundingClientRect(); + const [best] = [...box.querySelectorAll<HTMLElement>('[data-card]')] + .map((card) => { + const rect = card.getBoundingClientRect(); + + return { + key: card.dataset.card, + visible: + Math.max( + 0, + Math.min(rect.bottom, view.bottom) - Math.max(rect.top, view.top) + ) * + Math.max( + 0, + Math.min(rect.right, view.right) - Math.max(rect.left, view.left) + ), + }; + }) + .filter((card) => card.visible > 0) + .sort((a, b) => b.visible - a.visible); + + return best?.key; + }; + const time = useActiveTime({ + cardAt, + scope: + current !== null + ? { + podId: current.podId, + qid: current.qid, + version: current.qid === cardQid ? versionOnScreen : undefined, + } + : view !== null + ? { podId: view } + : {}, + busy: player.playing || awaitingLiveReply, + summary: state?.time, + // Looking at your own stats isn't planning, and neither is an empty inbox + // (a pod's own page still counts — you can talk to its dev there). + paused: + showTime || listPage || (!creating && view === null && current === null), + }); + + /** Scroll the Plan rail so this section of the card is at the top. */ + const scrollToSection = (key: string): void => { + const element = scrollRef.current; + const target = element?.querySelector( + `[data-section="${CSS.escape(key)}"]` + ); + + if (element === null || !(target instanceof HTMLElement)) { + return; + } + + const offset = + target.getBoundingClientRect().top - element.getBoundingClientRect().top; + element.scrollTo({ + top: element.scrollTop + offset - 16, + behavior: 'smooth', + }); + }; + + // A section step they asked for (A/D, ⇧←/⇧→, ⏮/⏭) always brings the + // section's top to the top of the rail, even right after a hand scroll. + const steppedAt = useRef(0); + const [steps, setSteps] = useState(0); + const stepSection = (): void => { + steppedAt.current = Date.now(); + setSteps((count) => count + 1); + }; + + useEffect(() => { + const key = player.activeKey; + const asked = Date.now() - steppedAt.current < 1_500; + + if ( + key === null || + (!player.playing && !asked) || + (!asked && Date.now() - scrolledByHandAt.current < 4_000) + ) { + return; + } + + scrollToSection(key); + // Keyed on the section (and steps they ask for) only: progress ticks + // within a section must not re-scroll. + }, [player.activeKey, steps]); + + // An item coming up opens the Plan rail — unless they're watching the agents + // or working the player; then the Plan icon gets a dot instead. + const { rail, setRail, showPod, openIfShut } = rails; + const podOnScreen = current?.podId ?? view; + const podOnScreenRecord = state?.pods.find( + (summary) => summary.pod.id === podOnScreen + )?.pod; + const freeform = podOnScreenRecord?.mode === 'freeform'; + // A building pod on its own page (picked in the sidebar), not one whose + // item is up in the inbox or Auto: that one is in the planning screen. + const buildingPageShown = + !otherPage && + view !== null && + podOnScreen === view && + podOnScreenRecord !== undefined && + buildingScreenMode(podOnScreenRecord) !== null; + const firstRail = firstRailOf(podOnScreenRecord, buildingPageShown); + const podOnScreenKind = podOnScreenRecord?.kind; + // A Lambda just started isn't in the state yet: wait to learn its mode. + const podKnown = + podOnScreen === null || + state?.pods.some((summary) => summary.pod.id === podOnScreen) === true; + + // Each Lambda keeps its own rail: a structured one opens on Plan the first + // time, a freeform chat starts shut. Before the effects below, so an item + // coming up opens the rail of the Lambda it belongs to. A building pod has + // other rails on its own page than in the planning screen. + useLayoutEffect(() => { + if (podKnown) { + showPod( + podOnScreen, + firstRail, + buildingPageShown ? BUILDING_RAILS : planningRailsOf(podOnScreenKind) + ); + } + }, [podOnScreen, firstRail, podKnown, buildingPageShown, podOnScreenKind]); + + // A proposal to start structured planning opens the Plan rail: the pitch + // and its Start planning button live there. + const structureUp = current?.kind === 'structure'; + + useEffect(() => { + if (structureUp && rail !== 'plan') { + setRail('plan'); + } + // Keyed on the item coming up only: closing the rail after is their call. + }, [structureUp, currentKey]); + + // A freeform chat has no plan to show — each reply is a new item, so + // opening it there would pop the rail every turn. + useEffect(() => { + if (currentKey !== null && !freeform) { + openIfShut('plan'); + } + // Only when the item itself changes — closing the rail stays closed. + }, [currentKey]); + + // ── actions ───────────────────────────────────────────────────────────── + const advance = useCallback(() => { + if (currentKey !== null) { + setHidden((previous) => new Set([...previous, currentKey])); + // Sent: its draft is done with. + saveDraft(currentKey, ''); + } + + setPinned(null); + setTrail([]); + setDraft(''); + player.stop(); + + // Following the queue (Auto): let go of this pod's chat, so all caught + // up shows when nothing else is waiting. (A picked pod keeps its page.) + if (view === null) { + setLastPod(null); + } + }, [currentKey, player, view]); + + /** Where the item on screen sits in the queue (Auto mode's "2 of 3"). */ + const queueIndex = + currentKey === null + ? -1 + : queue.findIndex((entry) => keyOf(entry) === currentKey); + + /** Bring a waiting item up (‹ ›, "Go to it"): the queue itself is unchanged. */ + const goTo = useCallback( + (entry: InboxEntry | undefined) => { + if (entry === undefined || inConversation) { + return; + } + + setPinned(null); + setTrail([]); + setHeld(keyOf(entry)); + setDraft(''); + player.stop(); + }, + [inConversation, player] + ); + + /** ‹ › (P / N): step through the waiting items in priority order. */ + const browse = useCallback( + (step: 1 | -1) => goTo(queue[queueIndex + step]), + [goTo, queue, queueIndex] + ); + + // The hotkey handler is installed once; it reads the latest from here. + const browseRef = useRef(browse); + browseRef.current = browse; + + /** Follow a link to another item of the same pod (open or closed). */ + const openItem = useCallback( + (podId: string, qid: string) => { + pushNext.current = true; + setTrail((previous) => [...previous, { podId, qid }]); + setDraft(''); + player.stop(); + }, + [player] + ); + + const back = (): void => { + setTrail((previous) => previous.slice(0, -1)); + setDraft(''); + player.stop(); + }; + + const onDraftChange = (value: string): void => { + setDraft(value); + saveDraft(currentKey, value); + }; + + /** "Done — carry on": stop the back-and-forth; the pod proceeds on its own. */ + const carryOn = (): void => { + if (current?.kind !== 'reply') { + return; + } + + api.carryOn(current.podId).catch(fail); + advance(); + }; + + const nudge = (): void => { + if (current?.kind !== 'stalled') { + return; + } + + api.nudge(current.podId).catch(fail); + advance(); + }; + + const acceptRec = (): void => { + if ( + question === null || + question.recommendation === undefined || + inConversation + ) { + return; + } + + api.acceptRec(question.podId, question.id).catch(fail); + advance(); + }; + + /** They've said something to this pod since the item came up. */ + const discussed = + question !== null && + detailMatches && + detail.thread.some( + (message) => + message.from === 'human' && + message.text.trim() !== '' && + message.createdAt > question.createdAt + ); + + /** The option Done goes with: the one they clicked, else the recommendation. */ + const pickedOption = + question === null || question.options.length === 0 + ? undefined + : pick !== null && pick.key === currentKey + ? pick.optionId + : question.recommendation?.optionId; + + /** Send words to the dev as their last word on an item, kept for "Put it back". */ + const lastWord = (podId: string, text: string): Promise<unknown> => { + if (text === '') { + return Promise.resolve(); + } + + recordSent(podId, currentKey, text); + + return api.chat(podId, text); + }; + + /** + * ⌘⏎, or the Plan rail's Done: finished with the item that's up. What's + * typed goes along as the last word. A question is answered with what was + * said (nothing said: the recommendation); a checkpoint is approved; "Your + * turn" carries on; a stalled pod is nudged. With nothing up, it just sends. + */ + const done = (typed: string): void => { + const text = typed.trim(); + + // An earlier version is for reading: Done takes them back to the current + // one rather than approving something they aren't looking at (anything they + // typed still goes to the chat). + if (oldCard !== null) { + setViewingVersion(null); + + if (text !== '') { + sendToChat(oldCard.id, text); + } + + return; + } + + if (current === null || otherPage) { + if (text !== '') { + sendToChat(null, text); + } + + return; + } + + if (current.kind === 'stalled') { + lastWord(current.podId, text).catch(fail); + nudge(); + + return; + } + + // Start structured planning, as the dev proposed (with their last word). + if (current.kind === 'structure') { + const { podId } = current; + (text === '' ? Promise.resolve() : lastWord(podId, text)) + .then(() => api.structure(podId)) + .catch(fail); + advance(); + + return; + } + + if (current.kind === 'reply') { + if (text === '') { + carryOn(); + + return; + } + + lastWord(current.podId, text).catch(fail); + advance(); + + return; + } + + if (question === null || question.status !== 'open') { + if (text !== '') { + sendToChat(null, text); + } + + return; + } + + const { checkpoint } = question; + + if (question.kind === 'checkpoint' && checkpoint !== undefined) { + const { podId } = question; + lastWord(podId, text) + .then(() => api.checkpointComplete(podId, checkpoint.cp)) + .catch(fail); + advance(); + + return; + } + + // An item with options: go with the one picked (the rec by default). + if (pickedOption !== undefined) { + if (text === '' && pickedOption === question.recommendation?.optionId) { + acceptRec(); + + return; + } + + recordSent(question.podId, currentKey, text); + api + .answer( + question.podId, + question.id, + [`Go with ${pickedOption}.`, text] + .filter((part) => part !== '') + .join('\n\n') + ) + .catch(fail); + advance(); + + return; + } + + if (text === '' && !discussed) { + return; + } + + recordSent(question.podId, currentKey, text); + api + .answer( + question.podId, + question.id, + text === '' ? 'Done — go with what we just discussed.' : text + ) + .catch(fail); + advance(); + }; + + /** + * "Back when done" is offered while the dev owes them something on this + * item: they've said something since it came up, and the dev is mid-turn. + */ + const itemWait = + current !== null && + openedEntry === null && + // A question: once they've written on it. Start planning and Your turn + // have no question behind them, so the dev's turn alone is enough. + (question === null || (question.status === 'open' && discussed)) && + // Mid-turn: the server's turn state, else (an older server) their last + // message still unanswered. Never the pod's status — a pod with an item + // waiting reads "Needs you" even while its dev works. + (podSummary?.devBusy === true || + (detailMatches && owesReply(detail.thread))); + /** + * A chat on screen with no item behind it (their reply answered the item — + * every freeform turn): its dev is working on what they said, so they can step + * away from the chat itself. + */ + const chatWait = + current === null && + detailPodId !== null && + (state?.pods.find((summary) => summary.pod.id === detailPodId)?.devBusy === + true || + (detail !== null && + detail.pod.id === detailPodId && + owesReply(detail.thread))); + const canWait = itemWait || chatWait; + + /** Something else is waiting, for "Next item" to go to. */ + const othersWaiting = stops.some((entry) => keyOf(entry) !== currentKey); + + /** Set the item aside until the dev's turn ends; the queue moves on. */ + const later = (): void => { + // The item they're on, else the chat they're in (no item: keyed on the pod). + const podId = current?.podId ?? detailPodId; + const key = currentKey ?? (podId === null ? null : `${podId}/chat`); + + if (!canWait || podId === null || key === null) { + return; + } + + setAside((previous) => [ + ...previous.filter((item) => item.key !== key), + { key, podId, at: Date.now() }, + ]); + setReturned((previous) => + previous.filter((candidate) => candidate !== key) + ); + setPinned(null); + setTrail([]); + setHeld(null); + setDraft(''); + player.stop(); + // Done with this one for now: follow the queue to whatever's next, and + // let go of its chat so all-caught-up is what shows if nothing is. + setView(null, 'later'); + setLastPod(null); + }; + + const laterRef = useRef(later); + laterRef.current = later; + const toggleSidebarRef = useRef(() => toggleSidebar(!sidebarCollapsed)); + toggleSidebarRef.current = () => toggleSidebar(!sidebarCollapsed); + const toggleRailRef = useRef(rails.toggleRail); + toggleRailRef.current = rails.toggleRail; + const toggleExpandedRef = useRef(rails.toggleExpanded); + toggleExpandedRef.current = rails.toggleExpanded; + + const sendChat = (): void => { + const text = chatDraft.trim(); + + if (detailPodId === null || text === '') { + return; + } + + recordSent(detailPodId, null, text); + api.chat(detailPodId, text).catch(fail); + setChatDraft(''); + }; + + /** + * Sent from this browser but never saved in the pod's thread (checked once + * it's had ARRIVAL_GRACE_MS to get there), for the current pod's page/item. + */ + const lostHere = useMemo(() => { + const podId = current?.podId ?? view; + + if (podId === null || detail === null || detail.pod.id !== podId) { + return []; + } + + const arrived = detail.thread.filter((message) => message.from === 'human'); + + return sentLog.filter( + (message) => + message.podId === podId && + message.dismissed !== true && + Date.now() - message.at > ARRIVAL_GRACE_MS && + !arrived.some( + (entry) => + entry.text.trim() === message.text && + Date.parse(entry.createdAt) >= message.at - 60_000 + ) + ); + }, [sentLog, detail, current, view]); + + /** Put a lost message back where it was typed, so it can be sent again. */ + const restoreSent = (message: SentMessage): void => { + // Same item: back in its box. The pod's own page: its chat box. + // Anywhere else: the clipboard — never into the wrong item's box. + if (message.itemKey !== null && message.itemKey === currentKey) { + setDraft(message.text); + saveDraft(currentKey, message.text); + } else if (message.itemKey === null && current === null && view !== null) { + setChatDraft(message.text); + } else { + navigator.clipboard + .writeText(message.text) + .then(() => + setError('Copied the lost message — paste it on the item it was for.') + ) + .catch(() => setError(`Lost message: ${message.text}`)); + } + + dismissSent(message.id); + }; + + // Esc in a text field leaves it (after the field's own Esc, e.g. Search + // clearing itself), so the single-key shortcuts work again. + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + const field = document.activeElement; + + if ( + event.key === 'Escape' && + field instanceof HTMLElement && + (field instanceof HTMLInputElement || + field instanceof HTMLTextAreaElement || + field.isContentEditable) + ) { + field.blur(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, []); + + // Playback shortcuts — only when not typing, and not while a prototype or + // picture is open over the inbox (it has its own keys). + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if ( + isTypingTarget(event.target) || + document.body.classList.contains('viewer-open') + ) { + return; + } + + if (event.key === ' ') { + event.preventDefault(); + + // A focused button (▶, a 🔊…) would also "click" on Space. + if (event.target instanceof HTMLElement) { + event.target.blur(); + } + + player.toggle(); + + return; + } + + // ←/→ ±5 s; ⇧←/⇧→ previous / next section. + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + const forward = event.key === 'ArrowRight'; + + // Shift: jump a whole section; plain: 5 seconds. + if (event.shiftKey) { + stepSection(); + + if (forward) { + player.next(); + + return; + } + + player.previous(); + + return; + } + + player.skip(forward ? 5 : -5); + + return; + } + + // WASD (no modifiers — ⌘S etc. stay the browser's): W/S speed, + // A/D previous / next section. + const letter = + event.metaKey || event.ctrlKey || event.altKey + ? '' + : event.key.toLowerCase(); + + if (letter === 'w' || letter === 's') { + event.preventDefault(); + player.setRate(player.rate + (letter === 'w' ? 0.25 : -0.25)); + + return; + } + + // N / P: the next / previous waiting item (the queue doesn't change). + if (letter === 'n' || letter === 'p') { + event.preventDefault(); + browseRef.current(letter === 'n' ? 1 : -1); + + return; + } + + // L: "Back when done", when it's offered. + if (letter === 'l') { + event.preventDefault(); + laterRef.current(); + + return; + } + + if (letter === 'a' || letter === 'd') { + event.preventDefault(); + + stepSection(); + + if (letter === 'd') { + player.next(); + + return; + } + + player.previous(); + + return; + } + + // ↑/↓: speed up / slow down by 0.25×. + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + player.setRate(player.rate + (event.key === 'ArrowUp' ? 0.25 : -0.25)); + + return; + } + + // M: maximize the right rail, or shrink it back. + if (letter === 'm') { + event.preventDefault(); + toggleExpandedRef.current(); + + return; + } + + // [ / ]: hide or bring back the sidebar / the right rail. + if (letter === '[') { + event.preventDefault(); + toggleSidebarRef.current(); + + return; + } + + if (letter === ']') { + event.preventDefault(); + toggleRailRef.current(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, [player]); + + // ── render ────────────────────────────────────────────────────────────── + // The loading screen sits over the app and fades out once it's ready; it + // is the same element (first child) before and after, so its λ doesn't + // restart mid-breath. + const loadingScreen = loadingGone ? null : ( + <div + className={`loading${shellReady ? ' loading--out' : ''}`} + role="status" + aria-label="Loading" + > + <svg + className="loading__lambda" + viewBox="0 0 32 32" + width="64" + height="64" + aria-hidden="true" + > + <path d="M9 6.5c3 0 4.2 1.6 5.4 4.4L21 25.5h3" /> + <path d="M15.4 14.2L8.5 25.5" /> + </svg> + </div> + ); + + if (state === null || !loadingShown) { + return <>{loadingScreen}</>; + } + + const forkTabs = (detail?.windows ?? []).filter((window) => + window.startsWith('fork-') + ); + const tabs = ['dev', 'manager', ...forkTabs]; + const activeTab = tabs.includes(rightTab) ? rightTab : 'dev'; + const viewPod = + view === null + ? null + : state.pods.find((summary) => summary.pod.id === view); + /** Waiting across every pod (the Auto count while a pod is picked). */ + const waitingCount = stops.length; + const position = + current === null + ? '' + : `${Math.max( + 1, + queue.findIndex((entry) => keyOf(entry) === currentKey) + 1 + )}/${queue.length}`; + /** + * The Up-next strip: what's come up since this item did — anything back + * from "Back when done" first (it was already their turn), then arrivals. + */ + // Not the Lambda they're on: its other items are already in front of them. + const backReady = returned.flatMap((key) => + key === currentKey + ? [] + : queue.filter( + (entry) => keyOf(entry) === key && entry.podId !== current?.podId + ) + ); + // A "Your turn" from the pod whose chat is on screen is already in front + // of them (it's the dev's latest message): not news. + const announce = freshHere.filter( + (entry) => !(entry.kind === 'reply' && entry.podId === detailPodId) + ); + const [firstFresh] = [...backReady, ...announce]; + const nameOfPod = (podId: string): string => { + const found = state.pods.find((summary) => summary.pod.id === podId); + + return found === undefined ? podId : podName(found.pod); + }; + const freshNames = [ + ...new Set(announce.map((entry) => nameOfPod(entry.podId))), + ]; + const freshLabel = (entry: InboxEntry): string => { + const question = + entry.qid === undefined + ? undefined + : state.questions[entry.podId]?.find( + (candidate) => candidate.id === entry.qid + ); + + if (entry.kind === 'reply') { + return 'Your turn'; + } + + if (entry.kind === 'stalled') { + return 'Stalled'; + } + + if (entry.kind === 'structure') { + return 'Structured planning'; + } + + return question === undefined ? 'An item' : itemTitle(question); + }; + const attention = + current !== null && + (current.kind === 'reply' || + current.kind === 'stalled' || + current.kind === 'structure'); + + const titlePodId = current?.podId ?? view ?? detailPodId; + const titlePod = + titlePodId === null + ? undefined + : state.pods.find((summary) => summary.pod.id === titlePodId); + const centerTitle = creating + ? '' + : showTime + ? 'Planning time' + : showTickets + ? 'Tickets' + : page === 'projects' + ? 'Projects' + : page === 'views' + ? 'Views' + : accountPage !== null + ? ACCOUNT_PAGE_TITLES[accountPage] + : titlePod === undefined + ? 'All caught up' + : withProjectTag(podName(titlePod.pod), titlePod.pod.id, state.lambda); + const detailLabel = + detail !== null && detail.pod.id === detailPodId + ? podName(detail.pod) + : 'this Lambda'; + const answering = + !otherPage && + !attention && + question !== null && + current !== null && + question.status === 'open'; + const replying = !otherPage && current !== null && current.kind === 'reply'; + const audioTrouble = + player.needsGesture || + player.audioError !== null || + state.tts?.overBudget === true; + /** What the audio needs them to know (in the Plan rail's player drawer). */ + const audioNotices = + player.audioError !== null || + (player.source === 'unavailable' && current !== null) || + state.tts?.overBudget === true ? ( + <div className="audio-chips"> + {player.audioError !== null && ( + <button + type="button" + className="chip" + title="Dismiss" + onClick={player.dismissAudioError} + > + {player.audioError} ✕ + </button> + )} + {player.source === 'unavailable' && current !== null && ( + <span className="chip">{audioUnavailableReason(current.audio)}</span> + )} + {state.tts?.overBudget === true && ( + <span className="chip"> + Out of ElevenLabs audio (about ${state.tts.usd.toFixed(2)} used this + month) — no audio until your credits renew + </span> + )} + </div> + ) : null; + + /** The chat's box while an item is up: typed text answers it. */ + const isCheckpoint = + answering && question !== null && question.kind === 'checkpoint'; + const loneSplit = + isCheckpoint && + question?.checkpoint !== undefined && + offersKeepOne(question.checkpoint.cp, pod ?? undefined); + /** What ⌘⏎ (and the Plan rail's Done) does right now. */ + const doneLabel = + otherPage || current === null + ? null + : current.kind === 'stalled' + ? 'Nudge' + : current.kind === 'structure' + ? 'Start planning' + : replying + ? 'Done — carry on' + : !answering || question === null + ? null + : isCheckpoint && question.checkpoint !== undefined + ? loneSplit + ? 'Accept the split' + : `Approve checkpoint ${question.checkpoint.cp}` + : pickedOption !== undefined + ? `Go with ${pickedOption}` + : 'Done'; + const detailSummary = state.pods.find( + (summary) => summary.pod.id === detailPodId + ); + const devBusy = detailSummary !== undefined && isWorking(detailSummary); + const chatContext = devBusy ? ( + <>Lambda is working — it'll read this when its turn ends.</> + ) : undefined; + + /** The text in the chat box: the item's draft, or the pod's. */ + const boxDraft = current === null ? podDraft : draft; + const setBoxDraft = current === null ? setPodDraft : onDraftChange; + + /** + * The Plan rail's footer: Done (the same as ⌘⏎, with what's in the box), + * plus the other ways to finish — Go with the rec once they've talked it + * through, and Keep it one ticket on a lone ticket's split proposal. + */ + const planActions = + doneLabel === null ? null : ( + <> + {loneSplit && question?.checkpoint !== undefined && ( + <button + type="button" + className="ghost" + onClick={() => { + api + .checkpointComplete( + question.podId, + question.checkpoint?.cp ?? 3, + true + ) + .catch(fail); + advance(); + }} + > + Keep it one ticket + </button> + )} + <button + type="button" + className="primary done" + disabled={oldCard !== null} + title={ + oldCard === null + ? undefined + : 'You are looking at an earlier version — go back to the current one to approve (⌘⏎)' + } + onClick={() => { + done(boxDraft); + setBoxDraft(''); + }} + > + <span className="done__label">{doneLabel}</span> + <span className="kbd">⌘⏎</span> + </button> + </> + ); + + // The New pod and Time pages have no item: the Plan rail steps aside for + // them (and comes back after); the other rails stay useful there. + // So does all caught up: that page is the whole view. + const caughtUp = !otherPage && detailPodId === null; + const autoOn = view === null && !creating; + const detailPodKind = state.pods.find( + (summary) => summary.pod.id === detailPodId + )?.pod.kind; + // The Tickets screen is a page of its own, Linear's way: no rail beside it. + const shownRail = listPage + ? null + : planningRailOf(rail, { + offPod: otherPage || caughtUp, + buildingPod: detailPodKind === 'building', + }); + /** A building pod picked in the sidebar: it has its own page. */ + const buildingView = + otherPage || view === null + ? undefined + : state.pods.find( + (summary) => + summary.pod.id === view && buildingScreenMode(summary.pod) !== null + ); + const sidebarWidth = sidebarNarrow ? '56px' : '288px'; + const expanded = rails.expanded && shownRail !== null; + + /** A ticket's page, on the Tickets screen. */ + const openTicketPage = (projectId: string, ticketId: string): void => { + setOpenTicket({ projectId, ticketId }); + showPage('tickets'); + }; + + /** A pod's own page (a planning chat, a computer): Auto goes off. */ + const openPod = (podId: string, why: string): void => { + showPage(null); + setLambdaOpen(null); + setView(podId, why); + setPinned(null); + setTrail([]); + }; + + /** + * A Lambda's row: still planning (or a plan or chat that made no + * tickets), its planning chat; past that, its own page. + */ + const openLambda = (lambda: Lambda): void => { + if ( + lambda.planningPodId !== null && + (lambda.phase === 'planning' || lambda.phase === 'done') + ) { + openPod(lambda.planningPodId, 'sidebar-lambda'); + + return; + } + + const anchor = anchorOf(lambda); + + if (anchor === null) { + return; + } + + showPage(null); + setView(anchor, 'sidebar-lambda'); + setLambdaOpen({ key: lambda.key, podId: anchor }); + setPinned(null); + setTrail([]); + }; + + /** The Tickets list, scoped: all of it, a project's, or a view's. */ + const openTicketsScope = (scope: TicketsScope): void => { + setOpenTicket(null); + setTicketsScope(scope); + setTicketsFilter(undefined); + showPage('tickets'); + }; + + // The sidebar's Favorites, each named and drawn as what it stars. + const listUp = showTickets && openTicket === null; + const favoriteRows: FavoriteRow[] = workspace.favorites.flatMap( + (favorite): FavoriteRow[] => { + const { id, kind, ref } = favorite; + + if (kind === 'view') { + const saved = workspace.views?.find((one) => one.id === ref); + + return saved === undefined + ? [] + : [ + { + id, + kind, + ref, + label: saved.name, + emoji: iconEmoji(saved.icon), + color: null, + active: + listUp && + ticketsScope.kind === 'view' && + ticketsScope.viewId === ref, + }, + ]; + } + + if (kind === 'lambda') { + const starred = lambdas.find((one) => one.key === ref); + + return starred === undefined + ? [] + : [ + { + id, + kind, + ref, + label: starred.title, + emoji: null, + color: null, + active: lambdaShown?.key === ref, + }, + ]; + } + + const { codebaseId, name } = splitFavoriteRef(ref); + + if (kind === 'project') { + const tracked = workspace + .projectsOf(codebaseId) + .find((one) => one.name === name); + + return [ + { + id, + kind, + ref, + label: name, + emoji: iconEmoji(tracked?.icon), + color: tracked?.color ?? null, + active: + listUp && + ticketsScope.kind === 'project' && + favoriteRef(ticketsScope.codebaseId, ticketsScope.name) === ref, + }, + ]; + } + + return [ + { + id, + kind, + ref, + label: + favorite.label ?? + lambdas + .flatMap((one) => one.tickets) + .find((ticket) => ticket.id === name)?.title ?? + name, + emoji: null, + color: null, + active: + showTickets && + openTicket?.projectId === codebaseId && + openTicket.ticketId === name, + }, + ]; + } + ); + const openFavorite = (favorite: FavoriteRow): void => { + player.stop(); + + if (favorite.kind === 'view') { + openTicketsScope({ kind: 'view', viewId: favorite.ref }); + + return; + } + + if (favorite.kind === 'lambda') { + const starred = lambdas.find((one) => one.key === favorite.ref); + + if (starred !== undefined) { + openLambda(starred); + } + + return; + } + + const { codebaseId, name } = splitFavoriteRef(favorite.ref); + + if (favorite.kind === 'project') { + openTicketsScope({ kind: 'project', codebaseId, name }); + + return; + } + + openTicketPage(codebaseId, name); + }; + + const toggleAuto = (on: boolean): void => { + showPage(null); + setLambdaOpen(null); + setPinned(null); + setTrail([]); + + // On: straight to the top of the queue. Off: stay on the pod that's on + // screen, now picked. On also lets go of the last pod's chat, so all + // caught up shows if nothing's waiting. + if (on) { + setHeld(null); + setLastPod(null); + } + + setView( + on + ? null + : current?.podId ?? + detailPodId ?? + // All caught up: back to the pod they were last on, else the top of + // the sidebar. + (state.pods.some( + (summary) => summary.pod.id === lastSeenPod.current + ) + ? lastSeenPod.current + : orderPods(state.pods, state.priority)[0]?.pod.id ?? null), + on ? 'auto-on' : 'auto-off' + ); + }; + + return ( + <> + {loadingScreen} + <div + className={`layout${expanded ? ' layout--expanded' : ''}${ + sidebarSlide === null ? '' : ` layout--sidebar-${sidebarSlide}` + }`} + style={{ gridTemplateColumns: `${sidebarWidth} minmax(0, 1fr)` }} + > + <Sidebar + pods={state.pods.map((summary) => { + // The badge counts the stops they'll make, not the server's raw items. + const counted = { + ...summary, + needsYou: stops.filter( + (entry) => + entry.podId === summary.pod.id && entry.kind !== 'stalled' + ).length, + }; + + return holdingNow.has(summary.pod.id) + ? { + ...counted, + chatHold: true, + status: + summary.status === 'needs-you' ? 'working' : summary.status, + } + : counted; + })} + priority={state.priority} + activePodId={otherPage ? null : view ?? current?.podId ?? detailPodId} + auto={{ + // Starting a new Lambda steps out of the queue: Auto reads off + // there, and turning it on goes to what's waiting (or caught up). + on: autoOn, + index: autoOn ? queueIndex : -1, + total: autoOn ? queue.length : waitingCount, + onToggle: toggleAuto, + onPrev: () => browse(-1), + onNext: () => browse(1), + }} + asidePods={new Set(aside.map((item) => item.podId))} + // Picking a pod turns Auto off. + onSelectPod={(podId, via) => openPod(podId, `sidebar-${via}`)} + sections={lambdaSections} + activeLambdaKey={ + lambdaShown?.key ?? + lambdas.find( + (one) => + one.planningPodId !== null && + one.planningPodId === (current?.podId ?? view) + )?.key ?? + null + } + onSelectLambda={openLambda} + onMoveTicket={(moving, after) => { + if ( + moving.projectId === null || + moving.ticketId === null || + after.ticketId === null + ) { + return; + } + + api + .moveTicket(moving.projectId, moving.ticketId, after.ticketId) + .then(() => setTick((value) => value + 1)) + .catch(fail); + }} + onOpenComputer={(podId) => openPod(podId, 'sidebar-computer')} + checkpointOf={(entry) => cpOf(entry) ?? null} + onReorder={(order) => { + setState({ ...state, priority: order }); + api.priority(order).catch(fail); + }} + onNewPod={() => { + setNewProjectId(null); + showPage('new'); + }} + onPauseBuilding={(podId, pause) => { + (pause ? api.pause(podId) : api.resume(podId)).catch(fail); + }} + onOpenTerminal={(podId) => { + openPod(podId, 'sidebar-click'); + rails.setRail('agents'); + }} + onRename={(podId, title) => { + // Optimistic: show the new name now; the server's state confirms it. + setState({ + ...state, + pods: state.pods.map((summary) => + summary.pod.id === podId + ? { ...summary, pod: { ...summary.pod, title } } + : summary + ), + }); + api.rename(podId, title).catch(fail); + }} + onDelete={(podId) => { + // Optimistic: it leaves the sidebar now; the server's state confirms. + if (view === podId) { + setView(null, 'pod-deleted'); + } + + setState({ + ...state, + pods: state.pods.filter((summary) => summary.pod.id !== podId), + }); + api.deletePod(podId).catch(fail); + }} + freshPods={freshPods} + page={page} + collapsed={sidebarCollapsed} + closing={sidebarSlide === 'closing'} + onCollapse={toggleSidebar} + onOpenTickets={() => { + // The nav's Tickets is the whole list, never the last ticket page. + openTicketsScope({ kind: 'all' }); + }} + onOpenProjects={() => { + showPage('projects'); + player.stop(); + }} + onOpenViews={() => { + showPage('views'); + player.stop(); + }} + ticketsScoped={ticketsScope.kind !== 'all'} + favorites={favoriteRows} + onOpenFavorite={openFavorite} + onUnfavorite={(favorite) => + workspace.setFavorite(favorite.kind, favorite.ref, false) + } + onReorderFavorites={(ids) => + workspace.orderFavorites([ + ...ids, + ...workspace.favorites + .map((one) => one.id) + .filter((one) => !ids.includes(one)), + ]) + } + onOpenTime={() => { + showPage('time'); + player.stop(); + }} + lambda={state.lambda} + onOpenAccountPage={(opened) => { + showPage(opened); + player.stop(); + }} + onSignOut={() => { + api + .signOut() + .then(() => window.location.assign('/')) + .catch(fail); + }} + stopwatch={{ + ticking: time.ticking, + today: formatDuration(time.todayMs), + }} + /> + + {lambdaShown !== undefined ? ( + <Lazy + fallback={<p className="muted">Loading…</p>} + resetKey={lambdaShown.key} + > + <LambdaScreen + key={lambdaShown.key} + lambda={lambdaShown} + state={state} + rails={rails} + tick={tick} + starred={workspace.isFavorite('lambda', lambdaShown.key)} + onStar={() => + workspace.setFavorite( + 'lambda', + lambdaShown.key, + !workspace.isFavorite('lambda', lambdaShown.key) + ) + } + onOpenPod={(podId) => openPod(podId, 'lambda-page')} + onOpenTicket={(projectId, ticketId) => { + setOpenTicket({ projectId, ticketId }); + showPage('tickets'); + }} + onPlanNew={(projectId, text) => { + startNewDraft(text); + setNewProjectId(projectId); + showPage('new'); + }} + onRename={(podId, title) => { + setState({ + ...state, + pods: state.pods.map((summary) => + summary.pod.id === podId + ? { ...summary, pod: { ...summary.pod, title } } + : summary + ), + }); + api.rename(podId, title).catch(fail); + }} + onDelete={(podId) => { + setLambdaOpen(null); + setView(null, 'pod-deleted'); + setState({ + ...state, + pods: state.pods.filter( + (summary) => summary.pod.id !== podId + ), + }); + api.deletePod(podId).catch(fail); + }} + /> + </Lazy> + ) : buildingView !== undefined ? ( + <Lazy + fallback={<p className="muted">Loading…</p>} + resetKey={view ?? undefined} + > + <BuildingPodScreen + // One pod's page, state and all: switching pods starts afresh + // rather than showing the last pod's thread under this header. + key={buildingView.pod.id} + summary={buildingView} + lambda={state.lambda} + rails={rails} + tick={tick} + onOpenTicket={(projectId, ticketId) => { + setOpenTicket({ projectId, ticketId }); + showPage('tickets'); + }} + /> + </Lazy> + ) : ( + <ViewerProvider + podId={detailPodId} + refreshKey={ + detail !== null && detail.pod.id === detailPodId + ? detail.thread.length + : 0 + } + onAddToChat={(text) => + setBoxDraft( + boxDraft.trim() === '' ? `${text}\n` : `${boxDraft}\n${text}\n` + ) + } + > + {/* The chat and its rail share one white card, as in Claude Code. */} + <div + className="workspace" + style={{ + gridTemplateColumns: + shownRail === null || expanded + ? 'minmax(0, 1fr)' + : `minmax(${MIN_CHAT}px, 1fr) ${rails.width}px`, + }} + > + <main + className={`main${listPage ? ' main--tickets' : ''}`} + hidden={expanded} + > + <div className="toolbar" hidden={listPage}> + <h1 className="toolbar__title"> + {centerTitle} + {!otherPage && + detailLambda?.ticketId !== null && + detailLambda?.ticketId !== undefined && + detailLambda.projectId !== null && ( + <button + type="button" + className="toolbar__ticket" + title="Open the ticket" + onClick={() => { + if ( + detailLambda.projectId !== null && + detailLambda.ticketId !== null + ) { + openTicketPage( + detailLambda.projectId, + detailLambda.ticketId + ); + } + }} + > + {detailLambda.ticketId} + </button> + )} + </h1> + <div className="toolbar__right"> + {shownRail !== 'plan' && <MiniPlayer player={player} />} + <RailSwitcher + rails={rails} + shown={shownRail} + badges={{ + plan: current !== null || audioTrouble, + }} + ids={planningRailsOf(detailPodKind)} + /> + </div> + </div> + + {!otherPage && + detailPodId !== null && + detailPodKind !== 'building' && ( + <div className="lambda-page__strip"> + <PhaseStrip at="planning" qaOn={false} /> + </div> + )} + + {error !== null && ( + <button + type="button" + className="error" + onClick={() => setError(null)} + > + {error} (dismiss) + </button> + )} + + {otherPage && ( + <div className="main__scroll"> + <Lazy + fallback={<p className="muted">Loading…</p>} + resetKey={page ?? undefined} + > + {showTickets && ( + <TicketsPage + state={state} + tick={tick} + open={openTicket} + onOpen={setOpenTicket} + onPlan={(projectId, text) => { + startNewDraft(text); + setNewProjectId(projectId); + showPage('new'); + }} + onOpenPod={(podId) => openPod(podId, 'tickets')} + onOpenLambda={openLambda} + onChatAbout={(projectId, ticket) => { + startNewAbout(ticket); + setNewProjectId(projectId); + showPage('new'); + }} + scope={ticketsScope} + filter={ticketsFilter} + onFilter={setTicketsFilter} + workspace={workspace} + onOpenProjects={() => showPage('projects')} + onOpenViews={() => showPage('views')} + onOpenView={(viewId) => + openTicketsScope({ kind: 'view', viewId }) + } + /> + )} + + {page === 'projects' && ( + <ProjectsPage + workspace={workspace} + onOpenProject={(codebaseId, name) => + openTicketsScope({ + kind: 'project', + codebaseId, + name, + }) + } + /> + )} + + {page === 'views' && ( + <ViewsPage + workspace={workspace} + ownerName={state.lambda?.owner.name ?? 'You'} + onOpenView={(viewId) => + openTicketsScope({ kind: 'view', viewId }) + } + /> + )} + + {showTime && ( + <TimePage + time={state.time} + todayMs={time.todayMs} + pods={state.pods} + questions={state.questions} + onClose={() => showPage(null)} + onOpenItem={(podId, qid) => { + showPage(null); + openItem(podId, qid); + }} + /> + )} + + {accountPage === 'computers' && ( + <ComputersPage tick={tick} /> + )} + {accountPage === 'analytics' && ( + <AnalyticsPage lambda={state.lambda} tick={tick} /> + )} + {(accountPage === 'settings/devices' || + accountPage === 'settings/process' || + accountPage === 'settings/design-system') && ( + <SettingsPage + tab={settingsTabOf(accountPage)} + onTab={(tab) => showPage(`settings/${tab}`)} + lambda={state.lambda} + tick={tick} + /> + )} + </Lazy> + + {creating && ( + <NewPodForm + lambda={state.lambda} + startProjectId={newProjectId} + onProjectsChanged={() => setTick((value) => value + 1)} + onStarted={openCreated} + onCreate={async (request) => { + try { + const podId = await api.newPod(request); + clearNewDraft(); + openCreated(podId); + } catch (caught) { + fail(caught); + } + }} + /> + )} + </div> + )} + + {!otherPage && detailPodId !== null && ( + <Lazy fallback={null} resetKey={detailPodId ?? undefined}> + <ChatPanel + podLabel={detailLabel} + thread={ + detail !== null && detail.pod.id === detailPodId + ? detail.thread + : [] + } + questions={state.questions[detailPodId] ?? []} + pending={chatPending.filter( + (sent) => sent.podId === detailPodId + )} + draft={boxDraft} + onDraftChange={setBoxDraft} + onSend={(text) => + sendToChat( + current !== null && current.podId === detailPodId + ? current.qid ?? null + : null, + text + ) + } + onDone={done} + onRetry={retryChat} + context={chatContext} + whileWaiting={ + canWait ? ( + <div className="while-waiting"> + <LambdaMark working /> + {othersWaiting ? ( + <p> + <strong>Lambda is working on this.</strong> You + don't need to wait. It comes back to you, + first in line, as soon as Lambda's done. + </p> + ) : ( + <p> + <strong>Lambda is working on this.</strong>{' '} + Nothing else needs you right now. Step away and + this will chime when Lambda's done. + </p> + )} + <button + type="button" + className="ghost" + onClick={later} + > + {othersWaiting ? 'Next item →' : 'Step away'} + <span className="kbd">L</span> + </button> + </div> + ) : undefined + } + listening={player.chat} + onListen={(msgId, tracks) => + player.playChat(detailPodId, msgId, tracks) + } + /> + </Lazy> + )} + + {!otherPage && detailPodId === null && ( + <CaughtUp + line={caughtUpLine(lambdas)} + items={launchItems( + lambdas, + state.lambda, + state.pods, + Date.now() + )} + working={state.pods.some( + (summary) => + summary.devBusy === true || + // An older server doesn't say: an item set aside with + // "Next item" is waiting on its dev, so that pod is busy. + (summary.devBusy === undefined && + aside.some((item) => item.podId === summary.pod.id)) + )} + onNew={() => showPage('new')} + onPlan={(titles) => { + startNewDraft( + `Plan these Ready tickets: ${titles.join(', ')}` + ); + setNewProjectId(null); + showPage('new'); + }} + onResume={(podId) => api.resume(podId).catch(fail)} + onReassign={() => { + setOpenTicket(null); + showPage('tickets'); + }} + /> + )} + </main> + + {/* Plan stays mounted (hidden when another rail is up), so the item keeps + its scroll position and the audio's follow-along keeps its anchor. */} + <RailHost + rails={rails} + title="Plan" + hidden={shownRail !== 'plan'} + > + <div className="rail__scroll" ref={scrollRef}> + <Lazy fallback={null}> + {/* A message sent from here that never reached the pod. */} + {!otherPage && + lostHere.map((message) => ( + <div + key={message.id} + className="lost-message" + role="alert" + > + <span> + ⚠ A message you sent at{' '} + {new Date(message.at).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + })}{' '} + never arrived: “{message.text.slice(0, 120)} + {message.text.length > 120 ? '…' : ''}” + </span> + <button + type="button" + className="primary" + onClick={() => restoreSent(message)} + > + Put it back + </button> + <button + type="button" + className="ghost" + onClick={() => dismissSent(message.id)} + > + Dismiss + </button> + </div> + ))} + + {/* The Up-next strip: what's ready since this item came up. */} + {!otherPage && current !== null && openedEntry === null && ( + <div className="queue-bar"> + {firstFresh !== undefined && ( + <div className="up-next" role="status"> + <span className="up-next__dot" aria-hidden="true" /> + <span className="up-next__text"> + {backReady.includes(firstFresh) + ? `Ready again · ${nameOfPod( + firstFresh.podId + )} · ${freshLabel(firstFresh)}` + : announce.length === 1 + ? `New · ${nameOfPod( + firstFresh.podId + )} · ${freshLabel(firstFresh)}` + : `${announce.length} new · ${freshNames.join( + ', ' + )}`} + </span> + <button + type="button" + className="ghost" + title="Open it now — the item on screen stays in the queue" + onClick={() => goTo(firstFresh)} + > + Go to it + </button> + </div> + )} + </div> + )} + + {!otherPage && + attention && + current !== null && + pod !== null && + (current.kind === 'structure' ? ( + pod.structureProposal === undefined ? null : ( + <StructureCard + pod={pod} + proposal={pod.structureProposal} + /> + ) + ) : current.kind === 'reply' ? ( + <YourTurnCard + pod={pod} + open={(state.questions[current.podId] ?? []).filter( + (candidate) => candidate.status === 'open' + )} + onOpen={(qid) => openItem(current.podId, qid)} + /> + ) : ( + <StalledCard + pod={pod} + running={podSummary?.running ?? false} + reason={current.reason} + position={position} + /> + ))} + + {!otherPage && + !attention && + question !== null && + pod !== null && + current !== null ? ( + <> + {opened !== null && ( + <button + type="button" + className="link-button back" + onClick={back} + > + ← Back + </button> + )} + <QuestionCard + // Another version is a different card, not an edit to + // highlight as it arrives. + key={ + oldCard === null + ? question.id + : `${question.id}/v${ + oldCard.checkpoint?.version ?? 1 + }` + } + pod={pod} + question={shownCard ?? question} + readOnly={oldCard !== null} + notice={ + oldCard === null ? undefined : ( + <OldVersionNote + version={oldCard.checkpoint?.version ?? 1} + of={cardVersion} + onBack={() => viewVersion(null)} + /> + ) + } + times={time.itemTime(question.podId, question.id)} + versions={ + cardQid === question.id + ? { + timeline, + viewing: shownCard?.checkpoint?.version ?? 1, + onView: viewVersion, + } + : undefined + } + timeNote={ + cardQid === question.id ? ( + <VersionMenu + timeline={timeline} + viewing={shownCard?.checkpoint?.version ?? 1} + onView={(version) => viewVersion(version)} + itemMs={ + time.itemTime(question.podId, question.id) + .itemMs + } + versionMs={ + time.itemTime(question.podId, question.id) + .versionMs + } + cp={question.checkpoint?.cp} + /> + ) : undefined + } + extra={player.extra} + onPlayExtra={player.playExtra} + pickedOption={pickedOption} + onPickOption={(optionId) => + oldCard === null && + currentKey !== null && + question.status === 'open' && + setPick({ key: currentKey, optionId }) + } + onKeepJudgements={(indices) => + oldCard === null && + api + .keepJudgements( + question.podId, + question.id, + indices + ) + .then(() => setTick((value) => value + 1)) + .catch(fail) + } + discussed={ + detailMatches + ? detail.thread.filter( + (message) => + (message.qid === question.id || + message.about === question.id) && + message.text.trim() !== '' && + message.progress !== true && + (message.from === 'human' || + (message.from !== 'system' && + message.target === undefined)) + ).length + : 0 + } + onShowInChat={() => + document + .querySelector( + `.center-chat [data-topic="${CSS.escape( + question.id + )}"]` + ) + ?.scrollIntoView({ + block: 'start', + behavior: 'smooth', + }) + } + // The rail's title carries the position. + position="" + playing={player.activeKey} + onPlaySection={player.seekSection} + thread={detailMatches ? detail.thread : []} + questions={state.questions[current.podId] ?? []} + // An earlier version lists its decisions as they were. + decisions={ + oldCard?.checkpoint?.decisionSnapshot ?? + liveDecisions + } + onOpenItem={(qid) => openItem(current.podId, qid)} + status={ + opened === null + ? undefined + : itemStatus( + question, + state.questions[current.podId] ?? [], + detailMatches ? detail.thread : [] + ) + } + /> + </> + ) : null} + + {/* A pod's own page: its whole thread, unless an item of its is up. */} + {!otherPage && + view !== null && + detail !== null && + current === null && ( + <PodIdle + key={detail.pod.id} + summary={viewPod ?? null} + answering={aside.some((item) => item.podId === view)} + onAuto={() => toggleAuto(true)} + /> + )} + </Lazy> + </div> + + {!otherPage && current !== null && ( + <footer className="rail__actions"> + <Lazy fallback={null}> + <PlanPlayer + player={player} + notices={audioNotices} + actions={planActions} + onSeekSection={scrollToSection} + onStep={stepSection} + /> + </Lazy> + </footer> + )} + </RailHost> + + {shownRail === 'workflow' && ( + <RailHost rails={rails} title="Workflow"> + <div className="rail__scroll rail__scroll--workflow"> + <Lazy fallback={null} resetKey={detailPodId ?? undefined}> + <WorkflowPanel + podLabel={ + detail !== null && detail.pod.id === detailPodId + ? podName(detail.pod) + : null + } + view={ + detail !== null && detail.pod.id === detailPodId + ? detail.workflow + : null + } + onSkip={(stageId, label, writesLinear) => { + if (detailPodId === null) { + return; + } + + const ok = window.confirm( + writesLinear + ? `Skip "${label}"? This stage writes to your tickets (new tickets or labels) — skipping it means that work is NOT done in them.` + : `Skip "${label}"? This Lambda passes it by; a skipped checkpoint counts as closed.` + ); + + if (ok) { + api + .skip(detailPodId, stageId) + .then(() => setTick((value) => value + 1)) + .catch(fail); + } + }} + onStructure={() => { + if (detailPodId === null) { + return; + } + + if ( + window.confirm( + 'Switch to the structured flow? The pipeline starts from this conversation. (There is no switching back.)' + ) + ) { + api + .structure(detailPodId) + .then(() => setTick((value) => value + 1)) + .catch(fail); + } + }} + onRenderMode={(mode) => { + if (detailPodId === null) { + return; + } + + api + .renderMode(detailPodId, mode) + .then(() => setTick((value) => value + 1)) + .catch(fail); + }} + /> + </Lazy> + </div> + </RailHost> + )} + + {rail === 'agents' && ( + <RailHost rails={rails} title={`Agents · ${detailLabel}`} dark> + {detailPodId === null ? ( + <p className="rail__empty">Nothing on screen.</p> + ) : ( + <> + <Lazy fallback={null} resetKey={detailPodId ?? undefined}> + <WindowPicker + podId={detailPodId} + project={detail?.pod.projectId ?? detailLabel} + agents={tabs} + labelOf={planningWindowName} + fallback="dev" + onError={fail} + onChosen={setRightTab} + /> + </Lazy> + {activeTab === 'dev' && ( + <footer className="rail__dev-compose"> + <ImageDrop value={chatDraft} onChange={setChatDraft}> + <textarea + rows={2} + value={wordsOf(chatDraft)} + placeholder="Message the dev directly — it interrupts its work · ⏎ send · drop images here" + onChange={(event) => + setChatDraft( + withWords(chatDraft, event.target.value) + ) + } + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendChat(); + } + }} + /> + </ImageDrop> + </footer> + )} + </> + )} + </RailHost> + )} + + {shownRail === 'tickets' && ( + <RailHost rails={rails} title="Tickets"> + <div className="rail__scroll"> + <LambdaTicketsRail + lambda={detailLambda} + onOpenTicket={openTicketPage} + /> + </div> + </RailHost> + )} + + {rail === 'made' && ( + <RailHost rails={rails} title={`Made here · ${detailLabel}`}> + <Lazy fallback={null} resetKey={detailPodId ?? undefined}> + <MadeRail + thread={ + detail !== null && detail.pod.id === detailPodId + ? detail.thread + : [] + } + /> + </Lazy> + </RailHost> + )} + </div> + </ViewerProvider> + )} + </div> + </> + ); +}; diff --git a/src/app/client/AttentionCards.tsx b/src/app/client/AttentionCards.tsx new file mode 100644 index 0000000..03e16cf --- /dev/null +++ b/src/app/client/AttentionCards.tsx @@ -0,0 +1,100 @@ +import type { Pod, Question } from '../../pod-store'; +import { itemTitle } from '../shared/items'; +import { podName } from '../shared/podName'; + +/** + * "Your turn" in the Plan rail: the dev asked the owner something and waits on + * their reply. The question itself is the newest message in the chat beside + * this, so the rail doesn't repeat it: one line saying so, and the pod's + * open items, a click away. + */ +export const YourTurnCard = ({ + pod, + open, + onOpen, +}: { + pod: Pod; + /** The pod's open questions and checkpoints. */ + open: Question[]; + onOpen: (qid: string) => void; +}) => ( + <article className="card"> + <div className="card__meta"> + <span className="kind">Your turn</span> + <span>{podName(pod)}</span> + </div> + <p className="lead your-turn__lead"> + The dev asked you something — it's the latest message in the chat. + Reply there, or press Done to let it carry on. + </p> + {open.length > 0 && ( + <section className="pod-panel pod-items"> + <h3>Open items</h3> + <ul> + {open.map((question) => ( + <li key={question.id}> + <button + type="button" + className="link-button" + onClick={() => onOpen(question.id)} + > + {itemTitle(question)} + </button> + </li> + ))} + </ul> + </section> + )} + </article> +); + +/** + * "Stalled": the watchdog could not get the pod moving. With a `reason` it + * names what a human must do (a permission prompt, the usage limit, a hang); + * without one, nobody is working and nothing is waiting on the owner. + */ +export const StalledCard = ({ + pod, + running, + reason, + position, +}: { + pod: Pod; + running: boolean; + reason?: string; + position: string; +}) => ( + <article className="card"> + <div className="card__meta"> + <span className="kind">Stalled</span> + <span>{podName(pod)}</span> + <span className="card__position">{position}</span> + </div> + <section className="section section--playing"> + <header> + <h3> + {reason === undefined + ? 'Nobody is working on this' + : 'This Lambda needs a hand'} + </h3> + </header> + <p className="lead">{stalledLead(pod, running, reason)}</p> + </section> + </article> +); + +const stalledLead = ( + pod: Pod, + running: boolean, + reason: string | undefined +): string => { + if (reason !== undefined) { + return /permission answer/.test(reason) + ? `${reason}. Answer it in the agent's pane (the Dev / Manager tabs); a nudge can't.` + : `${reason}.`; + } + + return running + ? `Both the manager and the dev have been idle for over five minutes, and nothing is waiting on you. A nudge tells the manager to check where phase ${pod.phase} stands and carry on.` + : 'Its agents are not running (the session ended or the machine restarted without resuming it). A nudge resumes both, with their conversations intact.'; +}; diff --git a/src/app/client/BlockerCard.tsx b/src/app/client/BlockerCard.tsx new file mode 100644 index 0000000..c1540b4 --- /dev/null +++ b/src/app/client/BlockerCard.tsx @@ -0,0 +1,139 @@ +import { blockerChoices } from '../shared/lambdaPage'; + +/** + * A build blocked on the owner, answered in place: its question, its + * options as choices (the recommended one picked), Answer and carry on, and + * Plan more. The same card on a Lambda's page and its ticket's page. Its + * state is the page's: the player bar's main action answers it too. + */ + +export interface Blocker { + what: string; + problem: string; + options: string; + recommendation: string; +} + +/** The answer a card holds: the choice picked, else the words typed. */ +export const blockerAnswer = ( + blocker: Blocker | undefined, + choice: number | null, + words: string +): string => { + const { choices, recommended } = + blocker === undefined + ? { choices: [], recommended: -1 } + : blockerChoices(blocker.options, blocker.recommendation); + + return choices.length > 0 + ? choices[choice ?? recommended] ?? '' + : words.trim(); +}; + +export const BlockerCard = ({ + blocker, + computer, + fallback, + choice, + words, + busy, + onChoice, + onWords, + onAnswer, + onPlanMore, +}: { + /** The blocked run's question; undefined while it loads. */ + blocker: Blocker | undefined; + computer: string | null; + /** Shown while the blocked run is unknown (an older computer). */ + fallback?: React.ReactNode; + choice: number | null; + words: string; + busy: boolean; + onChoice: (index: number) => void; + onWords: (text: string) => void; + onAnswer: () => void; + onPlanMore: () => void; +}) => { + const { choices, recommended } = + blocker === undefined + ? { choices: [], recommended: -1 } + : blockerChoices(blocker.options, blocker.recommendation); + const chosen = choice ?? recommended; + const waiting = + computer === null + ? 'The build is waiting on this.' + : `${computer} is waiting on this.`; + + return ( + <article className="lambda-card lambda-card--blocked"> + <div className="lambda-card__top"> + <span className="lambda-card__badge">Blocked</span> + <strong> + {blocker?.problem ?? + (computer === null + ? 'The build is waiting on you' + : `${computer} is waiting on you`)} + </strong> + </div> + {blocker !== undefined ? ( + <p className="lambda-card__body"> + {blocker.what}. {waiting} + </p> + ) : ( + fallback + )} + {choices.length > 0 ? ( + <div + className="lambda-card__choices" + role="radiogroup" + aria-label="Your answer" + > + {choices.map((one, index) => ( + <label key={one} className="lambda-card__choice"> + <input + type="radio" + name="blocker" + checked={chosen === index} + onChange={() => onChoice(index)} + /> + {one} + {index === recommended && ( + <span className="lambda-card__rec">(recommended)</span> + )} + </label> + ))} + </div> + ) : ( + <textarea + className="lambda-card__answer" + rows={2} + value={words} + placeholder="Your answer…" + aria-label="Your answer" + readOnly={busy} + onChange={(event) => onWords(event.target.value)} + /> + )} + <div className="lambda-card__actions"> + <button + type="button" + className="primary" + disabled={ + busy || (choices.length > 0 ? chosen < 0 : words.trim() === '') + } + onClick={onAnswer} + > + Answer, and carry on + </button> + <button + type="button" + className="lambda-page__button" + onClick={onPlanMore} + > + Plan more + </button> + </div> + </article> + ); +}; diff --git a/src/app/client/BuildingPodScreen.tsx b/src/app/client/BuildingPodScreen.tsx new file mode 100644 index 0000000..ae0810a --- /dev/null +++ b/src/app/client/BuildingPodScreen.tsx @@ -0,0 +1,1022 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { + BuildTicketRun, + Screenshot, + ThreadMessage, +} from '../../pod-store'; +import { + type StepStatus, + formatMinutes, + thisTicketMs, + ticketTimeline, + wholeRun, +} from '../shared/buildTimeline'; +import { + type BuildingPodDetail, + NOTHING_READY_TO_BUILD, + type VisualIssueView, + type VisualPictureView, + type VisualRoundView, + buildingLine, + buildingRefreshIn, + buildingReplyItems, + buildingScreenMode, + currentRun, + headerBuilding, + pauseHeld, + railPlaceholder, + runShots, + takenTickets, + ticketsRail, + visualPictureUrl, + visualRail, +} from '../shared/building'; +import { buildingChat } from '../shared/conversation'; +import { podName } from '../shared/podName'; +import type { LambdaInfo, PodSummary } from '../shared/types'; +import { Markdown } from './Message'; +import { + BUILDING_RAILS, + MIN_CHAT, + RailHost, + RailSwitcher, + type Rails, +} from './Rails'; +import { WindowPicker } from './WindowPicker'; +import { api } from './api'; +import { errorText, latestLoader } from './loading'; + +/** + * A building pod's page: the planning shell, but the chat is with the + * read-only Product manager (the Engineering manager's progress lines show + * as small bubbles in it), and the rails are Workflow, Tickets and Agents. + * Nothing here is scraped from a terminal: it's all stored messages and + * events. + */ + +const ROLE_NAMES: Record<string, string> = { + 'engineering-manager': 'Engineering manager', + 'product-manager': 'Product manager', + dev: 'Dev', + qa: 'QA', + manager: 'Manager', + system: 'Lambda', +}; + +/** "Engineering manager", "Ask a1b2" for an ask fork's window. */ +export const roleName = (window: string): string => + ROLE_NAMES[window] ?? + (window.startsWith('ask-') ? `Ask ${window.slice(4)}` : window); + +/** A building pod's window: its Engineering manager runs in `manager`. */ +export const buildingWindowName = (window: string): string => + window === 'manager' + ? ROLE_NAMES['engineering-manager'] ?? window + : roleName(window); + +const elapsed = (from: string, now: number): number => + Math.max(0, now - Date.parse(from)); + +/** + * The answer box under an item waiting on the owner (a Blocked item): sent to + * the pod on that item, so it settles it, never to the Product manager. + */ +const ItemAnswer = ({ + onAnswer, +}: { + onAnswer: (text: string) => Promise<unknown>; +}) => { + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + + const send = (): void => { + const text = draft.trim(); + + if (text === '' || sending) { + return; + } + + setSending(true); + onAnswer(text) + .then(() => setDraft('')) + .catch(() => undefined) + .finally(() => setSending(false)); + }; + + return ( + <div className="build-answer"> + <textarea + rows={2} + value={draft} + placeholder="Answer this item…" + aria-label="Answer this item" + readOnly={sending} + onChange={(event) => setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + send(); + } + }} + /> + <button + type="button" + onClick={send} + disabled={sending || draft.trim() === ''} + > + Send + </button> + </div> + ); +}; + +/** + * One chat line: the owner's, the Product manager's, or a role's bubble. A + * bubble the owner owes an answer (`onAnswer`) carries its answer box. + */ +export const ChatLine = ({ + message, + onAnswer, +}: { + message: ThreadMessage; + onAnswer?: (text: string) => Promise<unknown>; +}) => { + if (message.from === 'human') { + return ( + <div className="msg msg--you"> + <Markdown text={message.text} /> + </div> + ); + } + + if (message.from === 'product-manager') { + return ( + <div className="msg msg--claude"> + <span className="msg__label">Lambda</span> + <Markdown text={message.text} /> + </div> + ); + } + + // Markdown, like the Product manager's: a Blocked item's four bold parts, + // and the visual check's expected | actual pictures when it blocked it. + const at = message.picturesAt; + + return ( + <div className="build-bubble"> + <span className="build-bubble__dot" aria-hidden="true"> + ◦ + </span> + <strong>Lambda:</strong> + <Markdown text={message.text} /> + {at !== undefined && (message.pictures ?? []).length > 0 && ( + <span className="build-bubble__pictures"> + {(message.pictures ?? []).map((file) => { + const url = visualPictureUrl( + message.podId, + at.ticketId, + at.round, + file + ); + + return ( + <a key={file} href={url} target="_blank" rel="noreferrer"> + <img + src={url} + alt={`Expected and actual: ${file + .replace(/^composite-/, '') + .replace(/\.png$/, '')}`} + /> + </a> + ); + })} + </span> + )} + {onAnswer !== undefined && <ItemAnswer onAnswer={onAnswer} />} + </div> + ); +}; + +/** One picture of a visual check round, opened from the Workflow rail. */ +export interface OpenPair { + ticketId: string; + round: VisualRoundView; + picture: VisualPictureView; +} + +const SEVERITY_NAMES: Record<VisualIssueView['severity'], string> = { + high: 'High', + medium: 'Medium', + 'medium-low': 'Medium-low', + low: 'Low', +}; + +/** "Medium · Legend: below the map, should be beside it". */ +export const visualIssueLine = (issue: VisualIssueView): string => + `${SEVERITY_NAMES[issue.severity]} · ${issue.where}: ${issue.what}`; + +/** A visual check pair, full size, with that round's issues for it. */ +export const VisualPairViewer = ({ + podId, + open, + onClose, +}: { + podId: string; + open: OpenPair; + onClose: () => void; +}) => { + const close = useRef<HTMLButtonElement | null>(null); + const closing = useRef(onClose); + closing.current = onClose; + + useEffect(() => { + close.current?.focus(); + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + closing.current(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, []); + + const { ticketId, round, picture } = open; + const url = (file: string): string => + visualPictureUrl(podId, ticketId, round.round, file); + + return ( + <div + className="dialog dialog--screenshot" + role="dialog" + aria-modal="true" + aria-label={`Visual check · round ${round.round}`} + > + <div className="dialog__head"> + <h2> + Visual check · round {round.round} · {picture.situation} + </h2> + <button type="button" className="ghost" onClick={onClose} ref={close}> + Close + </button> + </div> + <div className="visual-pair"> + <figure> + <figcaption>Expected</figcaption> + <img + className="screenshot__full" + src={url(picture.expected)} + alt="" + /> + </figure> + <figure> + <figcaption>Actual</figcaption> + <img className="screenshot__full" src={url(picture.actual)} alt="" /> + </figure> + </div> + {picture.issues.length === 0 ? ( + <p>No issues this round.</p> + ) : ( + <ul className="visual-pair__issues"> + {picture.issues.map((issue, index) => ( + // Two issues can read the same: keyed by place too. + <li key={`${index}:${issue.where}`}>{visualIssueLine(issue)}</li> + ))} + </ul> + )} + </div> + ); +}; + +/** A test screenshot, full size, with what the tester saw. */ +export const ScreenshotViewer = ({ + podId, + shot, + onClose, +}: { + podId: string; + shot: Screenshot; + onClose: () => void; +}) => { + const close = useRef<HTMLButtonElement | null>(null); + // The latest onClose, so a re-render doesn't move focus back to Close. + const closing = useRef(onClose); + closing.current = onClose; + + // Keyboard users land in it and can leave it: focus on Close, Escape closes. + useEffect(() => { + close.current?.focus(); + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + closing.current(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( + <div + className="dialog dialog--screenshot" + role="dialog" + aria-modal="true" + aria-label={`Test · attempt ${shot.attempt}`} + > + <div className="dialog__head"> + <h2> + Test · attempt {shot.attempt} · {shot.passed ? '✓' : '✗'} + </h2> + <button type="button" className="ghost" onClick={onClose} ref={close}> + Close + </button> + </div> + <img + className="screenshot__full" + src={screenshotUrl(podId, shot)} + alt={shot.conclusion} + /> + <p className="screenshot__url">{shot.url}</p> + {shot.consoleErrors.map((line, index) => ( + // Two console lines can read the same: keyed by place too. + <p key={`${index}:${line}`} className="screenshot__console"> + Console: {line} + </p> + ))} + <p>Tester: {shot.conclusion}</p> + </div> + ); +}; + +export const screenshotUrl = (podId: string, shot: Screenshot): string => + `/api/pod-file/${encodeURIComponent(podId)}/screenshots/${encodeURIComponent( + shot.ticketId + )}/${shot.attempt}/${encodeURIComponent(shot.file)}`; + +const Bar = ({ ms, max }: { ms: number; max: number }) => ( + <span className="build-bar" aria-hidden="true"> + <span + className="build-bar__fill" + style={{ width: `${max === 0 ? 0 : Math.round((ms / max) * 100)}%` }} + /> + </span> +); + +const STEP_MARKS: Record<StepStatus, string> = { + done: '✓', + active: '●', + todo: '○', + skipped: '–', +}; + +/** The Visual check row's pictures: one expected | actual pair per picture per round. */ +export const VisualPairs = ({ + podId, + ticketId, + rounds, + onPair, +}: { + podId: string; + ticketId: string; + rounds: VisualRoundView[]; + onPair: (open: OpenPair) => void; +}) => ( + <span className="build-step__pairs"> + {rounds.map((round) => ( + <span + key={round.round} + className="build-pairs" + title={`Round ${round.round}`} + > + {round.pictures.map((picture) => { + const missed = picture.issues.length > 0; + + return ( + <button + key={picture.scenarioId} + type="button" + className={`build-pair${missed ? ' build-pair--missed' : ''}`} + title={`Round ${round.round} · ${picture.situation}`} + // Its images are unlabelled, so without this the ✗ would be its name. + aria-label={`Round ${round.round} · ${picture.situation}${ + missed ? ' · has issues' : '' + }`} + onClick={() => onPair({ ticketId, round, picture })} + > + <img + src={visualPictureUrl( + podId, + ticketId, + round.round, + picture.expected + )} + alt="" + /> + <img + src={visualPictureUrl( + podId, + ticketId, + round.round, + picture.actual + )} + alt="" + /> + {missed && ( + <span className="build-shot__fail" aria-hidden="true"> + ✗ + </span> + )} + </button> + ); + })} + </span> + ))} + </span> +); + +export const WorkflowRail = ({ + detail, + now, + onShot, + onPair, + ticketId, +}: { + detail: BuildingPodDetail; + now: number; + onShot: (shot: Screenshot) => void; + onPair: (open: OpenPair) => void; + /** One ticket's newest run (a Lambda's page), not the pod's current one. */ + ticketId?: string; +}) => { + const [whole, setWhole] = useState(false); + const run = + ticketId === undefined + ? currentRun(detail.building) ?? detail.building.runs.at(-1) + : detail.building.runs.filter((one) => one.id === ticketId).at(-1); + const taken = useMemo(() => takenTickets(detail.building), [detail]); + // Worked out once per read or clock tick, not on every render. + const timeline = useMemo( + () => + run === undefined + ? null + : // Only this run of it, on this pod: a ticket blocked and taken + // again starts over, and a blocked run's time stops at its block. + ticketTimeline(detail.events, run.id, detail.steps, now, { + pod: detail.pod.id, + run, + }), + [detail, run, now] + ); + const total = useMemo( + () => + wholeRun(detail.events, taken, detail.steps, now, { + pod: detail.pod.id, + runs: detail.building.runs, + }), + [detail, taken, now] + ); + + if (run === undefined || timeline === null) { + return <p className="rail__empty">{NOTHING_READY_TO_BUILD}.</p>; + } + + const max = Math.max(0, ...timeline.steps.map((step) => step.ms)); + const thisMs = thisTicketMs(run, timeline.totalMs, now); + const typical = detail.typicalMs; + const scale = Math.max(thisMs, typical ?? 0); + const shotsOf = (attempt: number): Screenshot[] => + runShots(detail.screenshots, run, attempt); + // A page from a runner older than the visual check has no `visual`. + const visual = visualRail(detail.visual ?? {}, run); + + return ( + <div className="build-workflow"> + <h3 className="build-workflow__title"> + Workflow · {run.id} {run.title} + </h3> + {!whole && ( + <ol className="build-steps"> + {timeline.steps.map((step) => ( + <li + key={step.id} + className={`build-step build-step--${step.status}`} + > + <span className="build-step__mark" aria-hidden="true"> + {STEP_MARKS[step.status]} + </span> + <span className="build-step__label"> + {step.id === 'visual-check' && + step.status !== 'skipped' && + visual.label !== null + ? visual.label + : step.label} + </span> + <Bar ms={step.ms} max={max} /> + <span className="build-step__time"> + {step.ms > 0 ? formatMinutes(step.ms) : ''} + </span> + {step.id === 'test' && step.attempt > 0 && ( + <span className="build-step__shots"> + {Array.from({ length: step.attempt }, (_, index) => + shotsOf(index + 1) + ) + .flat() + .map((shot) => ( + <button + key={`${shot.attempt}/${shot.file}`} + type="button" + className={`build-shot${ + shot.passed ? '' : ' build-shot--failed' + }`} + title={shot.conclusion} + onClick={() => onShot(shot)} + > + <img src={screenshotUrl(detail.pod.id, shot)} alt="" /> + {!shot.passed && ( + <span className="build-shot__fail">✗</span> + )} + </button> + ))} + </span> + )} + {step.id === 'visual-check' && + step.status !== 'skipped' && + visual.rounds.length > 0 && ( + <VisualPairs + podId={detail.pod.id} + ticketId={run.id} + rounds={visual.rounds} + onPair={onPair} + /> + )} + {step.id === 'ship' && timeline.shipStages.length > 0 && ( + <span className="build-step__stages"> + {timeline.shipStages.join(' · ')} + </span> + )} + </li> + ))} + </ol> + )} + {whole && ( + <ol className="build-steps"> + {total.steps + .filter((step) => step.ms > 0) + .map((step) => ( + <li + key={step.id} + className="build-step build-step--done build-step--whole" + > + <span className="build-step__label">{step.label}</span> + <span className="build-step__time">{step.percent}%</span> + <Bar ms={step.ms} max={total.totalMs} /> + <span className="build-step__time"> + {formatMinutes(step.ms)} + </span> + </li> + ))} + <li className="build-step build-step--total"> + Total {formatMinutes(total.totalMs)} across {taken.length}{' '} + {taken.length === 1 ? 'ticket' : 'tickets'} + </li> + </ol> + )} + {!whole && ( + <dl className="build-compare"> + <dt>This ticket</dt> + <dd> + {formatMinutes(thisMs)} <Bar ms={thisMs} max={scale} /> + </dd> + {typical !== null && ( + <> + <dt>Typical ticket</dt> + <dd> + {formatMinutes(typical)} <Bar ms={typical} max={scale} /> + </dd> + </> + )} + </dl> + )} + <div className="segmented" role="group" aria-label="Timeline"> + <button + type="button" + aria-pressed={!whole} + className={whole ? '' : 'segmented--on'} + onClick={() => setWhole(false)} + > + This ticket + </button> + <button + type="button" + aria-pressed={whole} + className={whole ? 'segmented--on' : ''} + onClick={() => setWhole(true)} + > + Whole run + </button> + </div> + </div> + ); +}; + +const TicketsRail = ({ + detail, + now, + onOpenTicket, +}: { + detail: BuildingPodDetail; + now: number; + onOpenTicket: (ticketId: string) => void; +}) => { + const { done, current, upNext } = ticketsRail(detail.building); + // A Done ticket's time only changes with a new read. + const doneTimes = useMemo( + () => + new Map( + ticketsRail(detail.building).done.map((run) => { + const total = ticketTimeline( + detail.events, + run.id, + detail.steps, + now, + { pod: detail.pod.id, run } + ).totalMs; + + return [run.id, total === null ? '' : formatMinutes(total)]; + }) + ), + [detail, now] + ); + const timeOf = (run: BuildTicketRun): string => doneTimes.get(run.id) ?? ''; + const row = (id: string, title: string, mark: string, time: string) => ( + <li key={id}> + <button + type="button" + className="build-ticket" + onClick={() => onOpenTicket(id)} + > + <span className="build-ticket__mark" aria-hidden="true"> + {mark} + </span> + <span className="build-ticket__title"> + {id} {title} + </span> + <span className="build-ticket__time">{time}</span> + </button> + </li> + ); + + return ( + <div className="build-tickets"> + {done.length > 0 && ( + <> + <h3 className="build-tickets__head">Done</h3> + <ul>{done.map((run) => row(run.id, run.title, '✓', timeOf(run)))}</ul> + </> + )} + {current !== null && ( + <> + <h3 className="build-tickets__head">Current</h3> + <ul> + {row( + current.id, + current.title, + '●', + `${formatMinutes(elapsed(current.startedAt, now))}…` + )} + </ul> + </> + )} + <h3 className="build-tickets__head">Up next</h3> + {upNext.length === 0 ? ( + <p className="muted">Nothing else is ready.</p> + ) : ( + <ul>{upNext.map((next) => row(next.id, next.title, '', ''))}</ul> + )} + </div> + ); +}; + +const AgentsRail = ({ + detail, + project, + onError, +}: { + detail: BuildingPodDetail; + project: string; + onError: (caught: unknown) => void; +}) => ( + <WindowPicker + podId={detail.pod.id} + project={detail.pod.projectId ?? project} + agents={detail.windows} + labelOf={buildingWindowName} + fallback="manager" + onError={onError} + /> +); + +export const BuildingPodScreen = ({ + summary, + lambda, + rails, + tick, + onOpenTicket, +}: { + summary: PodSummary; + lambda: LambdaInfo | undefined; + rails: Rails; + tick: number; + /** The ticket page, on the Tickets screen. */ + onOpenTicket: (projectId: string, ticketId: string) => void; +}) => { + const podId = summary.pod.id; + const [detail, setDetail] = useState<BuildingPodDetail | null>(null); + const [error, setError] = useState<string | null>(null); + // What the person did that failed (a send, Pause, a terminal): kept until + // dismissed, where a load's error clears on the next good read. + const [actionError, setActionError] = useState<string | null>(null); + const failed = (caught: unknown): void => setActionError(errorText(caught)); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + /** The pod's status when Pause/Resume was clicked, while it's held. */ + const [pausingFrom, setPausingFrom] = useState< + PodSummary['pod']['status'] | null + >(null); + const [shot, setShot] = useState<Screenshot | null>(null); + const [pair, setPair] = useState<OpenPair | null>(null); + const [now, setNow] = useState(() => Date.now()); + const load = useMemo( + () => + latestLoader<BuildingPodDetail>({ value: setDetail, error: setError }), + [] + ); + + // Each app event ticks, and a busy project sends many: read at most every + // BUILDING_REFRESH_MS, a tick inside the wait read once it is up. + const loadedAt = useRef<number | null>(null); + + useEffect(() => { + const read = (): void => { + loadedAt.current = Date.now(); + void load(api.building(podId)); + }; + const wait = buildingRefreshIn(loadedAt.current, Date.now()); + + if (wait === 0) { + read(); + + return undefined; + } + + const timer = window.setTimeout(read, wait); + + return () => window.clearTimeout(timer); + }, [load, podId, tick]); + + // Released once the pod's status moves on, so a later toggle elsewhere + // (back to the clicked-from status) doesn't hold it again. + useEffect(() => setPausingFrom(null), [summary.pod.status]); + + // The current ticket's time counts up while it's on screen. + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 30_000); + + return () => window.clearInterval(id); + }, []); + + const projectId = lambda?.podProjects[podId] ?? summary.pod.projectId; + const project = + lambda?.projects.find((one) => one.id === projectId)?.name ?? + podName(summary.pod); + // "Talk about this ticket": a chat with the Product manager about one + // ticket. It builds nothing, so it has no Pause and no building rails. + const talk = buildingScreenMode(summary.pod) === 'talk'; + const building = talk + ? undefined + : headerBuilding(summary.building, detail?.summary); + const paused = summary.pod.status === 'paused'; + const pausing = pauseHeld(pausingFrom, summary.pod.status); + const rail = + !talk && rails.rail !== null && BUILDING_RAILS.includes(rails.rail) + ? rails.rail + : null; + const expanded = rails.expanded && rail !== null; + const thread = buildingChat(detail?.thread ?? []); + // The items the sidebar says wait on the owner, answerable here too. + const waiting = new Set( + detail === null + ? [] + : buildingReplyItems(detail.thread, talk ? null : detail.building).map( + (message) => message.id + ) + ); + + /** An answer on one item: to the pod, about that item (it settles it). */ + const answer = (messageId: string, text: string): Promise<unknown> => { + setActionError(null); + + return api + .chat(podId, text, messageId) + .then(() => load(api.building(podId))) + .catch((caught: unknown) => { + failed(caught); + + throw caught; + }); + }; + + const send = (): void => { + const text = draft.trim(); + + if (text === '' || sending) { + return; + } + + setSending(true); + setActionError(null); + api + .pmChat(podId, text) + .then(() => { + setDraft(''); + void load(api.building(podId)); + }) + .catch(failed) + .finally(() => setSending(false)); + }; + + const togglePause = (): void => { + if (pausing) { + return; + } + + setPausingFrom(summary.pod.status); + (paused ? api.resume(podId) : api.pause(podId)).catch((caught: unknown) => { + setPausingFrom(null); + failed(caught); + }); + }; + + return ( + <div + className="workspace" + style={{ + gridTemplateColumns: + rail === null || expanded + ? 'minmax(0, 1fr)' + : `minmax(${MIN_CHAT}px, 1fr) ${rails.width}px`, + }} + > + <main className="main" hidden={expanded}> + <div className="toolbar"> + <h1 className="toolbar__title"> + {project} + {building !== undefined && ( + <span className="muted"> · {buildingLine(building)}</span> + )} + {talk && summary.pod.talk !== undefined && ( + <span className="muted"> + {' '} + · ticket {summary.pod.talk.ticketId} + </span> + )} + </h1> + {!talk && ( + <div className="toolbar__right"> + <button + type="button" + className="ghost" + onClick={togglePause} + disabled={pausing} + > + {paused ? '▶ Resume' : '⏸ Pause'} + </button> + <RailSwitcher + rails={rails} + shown={rail} + badges={{}} + ids={BUILDING_RAILS} + /> + </div> + )} + </div> + {error !== null && ( + <button + type="button" + className="error" + onClick={() => setError(null)} + > + {error} (dismiss) + </button> + )} + {actionError !== null && ( + <button + type="button" + className="error" + onClick={() => setActionError(null)} + > + {actionError} (dismiss) + </button> + )} + <section className="center-chat"> + <div className="chat-panel__log"> + {thread.map((message) => ( + <ChatLine + key={message.id} + message={message} + onAnswer={ + waiting.has(message.id) + ? (text) => answer(message.id, text) + : undefined + } + /> + ))} + {building?.current === null && ( + <p className="chat__empty muted"> + {building.idleReason ?? NOTHING_READY_TO_BUILD} + </p> + )} + </div> + <footer className="chat-panel__compose"> + <div className="image-drop"> + <textarea + rows={1} + value={draft} + placeholder="Ask Lambda…" + // Not disabled: that drops focus after every send. + readOnly={sending} + onChange={(event) => setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + send(); + } + }} + /> + </div> + </footer> + </section> + </main> + + {rail === 'workflow' && ( + <RailHost rails={rails} title="Workflow"> + <div className="rail__scroll"> + {detail === null ? ( + <p className="rail__empty">{railPlaceholder(error)}</p> + ) : ( + <WorkflowRail + detail={detail} + now={now} + onShot={setShot} + onPair={setPair} + /> + )} + </div> + </RailHost> + )} + {rail === 'tickets' && ( + <RailHost rails={rails} title="Tickets"> + <div className="rail__scroll"> + {detail === null ? ( + <p className="rail__empty">{railPlaceholder(error)}</p> + ) : ( + <TicketsRail + detail={detail} + now={now} + onOpenTicket={(ticketId) => + projectId === undefined + ? undefined + : onOpenTicket(projectId, ticketId) + } + /> + )} + </div> + </RailHost> + )} + {rail === 'agents' && ( + <RailHost rails={rails} title={`Agents · ${project}`} dark> + {detail === null ? ( + <p className="rail__empty">{railPlaceholder(error)}</p> + ) : ( + <AgentsRail detail={detail} project={project} onError={failed} /> + )} + </RailHost> + )} + {shot !== null && ( + <ScreenshotViewer + podId={podId} + shot={shot} + onClose={() => setShot(null)} + /> + )} + {pair !== null && ( + <VisualPairViewer + podId={podId} + open={pair} + onClose={() => setPair(null)} + /> + )} + </div> + ); +}; diff --git a/src/app/client/CardVersions.tsx b/src/app/client/CardVersions.tsx new file mode 100644 index 0000000..6526d8c --- /dev/null +++ b/src/app/client/CardVersions.tsx @@ -0,0 +1,304 @@ +import { type ReactNode, useEffect, useRef, useState } from 'react'; + +import { + type CardChange, + type Timeline, + lastChange, +} from '../shared/cardHistory'; + +/** + * Versions of a checkpoint, on one timeline. The checkpoint's own dropdown + * lists every version (what changed, the time on it); each card inside — + * a screen, a ticket, a decision — says when it last changed ("changed v8") + * and opens the same list, with the versions that touched it marked. Picking + * any version shows the whole checkpoint as it was then, read-only. + */ + +const KIND_LABEL: Record<Timeline['versions'][number]['kind'], string> = { + posted: 'posted', + reposted: 're-posted', + edited: 'edited', +}; + +const CHANGE_LABEL = { + added: 'added', + changed: 'changed', + removed: 'removed', +}; + +/** 24m · 8m 30s · 45s · 1h 5m */ +export const formatSpent = (ms: number): string => { + const seconds = Math.round(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = seconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + if (minutes > 0) { + return rest > 0 && minutes < 10 ? `${minutes}m ${rest}s` : `${minutes}m`; + } + + return `${rest}s`; +}; + +/** today 9:12 am · Oct 16, 4:10 pm */ +const formatWhen = (iso: string, now: Date = new Date()): string => { + const at = new Date(iso); + const time = at + .toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + .toLowerCase(); + + return at.toDateString() === now.toDateString() + ? `today ${time}` + : `${at.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })}, ${time}`; +}; + +/** A button that opens a list below it; click outside or Esc closes it. */ +const Dropdown = ({ + className, + label, + title, + children, +}: { + className: string; + label: ReactNode; + title: string; + children: (close: () => void) => ReactNode; +}) => { + const [open, setOpen] = useState(false); + const box = useRef<HTMLSpanElement>(null); + + useEffect(() => { + if (!open) { + return undefined; + } + + const onPointer = (event: PointerEvent): void => { + if ( + !(event.target instanceof Node) || + box.current?.contains(event.target) !== true + ) { + setOpen(false); + } + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + window.addEventListener('pointerdown', onPointer); + window.addEventListener('keydown', onKey); + + return () => { + window.removeEventListener('pointerdown', onPointer); + window.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( + <span className="versions" ref={box}> + <button + type="button" + className={className} + aria-haspopup="listbox" + aria-expanded={open} + title={title} + onClick={() => setOpen(!open)} + > + {label} {open ? '▴' : '▾'} + </button> + {open && ( + <div className="versions__menu" role="listbox"> + {children(() => setOpen(false))} + </div> + )} + </span> + ); +}; + +/** The checkpoint's versions, at the top of the card. */ +export const VersionMenu = ({ + timeline, + viewing, + onView, + itemMs, + versionMs, + cp, +}: { + timeline: Timeline; + viewing: number; + /** Show this version (null: the current one). */ + onView: (version: number | null) => void; + itemMs: number; + versionMs: (version: number) => number; + cp: number | undefined; +}) => { + const current = timeline.versions.at(-1)?.version ?? 1; + + return ( + <span className="versions__line"> + <Dropdown + className="versions__pill" + label={`v${viewing}`} + title="Versions of this checkpoint" + > + {(close) => ( + <> + <div className="versions__head"> + <span> + Checkpoint {cp} · {timeline.versions.length}{' '} + {timeline.versions.length === 1 ? 'version' : 'versions'} + </span> + <span>{formatSpent(itemMs)} total</span> + </div> + {[...timeline.versions].reverse().map((meta) => ( + <button + key={meta.version} + type="button" + role="option" + aria-selected={meta.version === viewing} + className={`versions__row versions__row--touched${ + meta.version === viewing ? ' versions__row--on' : '' + }`} + onClick={() => { + onView(meta.version === current ? null : meta.version); + close(); + }} + > + <span className="versions__v">v{meta.version}</span> + <span className="versions__when"> + {meta.version === current ? 'current · ' : ''} + {formatWhen(meta.at)} · {KIND_LABEL[meta.kind]} + {meta.version === 1 + ? '' + : meta.changes === 0 + ? ' · no changes' + : ` · ${meta.changes} ${ + meta.changes === 1 ? 'change' : 'changes' + }`} + </span> + <span className="versions__ms"> + {formatSpent(versionMs(meta.version))} + </span> + </button> + ))} + </> + )} + </Dropdown> + <span>{formatSpent(itemMs)} on this checkpoint</span> + </span> + ); +}; + +/** + * One card's mark: "changed v8 · 2m ▾". The list is the checkpoint's whole + * timeline, with the versions that touched this card marked. + */ +export const CardMark = ({ + cardKey, + title, + timeline, + viewing, + onView, + msOf, +}: { + cardKey: string; + title: string; + timeline: Timeline; + viewing: number; + /** Show this version — keeping this card where it is on screen. */ + onView: (version: number | null, anchor: string) => void; + /** Active ms on this card: on these versions, or on all of them. */ + msOf: (versions?: number[]) => number; +}) => { + const last = lastChange(timeline, cardKey, viewing); + const touched = timeline.cards.get(cardKey) ?? new Map<number, CardChange>(); + const current = timeline.versions.at(-1)?.version ?? 1; + const totalMs = msOf(); + + if (last === undefined) { + return null; + } + + return ( + <Dropdown + className="versions__mark" + label={`${last.change} v${last.version}${ + totalMs >= 1000 ? ` · ${formatSpent(totalMs)}` : '' + }`} + title="When this card changed, and the time on it" + > + {(close) => ( + <> + <div className="versions__head"> + <span> + {title} · changed in {touched.size} of {timeline.versions.length} + </span> + <span>{formatSpent(totalMs)} total</span> + </div> + {[...timeline.versions].reverse().map((meta) => { + const change = touched.get(meta.version); + + return ( + <button + key={meta.version} + type="button" + role="option" + aria-selected={meta.version === viewing} + className={`versions__row${ + change === undefined ? '' : ' versions__row--touched' + }${meta.version === viewing ? ' versions__row--on' : ''}`} + onClick={() => { + onView( + meta.version === current ? null : meta.version, + cardKey + ); + close(); + }} + > + <span className="versions__v"> + {change === undefined ? '' : '● '}v{meta.version} + </span> + <span className="versions__when"> + {change === undefined + ? 'no change' + : `${CHANGE_LABEL[change]} · ${formatWhen(meta.at)}`} + {meta.version === current ? ' · current' : ''} + </span> + <span className="versions__ms"> + {formatSpent(msOf([meta.version]))} + </span> + </button> + ); + })} + </> + )} + </Dropdown> + ); +}; + +/** Above everything while an earlier version is on screen. */ +export const OldVersionNote = ({ + version, + of, + onBack, +}: { + version: number; + of: number; + onBack: () => void; +}) => ( + <div className="versions__old"> + <span> + Viewing v{version} of {of} · not current + </span> + <button type="button" className="link-button" onClick={onBack}> + Back to current → + </button> + </div> +); diff --git a/src/app/client/CaughtUp.tsx b/src/app/client/CaughtUp.tsx new file mode 100644 index 0000000..6f3f10e --- /dev/null +++ b/src/app/client/CaughtUp.tsx @@ -0,0 +1,174 @@ +import type { LaunchItem } from '../shared/caughtUp'; + +/** The λ, faint; it breathes while a Lambda is working. */ +export const LambdaMark = ({ working }: { working: boolean }) => ( + <svg + className={`lambda-mark${working ? ' lambda-mark--working' : ''}`} + viewBox="0 0 32 32" + width="40" + height="40" + aria-hidden="true" + > + <path d="M9 6.5c3 0 4.2 1.6 5.4 4.4L21 25.5h3" /> + <path d="M15.4 14.2L8.5 25.5" /> + </svg> +); + +const names = (titles: string[]): string => + titles.length <= 3 + ? titles.join(' · ') + : `${titles.slice(0, 3).join(' · ')} · +${titles.length - 3} more`; + +/** + * Nothing needs the owner: Auto comes here only after every build blocker, + * review, checkpoint, reply and stalled Lambda. One line of where things + * stand, then only what they could do, never what waits on them. + */ +export const CaughtUp = ({ + line, + items, + working, + onNew, + onPlan, + onResume, + onReassign, +}: { + /** "2 planning · 11 building · 4 queued". */ + line: string; + items: LaunchItem[]; + /** Anything mid-turn: the λ breathes. */ + working: boolean; + onNew: () => void; + onPlan: (titles: string[]) => void; + onResume: (podId: string) => void; + onReassign: () => void; +}) => ( + <div className="caught-up"> + <div className="caught-up__top"> + <LambdaMark working={working} /> + <h2 className="caught-up__title">All caught up</h2> + <p className="caught-up__line">{line}</p> + </div> + + <section className="launch" aria-label="Keep things moving"> + <h3 className="launch__head">Keep things moving</h3> + {items.map((item) => { + switch (item.kind) { + case 'new': + return ( + <div key="new" className="launch__row"> + <svg + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + aria-hidden="true" + > + <circle cx="12" cy="12" r="9" /> + <path d="M12 8v8M8 12h8" /> + </svg> + <span className="launch__text">Plan something new</span> + <button type="button" className="primary" onClick={onNew}> + New + </button> + </div> + ); + case 'unplanned': + return ( + <div key="unplanned" className="launch__row"> + <svg + width="18" + height="18" + viewBox="0 0 16 16" + aria-hidden="true" + > + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="#52514e" + strokeWidth="1.4" + /> + </svg> + <span className="launch__text"> + {item.tickets.length === 1 + ? '1 Ready ticket has no plan yet' + : `${item.tickets.length} Ready tickets have no plan yet`} + <span className="launch__sub"> + {names(item.tickets.map((one) => one.title))} + </span> + </span> + <button + type="button" + className="launch__button" + onClick={() => onPlan(item.tickets.map((one) => one.title))} + > + Plan + </button> + </div> + ); + case 'paused': + return ( + <div key={`paused/${item.computer}`} className="launch__row"> + <svg + width="18" + height="18" + viewBox="0 0 24 24" + fill="#9a5b0b" + aria-hidden="true" + > + <rect x="6" y="5" width="4" height="14" rx="1" /> + <rect x="14" y="5" width="4" height="14" rx="1" /> + </svg> + <span className="launch__text"> + {item.computer} is paused + <span className="launch__sub"> + {item.waiting === 1 + ? '1 ticket is waiting for it' + : `${item.waiting} tickets are waiting for it`} + </span> + </span> + <button + type="button" + className="launch__button" + onClick={() => onResume(item.podId)} + > + Resume + </button> + </div> + ); + default: + return ( + <div key={`offline/${item.computer}`} className="launch__row"> + <span className="launch__offline" aria-hidden="true" /> + <span className="launch__text"> + {item.computer} is offline + <span className="launch__sub"> + {item.assigned === 1 + ? '1 ticket is assigned to it' + : `${item.assigned} tickets are assigned to it`} + </span> + </span> + <button + type="button" + className="launch__button" + onClick={onReassign} + > + Reassign + </button> + </div> + ); + } + })} + </section> + + <p className="caught-up__note"> + Auto brings you here only when nothing needs you: build blockers, reviews, + checkpoints, replies and stalled Lambdas all come first. This list is only + what you could do, never what's waiting on you. + </p> + </div> +); diff --git a/src/app/client/ChatPanel.tsx b/src/app/client/ChatPanel.tsx new file mode 100644 index 0000000..60706fd --- /dev/null +++ b/src/app/client/ChatPanel.tsx @@ -0,0 +1,446 @@ +import { + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { Question, ThreadMessage } from '../../pod-store'; +import { splitImages, withWords, wordsOf } from '../shared/images'; +import { itemTitle } from '../shared/items'; +import { useImageDrop } from './ImageDrop'; +import { Message } from './Message'; +import { SpeakerIcon } from './SpeakerIcon'; +import { type ChatTrack, chatTracks } from './chatPlaylist'; + +/** + * The chat, always in the center: the pod's whole conversation with the owner + * (every message they sent it, every message it sent them — answers on items + * and chat alike), grouped by what it was about, and one box. It talks to the + * pod's dev directly, as Claude Code does: ⏎ says something and nothing moves + * on; ⌘⏎ is "done with the item that's up" (App decides what that means — + * answer, approve, carry on). The Plan rail has the same Done as a button. + */ + +/** What the old side chat's hand-off put in front of an answer. */ +const DECIDED_PREFIX = 'Decided in the Chat panel:'; + +const isAgent = (message: ThreadMessage): boolean => + message.from === 'dev' || + message.from === 'manager' || + message.from === 'fork'; + +/** The owner ⇄ the pod — not agent-to-agent traffic or bookkeeping notes. */ +const chatHistory = (thread: ThreadMessage[]): ThreadMessage[] => + thread.filter( + (message) => + message.text !== '(warm-up)' && + ((message.from === 'human' && message.text.trim() !== '') || + (isAgent(message) && message.target === undefined) || + (message.from === 'system' && + message.text.startsWith('Sent the dev what you said in the chat'))) + ); + +/** + * The quiet note under a message. Who speaks goes without saying, as in + * Claude Code (your messages are the bubbles; every agent is Lambda), and the + * "About …" line above a group says which item it's on; what's left is a + * progress update. + */ +const noteOf = (message: ThreadMessage): string | undefined => { + const parts = [message.progress === true ? 'Progress' : ''].filter( + (part) => part !== '' + ); + + return parts.length === 0 ? undefined : parts.join(' · '); +}; + +export interface PendingChat { + id: string; + /** The pod it was sent to — the panel only shows its own pod's. */ + podId: string; + about: string | null; + text: string; + at: number; + /** The send failed (e.g. the app restarted): kept, with a retry. */ + failed?: boolean; + /** Sent with ⌘⏎: the pod steps aside until the chat replies. */ + hold?: boolean; +} + +export const ChatPanel = ({ + podLabel, + thread, + questions, + pending, + draft, + onDraftChange, + onSend, + onDone, + onRetry, + context, + whileWaiting, + listening, + onListen, +}: { + podLabel: string; + thread: ThreadMessage[]; + questions: Question[]; + /** Sent from this page, not in the thread yet. */ + pending: PendingChat[]; + /** The box's text — App keeps it (per item), so the Plan rail's Done sees it. */ + draft: string; + onDraftChange: (text: string) => void; + /** ⏎: say it to the dev; nothing moves on. */ + onSend: (text: string) => void; + /** ⌘⏎: done with the item that's up (the text, if any, is the last word). */ + onDone: (text: string) => void; + /** Re-send a message whose send failed. */ + onRetry: (sent: PendingChat) => void; + /** A quiet line under the box (the dev is mid-turn), when there's one. */ + context?: ReactNode; + /** Under the thinking dots: the offer to move on while Lambda works. */ + whileWaiting?: ReactNode; + /** The message the chat playlist is on, if it's playing. */ + listening: { msgId: string; loading: boolean } | null; + /** Speaker: play from this message through the newest (again: stop). */ + onListen: (msgId: string, tracks: () => ChatTrack[]) => void; +}) => { + // The whole chat takes a dropped image, not just the box at the bottom. + const drop = useImageDrop(draft, onDraftChange); + const box = useRef<HTMLTextAreaElement>(null); + const imageCount = splitImages(draft).images.length; + const lastImageCount = useRef(imageCount); + + // An image just landed in the draft: the box takes focus, ready for words. + useEffect(() => { + if (imageCount > lastImageCount.current) { + box.current?.focus(); + } + + lastImageCount.current = imageCount; + }, [imageCount]); + + const topicLabel = (qid: string | undefined | null): string => { + const question = + qid === undefined || qid === null + ? undefined + : questions.find((candidate) => candidate.id === qid); + + if (question === undefined) { + return 'the whole plan'; + } + + return question.kind === 'checkpoint' && question.checkpoint !== undefined + ? `Checkpoint ${question.checkpoint.cp}` + : itemTitle(question); + }; + + const history = useMemo(() => chatHistory(thread), [thread]); + const tracks = useMemo(() => chatTracks(history), [history]); + // Read at every step of the playlist, so new replies are played too. + const latestTracks = useRef(tracks); + latestTracks.current = tracks; + const audible = new Set(tracks.map((track) => track.id)); + const waiting = pending.filter( + (sent) => + !history.some( + (message) => + message.from === 'human' && + message.text.trim() === sent.text && + Date.parse(message.createdAt) >= sent.at - 60_000 + ) + ); + // The dev owes a reply while the newest message is theirs. + const last = history.filter((message) => message.from !== 'system').at(-1); + const thinking = + waiting.some((sent) => sent.failed !== true) || + (last !== undefined && last.from === 'human'); + + // Scrolling, as in claude.ai: sending puts their message at the top of the + // chat and nothing moves after — the reply fills in below it (a spacer + // under the newest message makes room, shrinking as the reply grows), and + // they scroll on through a long one themselves. Until they send, the chat + // sits at its newest message and follows new ones while they're at the bottom. + const log = useRef<HTMLDivElement>(null); + const spacer = useRef<HTMLDivElement>(null); + const anchored = useRef(false); + const scrollToAnchor = useRef(false); + const following = useRef(true); + + useLayoutEffect(() => { + const element = log.current; + const room = spacer.current; + + if (element === null || room === null) { + return; + } + + if (!anchored.current) { + room.style.height = '0px'; + + if (following.current) { + element.scrollTop = element.scrollHeight; + } + + return; + } + + const yours = element.querySelectorAll<HTMLElement>('[data-you]'); + const mine = yours.item(yours.length - 1); + + if (mine === null) { + return; + } + + const below = room.offsetTop - mine.offsetTop; + room.style.height = `${Math.max(0, element.clientHeight - below - 16)}px`; + + if (scrollToAnchor.current) { + scrollToAnchor.current = false; + element.scrollTop = mine.offsetTop - 16; + } + }); + + // Images (and long markdown) settle after render, making the chat taller + // with no re-render: while they're at the newest message — and hasn't just + // sent one — stay there. + useEffect(() => { + const element = log.current; + + if (element === null) { + return undefined; + } + + const observer = new ResizeObserver(() => { + if (!anchored.current && following.current) { + element.scrollTop = element.scrollHeight; + } + }); + Array.from(element.children).forEach((child) => observer.observe(child)); + const watchNew = new MutationObserver(() => + Array.from(element.children).forEach((child) => observer.observe(child)) + ); + watchNew.observe(element, { childList: true }); + + return () => { + observer.disconnect(); + watchNew.disconnect(); + }; + }, []); + + // Another pod's chat: back to its newest message. + useEffect(() => { + anchored.current = false; + following.current = true; + }, [podLabel]); + + const send = (done: boolean): void => { + const text = draft.trim(); + + if (done) { + onDraftChange(''); + onDone(text); + + return; + } + + if (text === '') { + return; + } + + onDraftChange(''); + anchored.current = true; + scrollToAnchor.current = true; + onSend(text); + }; + + const onKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + send(event.metaKey || event.ctrlKey); + } + }; + + return ( + <section + className={`center-chat${drop.over ? ' center-chat--dropping' : ''}`} + {...drop.zone} + > + {drop.over && ( + <div className="chat-panel__drop">Drop the image to attach it</div> + )} + <div + className="chat-panel__log" + ref={log} + onScroll={(event) => { + const element = event.currentTarget; + following.current = + element.scrollHeight - element.scrollTop - element.clientHeight < + 80; + }} + > + {history.length === 0 && waiting.length === 0 && ( + <p className="muted chat__empty"> + Talk to {podLabel}'s dev. ⏎ sends; ⌘⏎ when you're done + with the item that's up. + </p> + )} + {history.map((message, index) => { + const previous = history[index - 1]; + // "About …" heads a run of messages on one item: answers carry it + // as their qid, chat said while it was up as `about`. + const topic = message.qid ?? message.about; + const divider = + topic !== undefined && + (previous === undefined || + (previous.qid ?? previous.about) !== topic); + + if (message.from === 'system') { + return ( + <p key={message.id} className="muted chat__event"> + ✓ {message.text} + </p> + ); + } + + // The old side chat's decisions, pasted into an answer by the app + // (not typed by them): one quiet line, the list a click away. + if ( + message.from === 'human' && + message.text.startsWith(DECIDED_PREFIX) + ) { + const [decided, ...typed] = message.text.split('\n\n'); + const items = (decided ?? '') + .slice(DECIDED_PREFIX.length) + .split(/;\s*/) + .map((item) => item.trim().replace(/\.$/, '')) + .filter((item) => item !== ''); + + return ( + <details key={message.id} className="muted chat__decided"> + <summary> + Your chat decisions, sent to the dev ({items.length}) + </summary> + <ol> + {items.map((item) => ( + <li key={item}>{item}</li> + ))} + </ol> + {typed.length > 0 && <p>{typed.join('\n\n')}</p>} + </details> + ); + } + + return ( + <div + key={message.id} + data-msg={message.id} + data-you={message.from === 'human' ? '' : undefined} + > + {divider && ( + <p className="chat-panel__topic" data-topic={topic}> + About {topicLabel(topic)} + </p> + )} + <Message + from={message.from === 'human' ? 'you' : 'claude'} + text={message.text} + progress={message.progress === true} + label={noteOf(message)} + className={ + listening?.msgId === message.id ? 'msg--listening' : '' + } + > + {audible.has(message.id) && ( + <button + type="button" + className="icon msg__listen" + title={ + listening?.msgId === message.id + ? 'Stop' + : 'Listen from here to the newest message' + } + aria-label={ + listening?.msgId === message.id + ? 'Stop listening' + : 'Listen from here' + } + onClick={() => + onListen(message.id, () => latestTracks.current) + } + > + {listening?.msgId !== message.id ? ( + <SpeakerIcon /> + ) : listening.loading ? ( + '…' + ) : ( + '■' + )} + </button> + )} + </Message> + </div> + ); + })} + {waiting.map((sent) => ( + <div key={sent.id} data-you=""> + <Message + from="you" + text={sent.text} + className="msg--pending" + label={sent.failed === true ? 'Not sent' : 'Sending'} + > + {sent.failed === true && ( + <button + type="button" + className="link-button" + onClick={() => onRetry(sent)} + > + Retry + </button> + )} + </Message> + </div> + ))} + {thinking && ( + <div + className="msg msg--claude chat__typing" + aria-label="Lambda is thinking" + > + <span className="typing" aria-hidden="true"> + <span /> + <span /> + <span /> + </span> + </div> + )} + {whileWaiting} + <div ref={spacer} className="chat-panel__spacer" aria-hidden="true" /> + </div> + <footer className="chat-panel__compose"> + {context !== undefined && ( + <div className="chat-panel__about">{context}</div> + )} + <div className="image-drop"> + <textarea + ref={box} + rows={1} + // Attached images are thumbnails under the box, not "[Image: …]" + // lines in it; they travel with the words when it's sent. + value={wordsOf(draft)} + placeholder={`Reply to ${podLabel}`} + onChange={(event) => + onDraftChange(withWords(draft, event.target.value)) + } + onKeyDown={onKey} + /> + {drop.tray} + </div> + </footer> + </section> + ); +}; diff --git a/src/app/client/CheckpointExtras.tsx b/src/app/client/CheckpointExtras.tsx new file mode 100644 index 0000000..2725d73 --- /dev/null +++ b/src/app/client/CheckpointExtras.tsx @@ -0,0 +1,369 @@ +import type { ReactNode } from 'react'; + +import type { + Change, + ProposedTicket, + SuggestedJudgement, +} from '../../pod-store'; +import { ticketCardKey } from '../shared/cardHistory'; +import { + type SectionKey, + changeSectionKey, + judgementSectionKey, + proposalCounts, + proposalSectionKey, +} from '../shared/speech'; +import { LaneTag } from './LaneTag'; +import { SpeakerIcon } from './SpeakerIcon'; + +/** + * The pipeline-2 parts of a checkpoint card: the split proposal (CP3), "What I + * understood" (CP4) and the change lists (CP4: since the split was approved; + * CP5: since the UX review). Spoken by shared/speech.ts's checkpointSections. + */ + +const PlayButton = ({ + title, + onClick, +}: { + title: string; + onClick: () => void; +}) => ( + <button className="icon" type="button" title={title} onClick={onClick}> + <SpeakerIcon /> + </button> +); + +export const Understood = ({ + text, + playing, + onPlay, +}: { + text: string; + playing: SectionKey | null; + onPlay: (key: SectionKey) => void; +}) => ( + <section + className={`section${playing === 'understood' ? ' section--playing' : ''}`} + data-section="understood" + > + <header> + <h3>What I understood</h3> + <PlayButton title="Play" onClick={() => onPlay('understood')} /> + </header> + <p>{text}</p> + </section> +); + +const CHANGE_LABEL: Record<Change['kind'], string> = { + screen: 'Screen', + ticket: 'Ticket', + e2e: 'E2E', + human: 'Needs you', + engineering: 'Engineering', +}; + +export const Changes = ({ + changes, + since, + playing, + onPlay, +}: { + changes: Change[]; + since: string; + playing: SectionKey | null; + onPlay: (key: SectionKey) => void; +}) => { + const seen = changes.filter((change) => change.kind !== 'engineering'); + const engineering = changes.filter((change) => change.kind === 'engineering'); + + return ( + <section + className={`section${playing === 'changes' ? ' section--playing' : ''}`} + data-section="changes" + > + <header> + <h3> + Changed since {since} + {seen.length > 0 ? ` (${seen.length})` : ''} + </h3> + <PlayButton + title="Play the changes from the first" + onClick={() => + onPlay(seen.length === 0 ? 'changes' : changeSectionKey(0)) + } + /> + </header> + {seen.length === 0 ? ( + <p className="muted">Nothing you'd see changed.</p> + ) : ( + <ol className="changes"> + {seen.map((change, index) => { + const key = changeSectionKey(index); + + return ( + <li + key={change.text} + className={playing === key ? 'plan-ticket--playing' : undefined} + data-section={key} + > + <div> + <span className="changes__kind"> + {CHANGE_LABEL[change.kind]} + </span>{' '} + {change.text} + {change.before !== undefined && ( + <p className="changes__context"> + <strong>Before:</strong> {change.before} + </p> + )} + {change.why !== undefined && ( + <p className="changes__context"> + <strong>Why:</strong> {change.why} + </p> + )} + </div> + <PlayButton + title="Play from this change" + onClick={() => onPlay(key)} + /> + </li> + ); + })} + </ol> + )} + {engineering.length > 0 && ( + <details className="changes__engineering"> + <summary> + {engineering.length} engineering{' '} + {engineering.length === 1 ? 'fix' : 'fixes'} (not read aloud) + </summary> + <ul className="changes"> + {engineering.map((change) => ( + <li key={change.text}>{change.text}</li> + ))} + </ul> + </details> + )} + </section> + ); +}; + +const E2E_LABEL: Record<ProposedTicket['e2e']['kind'], string> = { + 'new-file': 'New spec file', + 'new-tests': 'New tests in an existing spec', + assertions: 'Assertions only', + none: 'No E2E', +}; + +const OVERLAP_LABEL: Record<ProposedTicket['overlaps'][number]['how'], string> = + { + 'folds-in': 'Folds in', + overlaps: 'Overlaps', + related: 'Related', + }; + +/** CP3: the proposed split, before any ticket is written. */ +export const Proposal = ({ + tickets, + playing, + onPlay, + markOf, + changedHere, +}: { + tickets: ProposedTicket[]; + playing: SectionKey | null; + onPlay: (key: SectionKey) => void; + /** A ticket's "changed v8 · 2m" and its versions (CardVersions.tsx). */ + markOf: (cardKey: string, title: string) => ReactNode; + /** The version on screen changed this ticket. */ + changedHere: (cardKey: string) => boolean; +}) => { + const counts = proposalCounts(tickets); + + return ( + <section className="section proposal"> + <header> + <h3>Proposed split</h3> + <PlayButton + title="Play the proposal from the first ticket" + onClick={() => onPlay(proposalSectionKey(0))} + /> + </header> + <p className="proposal__counts"> + <strong>{tickets.length}</strong> tickets ·{' '} + <strong>{counts['new-file']}</strong> new E2E spec{' '} + {counts['new-file'] === 1 ? 'file' : 'files'} · {counts['new-tests']}{' '} + add tests to an existing spec · {counts.assertions} assertions only + {counts.none > 0 ? ` · ${counts.none} no E2E` : ''} + </p> + <ol className="proposal__tickets"> + {tickets.map((ticket, index) => { + const key = proposalSectionKey(index); + const cardKey = ticketCardKey(ticket); + + return ( + <li + key={ticket.number} + className={`${playing === key ? 'plan-ticket--playing' : ''}${ + changedHere(cardKey) ? ' card--changed-here' : '' + }`} + data-section={key} + data-card={cardKey} + > + <div> + <div className="proposal__title"> + <strong> + {ticket.number} - {ticket.title} + </strong> + {ticket.lane !== undefined && <LaneTag lane={ticket.lane} />} + {markOf(cardKey, `${ticket.number} - ${ticket.title}`)} + </div> + <p>{ticket.what}</p> + <p + className={`proposal__e2e proposal__e2e--${ticket.e2e.kind}`} + > + <span>{E2E_LABEL[ticket.e2e.kind]}</span> + {ticket.e2e.spec !== undefined && ( + <code>{ticket.e2e.spec}</code> + )} + {ticket.e2e.why !== undefined && ( + <span className="muted"> — {ticket.e2e.why}</span> + )} + </p> + {ticket.dependsOn.length > 0 && ( + <p className="muted">After {ticket.dependsOn.join(', ')}</p> + )} + {ticket.docs.length > 0 && ( + <p className="muted"> + Docs to update: {ticket.docs.join(', ')} + </p> + )} + {ticket.overlaps.map((overlap) => ( + <p key={overlap.id} className="muted"> + {OVERLAP_LABEL[overlap.how]} {overlap.id}: {overlap.note} + </p> + ))} + <details className="proposal__files"> + <summary>Where in the code ({ticket.files.length})</summary> + <ul> + {ticket.files.map((file) => ( + <li key={file}> + <code>{file}</code> + </li> + ))} + </ul> + </details> + </div> + <PlayButton + title="Play from this ticket" + onClick={() => onPlay(key)} + /> + </li> + ); + })} + </ol> + </section> + ); +}; + +/** + * CP5: lasting judgements suggested from what they decided in this pod. Ticks + * are saved on the checkpoint as they make them; the ticked ones go into the + * knowledge store when they close it. + */ +export const SuggestedJudgements = ({ + judgements, + kept, + playing, + onPlay, + onKeep, +}: { + judgements: SuggestedJudgement[]; + kept: number[]; + playing: SectionKey | null; + onPlay: (key: SectionKey) => void; + onKeep: (indices: number[]) => void; +}) => { + const all = judgements.map((_judgement, index) => index); + const everything = kept.length === judgements.length; + const toggle = (index: number): void => + onKeep( + kept.includes(index) + ? kept.filter((other) => other !== index) + : [...kept, index].sort((a, b) => a - b) + ); + + return ( + <section className="section judgements"> + <header> + <h3>Suggested judgements ({judgements.length})</h3> + <PlayButton + title="Play the suggestions from the first" + onClick={() => onPlay(judgementSectionKey(0))} + /> + </header> + <p className="muted"> + Lasting rules for future tickets, from what you decided here. Ticked + ones are kept when you close the checkpoint. + </p> + <label className="judgements__all"> + <input + type="checkbox" + checked={everything} + onChange={() => onKeep(everything ? [] : all)} + />{' '} + Select all + </label> + <ol className="judgements__list"> + {judgements.map((judgement, index) => { + const key = judgementSectionKey(index); + + return ( + <li + key={judgement.statement} + className={playing === key ? 'plan-ticket--playing' : undefined} + data-section={key} + > + <label className="judgements__item"> + <input + type="checkbox" + checked={kept.includes(index)} + onChange={() => toggle(index)} + /> + <div> + <strong> + {judgement.kind === 'copy' ? 'Wording · ' : ''} + {judgement.statement} + </strong> + <p> + <span className="judgements__label"> + Why it's good long term: + </span>{' '} + {judgement.why} + </p> + {judgement.copy !== undefined && ( + <p className="muted"> + Say “{judgement.copy.use}” + {judgement.copy.never.length === 0 + ? '' + : `, never ${judgement.copy.never + .map((term) => `“${term}”`) + .join(', ')}`} + . + </p> + )} + <p className="muted">Your words: “{judgement.quote}”</p> + </div> + </label> + <PlayButton + title="Play this suggestion" + onClick={() => onPlay(key)} + /> + </li> + ); + })} + </ol> + </section> + ); +}; diff --git a/src/app/client/ChunkBoundary.tsx b/src/app/client/ChunkBoundary.tsx new file mode 100644 index 0000000..67607b8 --- /dev/null +++ b/src/app/client/ChunkBoundary.tsx @@ -0,0 +1,95 @@ +import { Component, type ErrorInfo, type ReactNode, Suspense } from 'react'; + +import { uiLog } from './uiLog'; + +/** What shows instead of a screen whose chunk didn't load. */ +export const UpdatedNotice = () => ( + <div className="caught-up"> + <p>Lambda was updated. Reload.</p> + <button type="button" onClick={() => window.location.reload()}> + Reload + </button> + </div> +); + +/** What shows instead of a screen that crashed while drawing. */ +export const CrashedNotice = () => ( + <div className="caught-up"> + <p>Something went wrong on this screen. Reload.</p> + <button type="button" onClick={() => window.location.reload()}> + Reload + </button> + </div> +); + +/** What Chrome, Firefox and Safari say when a lazy chunk's file is gone. */ +const CHUNK_LOAD_FAILED = + /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i; + +export const isChunkLoadError = (error: unknown): boolean => + error instanceof Error && CHUNK_LOAD_FAILED.test(error.message); + +type Failed = 'chunk' | 'crash' | null; + +/** + * Around each lazy screen. A chunk can fail to load: the client was rebuilt + * while this tab was open (the old hashed files are gone), or the network + * dropped. Without this React unmounts the whole app and leaves a blank + * page; with it, that screen says so and offers a reload. Any other error is + * a crash: said as one and logged, never passed off as an update. A new + * `resetKey` (another page opened) tries the screen again. + */ +export class ChunkBoundary extends Component< + { children: ReactNode; resetKey?: string }, + { failed: Failed } +> { + state: { failed: Failed } = { failed: null }; + + static getDerivedStateFromError(error: unknown): { failed: Failed } { + return { failed: isChunkLoadError(error) ? 'chunk' : 'crash' }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + uiLog('js-error', { + message: error.message, + where: (info.componentStack ?? '').trim().split('\n')[0]?.trim() ?? '', + }); + } + + componentDidUpdate(previous: { resetKey?: string }): void { + if ( + this.state.failed !== null && + previous.resetKey !== this.props.resetKey + ) { + this.setState({ failed: null }); + } + } + + render() { + if (this.state.failed === 'chunk') { + return <UpdatedNotice />; + } + + if (this.state.failed === 'crash') { + return <CrashedNotice />; + } + + return this.props.children; + } +} + +/** A lazy screen: its Suspense, inside the boundary. */ +export const Lazy = ({ + fallback, + resetKey, + children, +}: { + fallback: ReactNode; + /** Changes when another page opens in this spot. */ + resetKey?: string; + children: ReactNode; +}) => ( + <ChunkBoundary resetKey={resetKey}> + <Suspense fallback={fallback}>{children}</Suspense> + </ChunkBoundary> +); diff --git a/src/app/client/ComputersLine.tsx b/src/app/client/ComputersLine.tsx new file mode 100644 index 0000000..e5b29a5 --- /dev/null +++ b/src/app/client/ComputersLine.tsx @@ -0,0 +1,315 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + type ComputerRow, + canPause, + computerRows, + computersCounts, +} from '../shared/computersFooter'; +import type { LambdaInfo, PodSummary } from '../shared/types'; + +/** + * The sidebar's computers, as one line at the foot however many there are, + * opening a popover: computers needing attention first (paused with work + * waiting, offline), then building, then idle; Pause all and Resume all. + */ + +const BIG_GROUP = 3; + +const Dot = ({ row }: { row: ComputerRow }) => ( + <span + className={`computers__dot computers__dot--${row.state}`} + aria-hidden="true" + /> +); + +export const ComputersLine = ({ + pods, + lambda, + now, + onPause, + onOpenComputer, + onOpenTerminal, + onOpenPage, + onOpenTickets, +}: { + pods: PodSummary[]; + lambda: LambdaInfo | undefined; + now: number; + onPause: (podId: string, pause: boolean) => void; + onOpenComputer: (podId: string) => void; + onOpenTerminal: (podId: string) => void; + onOpenPage: () => void; + onOpenTickets: () => void; +}) => { + const [open, setOpen] = useState(false); + /** Where the popover sits: fixed, so the sidebar's scroll can't clip it. */ + const [at, setAt] = useState<{ left: number; bottom: number } | null>(null); + const [query, setQuery] = useState(''); + const [showAll, setShowAll] = useState<Record<string, boolean>>({}); + const box = useRef<HTMLDivElement>(null); + const rows = computerRows(lambda, pods, now); + const counts = computersCounts(rows); + const needle = query.trim().toLowerCase(); + const shown = rows.filter( + (row) => needle === '' || row.name.toLowerCase().includes(needle) + ); + const attention = shown.filter((row) => row.attention); + const working = shown.filter( + (row) => + !row.attention && + ['building', 'testing', 'shipping', 'blocked'].includes(row.state) + ); + const idle = shown.filter((row) => !row.attention && !working.includes(row)); + const buildingPods = rows.flatMap((row) => + row.podId === null + ? [] + : [{ podId: row.podId, paused: row.state === 'paused' }] + ); + + // A click elsewhere or Escape closes it. + useEffect(() => { + if (!open) { + return undefined; + } + + const away = (event: MouseEvent): void => { + if ( + box.current !== null && + event.target instanceof Node && + !box.current.contains(event.target) + ) { + setOpen(false); + } + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + window.addEventListener('mousedown', away); + window.addEventListener('keydown', onKey); + + return () => { + window.removeEventListener('mousedown', away); + window.removeEventListener('keydown', onKey); + }; + }, [open]); + + const row = (one: ComputerRow) => ( + <div key={one.name} className="computers__row"> + <Dot row={one} /> + <button + type="button" + className="computers__name" + disabled={one.podId === null} + onClick={() => { + if (one.podId !== null) { + setOpen(false); + onOpenComputer(one.podId); + } + }} + > + {one.name} + </button> + <span className="computers__detail">{one.detail}</span> + {one.state === 'paused' && one.podId !== null && ( + <button + type="button" + className="computers__act" + onClick={() => one.podId !== null && onPause(one.podId, false)} + > + Resume + </button> + )} + {one.state === 'offline' && ( + <button + type="button" + className="computers__act" + onClick={() => { + setOpen(false); + onOpenTickets(); + }} + > + Reassign + </button> + )} + {canPause(one) && ( + <button + type="button" + className="computers__act" + onClick={() => one.podId !== null && onPause(one.podId, true)} + > + Pause + </button> + )} + {canPause(one) && ( + <button + type="button" + className="computers__act computers__act--quiet" + title="Open terminal" + onClick={() => { + if (one.podId !== null) { + setOpen(false); + onOpenTerminal(one.podId); + } + }} + > + Terminal + </button> + )} + </div> + ); + + const group = (id: string, title: string, members: ComputerRow[]) => + members.length === 0 ? null : ( + <div className="computers__group"> + <p className="computers__head"> + {title} · {members.length} + </p> + {(showAll[id] === true || members.length <= BIG_GROUP + 1 + ? members + : members.slice(0, BIG_GROUP) + ).map(row)} + {showAll[id] !== true && members.length > BIG_GROUP + 1 && ( + <button + type="button" + className="computers__more" + onClick={() => setShowAll({ ...showAll, [id]: true })} + > + + {members.length - BIG_GROUP} more {title.toLowerCase()} + </button> + )} + </div> + ); + + return ( + <div className="computers" ref={box}> + <button + type="button" + className={`computers__line${open ? ' computers__line--open' : ''}`} + aria-haspopup="dialog" + aria-expanded={open} + title="Computers" + onClick={(event) => { + const box = event.currentTarget.getBoundingClientRect(); + setAt({ + left: box.right - 36, + bottom: window.innerHeight - box.top + 4, + }); + setOpen(!open); + }} + > + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + aria-hidden="true" + > + <rect x="4" y="5" width="16" height="11" rx="1.5" /> + <path d="M2 19h20" /> + </svg> + <span className="computers__counts"> + {counts.map((part, index) => ( + <span key={part.label} className={`computers__count--${part.tone}`}> + {index > 0 && ' · '} + {part.label} + </span> + ))} + </span> + <svg + width="10" + height="10" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2.4" + aria-hidden="true" + > + <path d="M9 6l6 6-6 6" /> + </svg> + </button> + {open && ( + <div + className="computers__popover" + style={at === null ? undefined : { left: at.left, bottom: at.bottom }} + role="dialog" + aria-label="Computers" + > + <div className="computers__top"> + <strong>Computers</strong> + <label className="computers__search"> + <svg + width="12" + height="12" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + aria-hidden="true" + > + <circle cx="11" cy="11" r="7" /> + <path d="M20 20l-3.5-3.5" /> + </svg> + <input + type="search" + placeholder="Search" + aria-label="Search computers" + value={query} + onChange={(event) => setQuery(event.target.value)} + /> + </label> + </div> + {attention.length > 0 && ( + <div className="computers__group computers__group--attention"> + <p className="computers__head">Needs attention</p> + {attention.map(row)} + </div> + )} + {group('building', 'Building', working)} + {group('idle', 'Idle', idle)} + <div className="computers__foot"> + <button + type="button" + className="computers__button" + disabled={buildingPods.every((one) => one.paused)} + onClick={() => + buildingPods + .filter((one) => !one.paused) + .forEach((one) => onPause(one.podId, true)) + } + > + Pause all + </button> + <button + type="button" + className="computers__button" + disabled={buildingPods.every((one) => !one.paused)} + onClick={() => + buildingPods + .filter((one) => one.paused) + .forEach((one) => onPause(one.podId, false)) + } + > + Resume all + </button> + <span className="computers__spacer" /> + <button + type="button" + className="computers__link" + onClick={() => { + setOpen(false); + onOpenPage(); + }} + > + Open Computers › + </button> + </div> + </div> + )} + </div> + ); +}; diff --git a/src/app/client/ComputersPage.tsx b/src/app/client/ComputersPage.tsx new file mode 100644 index 0000000..ddc329b --- /dev/null +++ b/src/app/client/ComputersPage.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { SharedTrackerUser, Tracker } from '../../tickets/assignees'; +import { type Computer, type TailscaleStep, api } from './api'; +import { errorText, latestLoader } from './loading'; + +/** How often Add a computer checks Tailscale again while it's open. */ +const TAILSCALE_RECHECK_MS = 5000; + +type Listen = + | { phase: 'idle' } + | { phase: 'asking' } + | { phase: 'ready'; command: string } + | { phase: 'failed'; line: string }; + +/** + * Add a computer: put both machines on Tailscale, see this computer's + * private address, paste one `lambda join` line on the other. Every check + * and the listen write run on the mothership; this only shows them. + */ +const AddComputerDialog = ({ onDone }: { onDone: () => void }) => { + const [tailscale, setTailscale] = useState<TailscaleStep | null>(null); + const [listen, setListen] = useState<Listen>({ phase: 'idle' }); + const [error, setError] = useState<string | null>(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const check = () => { + api + .tailscale() + .then((step) => { + setTailscale(step); + setError(null); + }) + .catch((caught: unknown) => setError(errorText(caught))); + }; + check(); + const timer = setInterval(check, TAILSCALE_RECHECK_MS); + + return () => clearInterval(timer); + }, []); + + const isUp = tailscale?.state === 'up'; + + // Once step 1 is ✓: listen on the Tailscale address, then make the code. + useEffect(() => { + if (!isUp || listen.phase !== 'idle') { + return; + } + + setListen({ phase: 'asking' }); + api + .listenOnTailscale() + .then(async ({ server }) => { + const { command } = await api.computerCode(); + setListen({ phase: 'ready', command: `${command} --server ${server}` }); + }) + .catch((caught: unknown) => + setListen({ phase: 'failed', line: errorText(caught) }) + ); + }, [isUp, listen.phase]); + + return ( + <div className="dialog" role="dialog" aria-label="Add a computer"> + <h2>Add a computer</h2> + {error !== null && <p className="error">{error}</p>} + <ol className="dialog__steps"> + <li> + <strong>Put both machines on Tailscale</strong>{' '} + <span className={isUp ? '' : 'muted'}> + {tailscale?.step ?? 'Checking…'} + </span> + <p className="muted">Tailscale is a free private network.</p> + </li> + {isUp && ( + <li> + <strong>This Mac's private address</strong> + <p> + {tailscale.label} · {tailscale.address} + </p> + </li> + )} + {isUp && listen.phase === 'failed' && ( + <li> + <p className="error">{listen.line}</p> + </li> + )} + {isUp && listen.phase === 'ready' && ( + <li> + <strong>On the other machine, run:</strong> + <p className="dialog__code"> + <code>{listen.command}</code> + <button + type="button" + className="ghost" + onClick={() => { + navigator.clipboard + .writeText(listen.command) + .then(() => setCopied(true)) + .catch(() => setCopied(false)); + }} + > + {copied ? 'Copied' : 'Copy'} + </button> + </p> + <p className="muted"> + This code works once and expires in 10 minutes. + </p> + <p className="muted">Waiting for it to join…</p> + </li> + )} + </ol> + <button type="button" className="ghost" onClick={onDone}> + Done + </button> + </div> + ); +}; + +/** + * Computers: the machines themselves (name, project, state). A computer + * works on one project at a time and moves to a waiting project after 5 idle + * minutes. Add a computer walks through joining another one over Tailscale. + */ +const TRACKER_NAMES: Record<Tracker, string> = { + linear: 'Linear', + jira: 'Jira', +}; + +/** "A and B", "A, B and C". */ +const listed = (names: string[]): string => + names.length <= 1 + ? names.join('') + : `${names.slice(0, -1).join(', ')} and ${names.at(-1) ?? ''}`; + +export const ComputersPage = ({ tick }: { tick: number }) => { + const [computers, setComputers] = useState<Computer[] | null>(null); + const [shared, setShared] = useState<SharedTrackerUser[]>([]); + const [adding, setAdding] = useState(false); + const [error, setError] = useState<string | null>(null); + + // A good answer clears an old error (latestLoader). + const load = useMemo( + () => latestLoader<Computer[]>({ value: setComputers, error: setError }), + [] + ); + + useEffect(() => { + void load( + api.computers().then((data) => { + setShared(data.shared ?? []); + + return data.computers; + }) + ); + }, [load, tick]); + + return ( + <section className="account-page"> + {error !== null && <p className="error">{error}</p>} + {computers === null ? ( + <p className="muted">Loading…</p> + ) : ( + <ul className="account-list"> + {shared.map((one) => ( + <li + key={`${one.tracker}/${one.who}`} + className="account-list__row account-list__warning" + role="status" + > + {listed(one.computers)} share the {TRACKER_NAMES[one.tracker]}{' '} + user {one.who}: a ticket assigned to it may be built by either. + </li> + ))} + {computers.map((computer) => ( + <li key={computer.id} className="account-list__row"> + <strong>{computer.name}</strong> + {computer.project !== null && <span> · {computer.project}</span>} + <span className="muted"> · {computer.state}</span> + <span className="account-list__trackers"> + {(computer.trackers ?? []).length === 0 + ? 'No tracker connected: tickets can’t be assigned to it in Linear or Jira' + : (computer.trackers ?? []) + .map( + (identity) => + `${TRACKER_NAMES[identity.tracker]}: ${ + identity.name ?? identity.who + } ✓` + ) + .join(' · ')} + </span> + </li> + ))} + {computers.length === 0 && ( + <li className="muted"> + No computers yet. Run lambda start on this one. + </li> + )} + </ul> + )} + <div className="account-page__actions"> + <button + type="button" + className="primary" + onClick={() => setAdding(true)} + > + Add a computer + </button> + </div> + {adding && <AddComputerDialog onDone={() => setAdding(false)} />} + </section> + ); +}; diff --git a/src/app/client/DescriptionEditor.tsx b/src/app/client/DescriptionEditor.tsx new file mode 100644 index 0000000..0ef4844 --- /dev/null +++ b/src/app/client/DescriptionEditor.tsx @@ -0,0 +1,323 @@ +import { Image } from '@tiptap/extension-image'; +import { Placeholder } from '@tiptap/extension-placeholder'; +import { TableKit } from '@tiptap/extension-table'; +import { Markdown } from '@tiptap/markdown'; +import { EditorContent, useEditor, useEditorState } from '@tiptap/react'; +import { BubbleMenu } from '@tiptap/react/menus'; +import { StarterKit } from '@tiptap/starter-kit'; +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; + +import { + type Autosave, + createAutosave, + tidyMarkdown, +} from '../shared/autosave'; + +/** + * A ticket's description, edited in place as Linear's is: it's always the + * editor, select text for a formatting bar, and every pause in typing saves. + * Markdown in, Markdown out, so the ticket's own file (or Linear, or Jira) + * keeps plain Markdown. + */ + +const browserTimer = { + set: (run: () => void, ms: number): unknown => window.setTimeout(run, ms), + clear: (handle: unknown): void => { + if (typeof handle === 'number') { + window.clearTimeout(handle); + } + }, +}; + +const Tool = ({ + label, + on, + onClick, + children, +}: { + label: string; + on: boolean; + onClick: () => void; + children: ReactNode; +}) => ( + <button + type="button" + className={`md-bar__tool${on ? ' md-bar__tool--on' : ''}`} + aria-label={label} + aria-pressed={on} + title={label} + // Keep the selection: a mousedown here would move focus off the text. + onMouseDown={(event) => event.preventDefault()} + onClick={onClick} + > + {children} + </button> +); + +const Glyph = ({ d }: { d: string }) => ( + <svg + width="15" + height="15" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + <path d={d} /> + </svg> +); + +export const DescriptionEditor = ({ + ticketKey, + markdown, + editable, + onSave, +}: { + /** Which ticket: a new one starts the editor afresh. */ + ticketKey: string; + markdown: string; + editable: boolean; + onSave: (markdown: string) => Promise<void>; +}) => { + const [problem, setProblem] = useState<string | null>(null); + /** The link being typed in the bar, while it's open. */ + const [linking, setLinking] = useState<string | null>(null); + const saveRef = useRef(onSave); + saveRef.current = onSave; + const autosave = useMemo<Autosave>( + () => + createAutosave( + markdown, + (text) => + saveRef.current(text).then( + () => setProblem(null), + (caught: unknown) => { + setProblem( + caught instanceof Error ? caught.message : String(caught) + ); + + throw caught; + } + ), + browserTimer + ), + // A fresh autosave per ticket; later reads only update what's saved. + [ticketKey] + ); + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + link: { openOnClick: false, autolink: true }, + }), + Image, + TableKit, + Markdown, + Placeholder.configure({ placeholder: 'Add a description…' }), + ], + content: markdown, + contentType: 'markdown', + editable, + // What it saved is the text as this editor writes it: a tracker's own + // markdown (Linear's code blocks, tables) reads back a little + // differently, and that alone is never a change. + onCreate: ({ editor: made }) => + autosave.saved(tidyMarkdown(made.getMarkdown())), + // Only what the person typed is saved: the editor tidying a document it + // just loaded is not an edit, and must never be written back. + onUpdate: ({ editor: changed }) => { + if (!changed.isEditable || !changed.isFocused) { + return; + } + + autosave.changed(tidyMarkdown(changed.getMarkdown())); + }, + onBlur: () => autosave.flush(), + }, + [ticketKey] + ); + + // The page re-reads the ticket: show the source's text, unless the person + // is typing or a save is still out (theirs is newer). + useEffect(() => { + if ( + editor === null || + editor.isDestroyed || + editor.isFocused || + autosave.busy() + ) { + return; + } + + if (tidyMarkdown(editor.getMarkdown()) !== tidyMarkdown(markdown)) { + editor.commands.setContent(markdown, { + contentType: 'markdown', + emitUpdate: false, + }); + } + + // As this editor writes it: see onCreate. + autosave.saved(tidyMarkdown(editor.getMarkdown())); + }, [editor, markdown, autosave]); + + useEffect(() => { + if (editor !== null && !editor.isDestroyed) { + editor.setEditable(editable); + } + }, [editor, editable]); + + // Leaving the ticket or the page saves what's waiting. + useEffect(() => { + const leave = (): void => autosave.flush(); + window.addEventListener('beforeunload', leave); + + return () => { + window.removeEventListener('beforeunload', leave); + leave(); + }; + }, [autosave]); + + const marks = useEditorState({ + editor, + selector: ({ editor: now }) => ({ + bold: now?.isActive('bold') ?? false, + italic: now?.isActive('italic') ?? false, + strike: now?.isActive('strike') ?? false, + code: now?.isActive('code') ?? false, + link: now?.isActive('link') ?? false, + heading: now?.isActive('heading') ?? false, + bullets: now?.isActive('bulletList') ?? false, + numbers: now?.isActive('orderedList') ?? false, + quote: now?.isActive('blockquote') ?? false, + }), + }); + + if (editor === null || editor.isDestroyed) { + return null; + } + + const chain = () => editor.chain().focus(); + + return ( + <div className="ticket__description"> + <BubbleMenu + editor={editor} + className={`md-bar${linking === null ? '' : ' md-bar--linking'}`} + shouldShow={({ editor: now, from, to }) => + now.isEditable && (from !== to || linking !== null) + } + > + {linking !== null && ( + <input + className="md-bar__link" + aria-label="Link to" + placeholder="Paste a link, then Enter" + autoFocus + value={linking} + onChange={(event) => setLinking(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setLinking(null); + editor.commands.focus(); + } + + if (event.key === 'Enter') { + event.preventDefault(); + + if (linking.trim() !== '') { + chain().setLink({ href: linking.trim() }).run(); + } + + setLinking(null); + } + }} + onBlur={() => setLinking(null)} + /> + )} + <Tool + label="Heading" + on={marks?.heading ?? false} + onClick={() => chain().toggleHeading({ level: 2 }).run()} + > + <span className="md-bar__text">Aa</span> + </Tool> + <span className="md-bar__line" aria-hidden="true" /> + <Tool + label="Bold (⌘B)" + on={marks?.bold ?? false} + onClick={() => chain().toggleBold().run()} + > + <Glyph d="M7 5h6a3.5 3.5 0 010 7H7zM7 12h7a3.5 3.5 0 010 7H7z" /> + </Tool> + <Tool + label="Italic (⌘I)" + on={marks?.italic ?? false} + onClick={() => chain().toggleItalic().run()} + > + <Glyph d="M14 5h-4M14 19h-4M13 5l-2 14" /> + </Tool> + <Tool + label="Strikethrough" + on={marks?.strike ?? false} + onClick={() => chain().toggleStrike().run()} + > + <Glyph d="M5 12h14M16 7a4 3 0 00-8 0c0 4 8 2 8 6a4 3 0 01-8 0" /> + </Tool> + <Tool + label="Code" + on={marks?.code ?? false} + onClick={() => chain().toggleCode().run()} + > + <Glyph d="M9 8l-4 4 4 4M15 8l4 4-4 4" /> + </Tool> + <Tool + label="Link" + on={marks?.link ?? false} + onClick={() => { + if (editor.isActive('link')) { + chain().unsetLink().run(); + + return; + } + + setLinking(''); + }} + > + <Glyph d="M10 14a4 4 0 006 0l3-3a4 4 0 00-6-6l-1 1M14 10a4 4 0 00-6 0l-3 3a4 4 0 006 6l1-1" /> + </Tool> + <span className="md-bar__line" aria-hidden="true" /> + <Tool + label="Quote" + on={marks?.quote ?? false} + onClick={() => chain().toggleBlockquote().run()} + > + <Glyph d="M7 7h4v4H8l-1 4M14 7h4v4h-3l-1 4" /> + </Tool> + <Tool + label="Bulleted list" + on={marks?.bullets ?? false} + onClick={() => chain().toggleBulletList().run()} + > + <Glyph d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" /> + </Tool> + <Tool + label="Numbered list" + on={marks?.numbers ?? false} + onClick={() => chain().toggleOrderedList().run()} + > + <Glyph d="M10 6h10M10 12h10M10 18h10M4 5h1v4M4 18h2l-2-2a1 1 0 112 0" /> + </Tool> + </BubbleMenu> + <EditorContent editor={editor} className="md-editor" /> + {problem !== null && ( + <p className="md-editor__problem" role="alert"> + Couldn’t save: {problem}. It tries again when you next pause. + </p> + )} + </div> + ); +}; diff --git a/src/app/client/ImageDrop.tsx b/src/app/client/ImageDrop.tsx new file mode 100644 index 0000000..df2d77d --- /dev/null +++ b/src/app/client/ImageDrop.tsx @@ -0,0 +1,212 @@ +import { + type ClipboardEvent, + type DragEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react'; + +import { + imageUrl, + splitImages, + withImage, + withoutImage, +} from '../shared/images'; +import { api } from './api'; + +/** A drag the owner could mean as an image: files, or an image from a web page (a link). */ +const droppable = (event: DragEvent): boolean => { + const types = Array.from(event.dataTransfer.types); + + return types.includes('Files') || types.includes('text/uri-list'); +}; + +/** The image link in an HTML drag (an <img> from another page), if any. */ +const imageLink = (transfer: DataTransfer): string | null => { + const html = transfer.getData('text/html'); + const fromHtml = /<img[^>]+src="([^"]+)"/i.exec(html)?.[1]; + + if (fromHtml !== undefined) { + return fromHtml.replace(/&/g, '&'); + } + + const uri = transfer + .getData('text/uri-list') + .split('\n') + .find((line) => line.trim() !== '' && !line.startsWith('#')); + + return uri === undefined ? null : uri.trim(); +}; + +export interface ImageDropState { + /** Spread on the element that should take drops (and pastes). */ + zone: { + onDragEnter: (event: DragEvent<HTMLElement>) => void; + onDragOver: (event: DragEvent<HTMLElement>) => void; + onDragLeave: (event: DragEvent<HTMLElement>) => void; + onDrop: (event: DragEvent<HTMLElement>) => void; + onPaste: (event: ClipboardEvent<HTMLElement>) => void; + }; + over: boolean; + /** Thumbnails, progress and errors — goes under the message box. */ + tray: ReactNode; + /** Attach files picked another way (a "+" button's file picker). */ + attach: (files: File[]) => void; +} + +/** + * Drop (or paste) images into a message. Each one is uploaded and added to + * the draft as an `[Image: …]` line — the agent opens that path with Read — + * and shown as a thumbnail; the box itself shows only the words (`wordsOf`). Files of any image type (HEIC and the like are + * converted on the server) and images dragged from a web page (a link the + * server fetches) both work; anything else says why it can't be attached. + */ +export const useImageDrop = ( + value: string, + onChange: (next: string) => void +): ImageDropState => { + const [over, setOver] = useState(false); + const [uploading, setUploading] = useState(0); + const [error, setError] = useState<string | null>(null); + // Uploads finish after more typing: append to the draft as it is then. + const latest = useRef(value); + latest.current = value; + // dragenter/leave fire for every child crossed: count, so the hint holds. + const depth = useRef(0); + + const track = (upload: Promise<string>): void => { + setError(null); + setUploading((count) => count + 1); + upload + .then((filePath) => onChange(withImage(latest.current, filePath))) + .catch((failure: unknown) => + setError(failure instanceof Error ? failure.message : String(failure)) + ) + .finally(() => setUploading((count) => count - 1)); + }; + + const attachFiles = (files: File[]): void => + files.forEach((file) => track(api.uploadImage(file))); + + const zone: ImageDropState['zone'] = { + onDragEnter: (event) => { + if (droppable(event)) { + depth.current += 1; + setOver(true); + } + }, + onDragOver: (event) => { + if (!droppable(event)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }, + onDragLeave: (event) => { + if (droppable(event)) { + depth.current = Math.max(0, depth.current - 1); + setOver(depth.current > 0); + } + }, + onDrop: (event) => { + if (!droppable(event)) { + return; + } + + event.preventDefault(); + depth.current = 0; + setOver(false); + const files = Array.from(event.dataTransfer.files); + + if (files.length > 0) { + attachFiles(files); + + return; + } + + const link = imageLink(event.dataTransfer); + + if (link === null) { + setError("That isn't an image I can attach."); + + return; + } + + track(api.uploadImageUrl(link)); + }, + onPaste: (event) => { + const files = Array.from(event.clipboardData.files); + + if (files.length > 0) { + event.preventDefault(); + attachFiles(files); + } + }, + }; + + const { images } = splitImages(value); + const tray = + images.length > 0 || uploading > 0 || error !== null ? ( + <div className="image-drop__tray"> + {images.map((filePath) => ( + <span key={filePath} className="image-drop__thumb"> + <img src={imageUrl(filePath) ?? ''} alt="Attached" /> + <button + type="button" + aria-label="Remove image" + onClick={() => onChange(withoutImage(value, filePath))} + > + ✕ + </button> + </span> + ))} + {uploading > 0 && ( + <span className="muted image-drop__status">Uploading…</span> + )} + {error !== null && ( + <span className="image-drop__status image-drop__error">{error}</span> + )} + </div> + ) : null; + + return { zone, over, tray, attach: attachFiles }; +}; + +/** A message box that takes dropped and pasted images. */ +export const ImageDrop = ({ + value, + onChange, + children, +}: { + value: string; + onChange: (next: string) => void; + children: ReactNode; +}) => { + const { zone, over, tray } = useImageDrop(value, onChange); + const host = useRef<HTMLDivElement>(null); + const imageCount = splitImages(value).images.length; + const lastImageCount = useRef(imageCount); + + // An image just landed: its box takes focus, ready for words. + useEffect(() => { + if (imageCount > lastImageCount.current) { + host.current?.querySelector('textarea')?.focus(); + } + + lastImageCount.current = imageCount; + }, [imageCount]); + + return ( + <div + ref={host} + className={`image-drop${over ? ' image-drop--over' : ''}`} + {...zone} + > + {children} + {tray} + {over && <div className="image-drop__hint">Drop to attach</div>} + </div> + ); +}; diff --git a/src/app/client/ItemContext.tsx b/src/app/client/ItemContext.tsx new file mode 100644 index 0000000..799d7b9 --- /dev/null +++ b/src/app/client/ItemContext.tsx @@ -0,0 +1,407 @@ +import { useState } from 'react'; + +import type { Question, ThreadMessage } from '../../pod-store'; +import { + type Exchange, + exchangeView, + isLiveExchange, + itemStatus, + itemTitle, + originView, + spawnedFrom, + splitExchange, +} from '../shared/items'; +import { Message } from './Message'; + +const who = (message: ThreadMessage): string => { + if (message.from === 'human') { + return 'You'; + } + + if (message.from === 'fork') { + return 'Lambda · live'; + } + + return message.progress === true ? 'Lambda · progress' : 'Lambda'; +}; + +const Bubble = ({ message }: { message: ThreadMessage }) => + // An empty message from the owner is the end of a live conversation. + message.from === 'human' && message.text.trim() === '' ? ( + <p className="muted chat__event">You ended the live conversation.</p> + ) : ( + <Message + from={message.from === 'human' ? 'you' : 'claude'} + text={message.text} + progress={message.progress === true} + label={ + who(message) === 'You' || who(message) === 'Lambda' + ? undefined + : who(message) + } + /> + ); + +const messagesOf = (exchange: Exchange): ThreadMessage[] => [ + ...(exchange.you === null ? [] : [exchange.you]), + ...exchange.replies, +]; + +/** ‹ Exchange 2 of 4 › — step back through earlier exchanges on the item. */ +const ExchangeNav = ({ + index, + total, + isDefault, + live, + onPick, +}: { + index: number; + total: number; + isDefault: boolean; + live: boolean; + onPick: (index: number | null) => void; +}) => + total < 2 ? null : ( + <div className="exchange-nav"> + <button + type="button" + className="ghost exchange-nav__step" + disabled={index === 0} + aria-label="Earlier exchange" + onClick={() => onPick(index - 1)} + > + ‹ + </button> + <span> + Exchange {index + 1} of {total} + {live ? ' · live' : ''} + </span> + <button + type="button" + className="ghost exchange-nav__step" + disabled={index === total - 1} + aria-label="Later exchange" + onClick={() => onPick(index + 1)} + > + › + </button> + {!isDefault && ( + <button + type="button" + className="link-button" + onClick={() => onPick(null)} + > + Back to the latest + </button> + )} + </div> + ); + +/** Live questions since the exchange the item opens with. */ +const LiveAfter = ({ + live, + playing, +}: { + live: Exchange[]; + playing: string | null; +}) => + live.length === 0 ? null : ( + <div + className={`chat__live-after${ + playing === 'live' ? ' section--playing' : '' + }`} + data-section="live" + > + <h3 className="chat__heading">Since then, live</h3> + {live.flatMap(messagesOf).map((message) => ( + <Bubble key={message.id} message={message} /> + ))} + </div> + ); + +/** + * An item's chat. Collapsed (the default): "Since you were last here" — the + * exchange the item opens with (the latest that isn't live talk), then any + * live questions since — with ‹ › to step back through earlier exchanges and + * every message one click away. Expanded (a live conversation, or on + * request): everything, in order. + */ +export const ItemChat = ({ + chat, + live = false, + questions = [], + humanDecisions = [], + onOpen, + playing = null, + exchangeIndex = null, + onExchangeIndex, +}: { + chat: ThreadMessage[]; + live?: boolean; + /** For matching parts of a split reply to the decisions they became. */ + questions?: Question[]; + humanDecisions?: { qid?: string; decision: string }[]; + onOpen?: (qid: string) => void; + playing?: string | null; + /** The exchange picked with ‹ › (null: the default). */ + exchangeIndex?: number | null; + onExchangeIndex?: (index: number | null) => void; +}) => { + const [open, setOpen] = useState(false); + + if (chat.length === 0) { + return null; + } + + const view = exchangeView(chat, exchangeIndex); + const { exchange } = view; + const split = live + ? null + : splitExchange(exchange, questions, humanDecisions); + const nav = + onExchangeIndex === undefined || live || open ? null : ( + <ExchangeNav + index={view.index} + total={view.total} + isDefault={view.isDefault} + live={exchange !== null && isLiveExchange(exchange)} + onPick={onExchangeIndex} + /> + ); + const heading = view.isDefault ? 'Since you were last here' : 'Earlier'; + + // A message with several parts, answered part by part: show it as + // question → answer pairs, each with the decision it became. + if ( + split !== null && + !open && + exchange?.you !== null && + exchange?.you !== undefined + ) { + const shownIds = new Set( + [exchange, ...view.liveAfter].flatMap(messagesOf).map((m) => m.id) + ); + const otherCount = chat.filter((m) => !shownIds.has(m.id)).length; + + return ( + <section className="chat"> + {otherCount > 0 && ( + <button + type="button" + className="link-button" + onClick={() => setOpen(true)} + > + {view.isDefault + ? `Show earlier messages (${otherCount})` + : `Show every message (${chat.length})`} + </button> + )} + {nav} + <h3 className="chat__heading"> + {heading} · {split.parts.length} parts + </h3> + <ol className="chat__parts"> + {split.parts.map((part, index) => ( + <li + key={part.quote} + data-section={`part-${index + 1}`} + className={ + playing === `part-${index + 1}` + ? 'chat__part--playing' + : undefined + } + > + <Message from="you" text={part.quote} /> + <Message from="claude" text={part.answer} /> + {part.decision !== undefined && ( + <p className="chat__part-status"> + {part.decision.resolved + ? `✓ Resolved${ + part.decision.chose === undefined + ? '' + : ` — you chose: ${part.decision.chose}` + }` + : '● Waiting on you as a decision'} + {onOpen !== undefined && ( + <> + {' · '} + <button + type="button" + className="link-button" + onClick={() => + part.decision !== undefined && + onOpen(part.decision.qid) + } + > + ↗ Open the decision + </button> + </> + )} + </p> + )} + </li> + ))} + </ol> + {split.rest.map((message) => ( + <Bubble key={message.id} message={message} /> + ))} + <details className="origin__full"> + <summary>Show your full message</summary> + <p>{exchange.you.text}</p> + </details> + <LiveAfter live={view.liveAfter} playing={playing} /> + </section> + ); + } + const latest = exchange === null ? [] : messagesOf(exchange); + const latestIds = new Set( + [...latest, ...view.liveAfter.flatMap(messagesOf)].map( + (message) => message.id + ) + ); + const earlier = chat.filter((message) => !latestIds.has(message.id)); + const expanded = live || open; + const shown = expanded ? chat : latest; + + return ( + <section className={`chat${live ? ' chat--live' : ''}`}> + {!live && earlier.length > 0 && ( + <button + type="button" + className="link-button" + onClick={() => setOpen((value) => !value)} + > + {open + ? 'Hide earlier messages' + : view.isDefault + ? `Show earlier messages (${earlier.length})` + : `Show every message (${chat.length})`} + </button> + )} + {nav} + <h3 className="chat__heading"> + {live ? 'Live conversation' : expanded ? 'Conversation' : heading} + </h3> + {shown.map((message) => ( + <Bubble key={message.id} message={message} /> + ))} + {!expanded && <LiveAfter live={view.liveAfter} playing={playing} />} + {/* Live: the owner spoke last, so Claude's reply is being written. */} + {live && chat.at(-1)?.from === 'human' && ( + <div + className="msg msg--claude chat__typing" + aria-label="Lambda is thinking" + > + <span className="typing" aria-hidden="true"> + <span /> + <span /> + <span /> + </span> + </div> + )} + {!live && + exchange !== null && + exchange.you !== null && + exchange.replies.length === 0 && ( + <p className="muted chat__pending">Lambda is working on this.</p> + )} + </section> + ); +}; + +/** "How we got here" — where this question came from, with a link back. */ +export const OriginBlock = ({ + question, + questions, + thread, + onOpen, +}: { + question: Question; + questions: Question[]; + thread: ThreadMessage[]; + onOpen: (qid: string) => void; +}) => { + const origin = originView(question, questions, thread); + + if (origin === null) { + return null; + } + + return ( + <section className="origin"> + <h3> + How we got here + {origin.series !== undefined && + ` · decision ${origin.series.index + 1} of ${origin.series.total}`} + </h3> + <p>{origin.lead}.</p> + {origin.quote !== undefined && ( + <p className="origin__quote"> + <span className="chat__who">You said</span> {origin.quote} + </p> + )} + {origin.reply !== undefined && ( + <p className="origin__quote"> + <span className="chat__who">Lambda</span> {origin.reply} + </p> + )} + {origin.fullMessage !== undefined && ( + <details className="origin__full"> + <summary>Show your full message</summary> + <p>{origin.fullMessage}</p> + </details> + )} + {origin.link !== undefined && ( + <button + type="button" + className="link-button" + onClick={() => origin.link !== undefined && onOpen(origin.link.qid)} + > + ↩ Open {origin.link.label} + </button> + )} + </section> + ); +}; + +/** "This led to" — the decisions this item spawned, and where each stands. */ +export const LedTo = ({ + question, + questions, + thread, + onOpen, +}: { + question: Question; + questions: Question[]; + thread: ThreadMessage[]; + onOpen: (qid: string) => void; +}) => { + const spawned = spawnedFrom(questions, question.id); + + if (spawned.length === 0) { + return null; + } + + return ( + <section className="led-to"> + <h3>This led to</h3> + <ul> + {spawned.map((child) => ( + <li key={child.id}> + <button + type="button" + className="link-button" + onClick={() => onOpen(child.id)} + > + {itemTitle(child)} + </button> + <span className="muted"> + {' '} + · {itemStatus(child, questions, thread)} + </span> + </li> + ))} + </ul> + </section> + ); +}; diff --git a/src/app/client/LambdaScreen.tsx b/src/app/client/LambdaScreen.tsx new file mode 100644 index 0000000..11fda4e --- /dev/null +++ b/src/app/client/LambdaScreen.tsx @@ -0,0 +1,1598 @@ +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; + +import type { Screenshot } from '../../pod-store'; +import { formatMinutes, stepDisplayName } from '../shared/buildTimeline'; +import { + type BuildingPodDetail, + type TicketPage, + buildingRefreshIn, + currentRun, +} from '../shared/building'; +import { + ago, + epicProgress, + filesLine, + focusTicket, + foldedLine, + mainAction, + phaseDivider, + queuePlace, + stripStepOf, +} from '../shared/lambdaPage'; +import type { Lambda, LambdaPhase, LambdaTicket } from '../shared/lambdas'; +import { lambdaDefaultRail, lambdaRailsOf } from '../shared/screenChoice'; +import { ticketActivity } from '../shared/ticketBoard'; +import type { AppState, PodDetail } from '../shared/types'; +import { BlockerCard, blockerAnswer } from './BlockerCard'; +import { + ChatLine, + type OpenPair, + ScreenshotViewer, + VisualPairViewer, + VisualPairs, + WorkflowRail, + buildingWindowName, + roleName, + screenshotUrl, +} from './BuildingPodScreen'; +import { TicketMark } from './LambdaTickets'; +import { Markdown } from './Message'; +import { PhaseStrip } from './PhaseStrip'; +import { StarButton } from './ProjectIcon'; +import { MadeRail } from './Prototypes'; +import { + MIN_CHAT, + RailHost, + type RailId, + RailSwitcher, + type Rails, +} from './Rails'; +import { ViewerProvider } from './Viewer'; +import { WindowPicker } from './WindowPicker'; +import { api } from './api'; +import { errorText, latestLoader } from './loading'; +import { type ReadAloudSection, useReadAloud } from './useReadAloud'; + +/** + * A Lambda's page past planning: one page for its whole life. The header + * (title, project and ticket, ⋯), the phase strip, one conversation (the + * planning before it folded to a line, a divider where each phase starts, + * the build's progress and anything waiting on the owner answered in + * place), and the phase's rail with read aloud and its main action. + */ + +const PLANNING_WINDOWS = (window: string): string => + window === 'dev' + ? 'Dev' + : window === 'manager' + ? 'Manager' + : `Fork ${window.slice(5)}`; + +/** Whoever answers, it's Lambda: the agent behind it never shows. */ +const TALKERS: Record<'you' | 'product-manager' | 'dev', string> = { + you: 'You', + 'product-manager': 'Lambda', + dev: 'Lambda', +}; + +type PagePhase = Exclude<LambdaPhase, 'planning'>; + +type MadeFilter = 'all' | 'planning' | 'building'; + +const MADE_FILTERS: MadeFilter[] = ['all', 'planning', 'building']; + +const pagePhase = (phase: LambdaPhase): PagePhase => + phase === 'planning' ? 'done' : phase; + +const COMPOSE: Record<PagePhase, { label: string; placeholder: string }> = { + queued: { + label: 'To Lambda · it knows the plan and the code', + placeholder: 'Ask about it, or change it…', + }, + building: { + label: 'To Lambda', + placeholder: 'Answer in your own words, or ask something…', + }, + qa: { + label: 'To Lambda', + placeholder: 'What should change? (sent with Send back)', + }, + shipped: { + label: 'To Lambda · it has the diff', + placeholder: 'Ask about what shipped…', + }, + done: { + label: 'To Lambda', + placeholder: 'Ask about it…', + }, +}; + +const SpeakerIcon = () => ( + <svg + width="13" + height="13" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + aria-hidden="true" + > + <path d="M4 9v6h4l5 4V5L8 9zM16 9a4 4 0 010 6" /> + </svg> +); + +/** A rail section's heading, with its read-aloud button. */ +const Heading = ({ + children, + onRead, +}: { + children: ReactNode; + onRead?: () => void; +}) => ( + <h3 className="lambda-rail__head"> + {children} + {onRead !== undefined && ( + <button + type="button" + className="lambda-rail__read" + aria-label="Read aloud" + title="Read aloud" + onClick={onRead} + > + <SpeakerIcon /> + </button> + )} + </h3> +); + +/** The player bar: read aloud, and the phase's main action (⌘⏎). */ +const PlayerBar = ({ + read, + action, + busy, + onAction, +}: { + read: ReturnType<typeof useReadAloud>; + action: string | null; + busy: boolean; + onAction: () => void; +}) => ( + <footer className="rail__actions lambda-player"> + <button + type="button" + className="lambda-player__skip" + aria-label="Previous section" + onClick={() => read.step(-1)} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> + <path d="M6 5h2v14H6zM20 5v14L9 12z" /> + </svg> + </button> + <button + type="button" + className="lambda-player__play" + aria-label={read.playing ? 'Pause' : 'Play'} + onClick={read.toggle} + > + {read.playing ? ( + <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"> + <path d="M6 4h4v16H6zM14 4h4v16h-4z" /> + </svg> + ) : ( + <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"> + <path d="M7 4v16l13-8z" /> + </svg> + )} + </button> + <button + type="button" + className="lambda-player__skip" + aria-label="Next section" + onClick={() => read.step(1)} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> + <path d="M16 5h2v14h-2zM4 5v14l11-7z" /> + </svg> + </button> + <button + type="button" + className="lambda-player__speed" + onClick={() => read.setSpeed(read.speed === 1 ? 2 : 1)} + > + {read.speed}× + </button> + <span className="lambda-player__spacer" /> + {action !== null && ( + <button + type="button" + className="primary done" + disabled={busy} + onClick={onAction} + > + <span className="done__label">{action}</span> + <span className="kbd">⌘⏎</span> + </button> + )} + </footer> +); + +export const LambdaScreen = ({ + lambda, + state, + rails, + tick, + onOpenPod, + onOpenTicket, + onPlanNew, + onRename, + onDelete, + starred, + onStar, +}: { + lambda: Lambda; + state: AppState; + rails: Rails; + tick: number; + /** Its planning chat (or a computer's page). */ + onOpenPod: (podId: string) => void; + onOpenTicket: (projectId: string, ticketId: string) => void; + /** A new planning chat about it: the New page with this text. */ + onPlanNew: (projectId: string | null, text: string) => void; + onRename: (podId: string, title: string) => void; + onDelete: (podId: string) => void; + /** It's in the sidebar's Favorites. */ + starred: boolean; + onStar: () => void; +}) => { + const [picked, setPicked] = useState<string | null>(null); + const ticket = + lambda.tickets.find((one) => one.id === picked) ?? focusTicket(lambda); + const ticketId = ticket?.id ?? lambda.ticketId; + const { projectId } = lambda; + const buildPodId = + ticket !== null && 'podId' in ticket.where ? ticket.where.podId : null; + const phase = pagePhase(lambda.phase); + const [page, setPage] = useState<TicketPage | null>(null); + const [detail, setDetail] = useState<BuildingPodDetail | null>(null); + const [planning, setPlanning] = useState<PodDetail | null>(null); + const [error, setError] = useState<string | null>(null); + const [actionError, setActionError] = useState<string | null>(null); + const failed = (caught: unknown): void => setActionError(errorText(caught)); + const [draft, setDraft] = useState(''); + const [answer, setAnswer] = useState(''); + /** The Blocked item's option picked (null: its recommendation). */ + const [choice, setChoice] = useState<number | null>(null); + const [busy, setBusy] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [planMore, setPlanMore] = useState<string | null>(null); + const [problem, setProblem] = useState<string | null>(null); + const [foldOpen, setFoldOpen] = useState(false); + const [shot, setShot] = useState<Screenshot | null>(null); + const [pair, setPair] = useState<OpenPair | null>(null); + const [now, setNow] = useState(() => Date.now()); + const [madeFilter, setMadeFilter] = useState<MadeFilter>('all'); + const loadPage = useMemo( + () => latestLoader<TicketPage>({ value: setPage, error: setError }), + [] + ); + const loadDetail = useMemo( + () => + latestLoader<BuildingPodDetail>({ value: setDetail, error: setError }), + [] + ); + const loadPlanning = useMemo( + () => latestLoader<PodDetail>({ value: setPlanning, error: setError }), + [] + ); + const loadedAt = useRef<number | null>(null); + const menu = useRef<HTMLDivElement>(null); + + // Each app event ticks: read at most every few seconds, like a building pod. + useEffect(() => { + const read = (): void => { + loadedAt.current = Date.now(); + + if (projectId !== null && ticketId !== null) { + void loadPage(api.ticketPage(projectId, ticketId)); + } + + if (buildPodId !== null) { + void loadDetail(api.building(buildPodId)); + } + + if (lambda.planningPodId !== null) { + void loadPlanning(api.pod(lambda.planningPodId)); + } + }; + const wait = buildingRefreshIn(loadedAt.current, Date.now()); + + if (wait === 0) { + read(); + + return undefined; + } + + const timer = window.setTimeout(read, wait); + + return () => window.clearTimeout(timer); + }, [ + loadPage, + loadDetail, + loadPlanning, + projectId, + ticketId, + buildPodId, + lambda.planningPodId, + tick, + ]); + + // Another ticket's page starts afresh. + useEffect(() => { + loadedAt.current = null; + setPage(null); + setDetail(null); + }, [ticketId, buildPodId]); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 30_000); + + return () => window.clearInterval(id); + }, []); + + // Each phase opens on its own rail: Ready on Tickets, building on Workflow… + const { setRail } = rails; + + useEffect(() => { + setRail(lambdaDefaultRail(phase)); + }, [lambda.key, phase, setRail]); + + // ⋯ closes on a click elsewhere or Escape. + useEffect(() => { + if (!menuOpen) { + return undefined; + } + + const away = (event: MouseEvent): void => { + if ( + menu.current !== null && + event.target instanceof Node && + !menu.current.contains(event.target) + ) { + setMenuOpen(false); + } + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setMenuOpen(false); + } + }; + window.addEventListener('mousedown', away); + window.addEventListener('keydown', onKey); + + return () => { + window.removeEventListener('mousedown', away); + window.removeEventListener('keydown', onKey); + }; + }, [menuOpen]); + + const ids: RailId[] = lambdaRailsOf(phase); + const shown: RailId | null = + rails.rail === null + ? null + : ids.includes(rails.rail) + ? rails.rail + : lambdaDefaultRail(phase); + const expanded = rails.expanded && shown !== null; + const project = + state.lambda?.projects.find((one) => one.id === projectId)?.name ?? + projectId ?? + ''; + const qaOn = + lambda.phase === 'qa' || + (detail?.steps.some((step) => step.id === 'review' && step.off !== true) ?? + false); + const strip = stripStepOf(lambda.phase); + + // What waits on the owner here, with its message on the building pod. + const waitingOn = (tag: 'blocked' | 'review') => + lambda.needsYou.find( + (entry) => + entry.tag === tag && + entry.ticketId === ticketId && + entry.messageId !== undefined + ); + const blockedEntry = waitingOn('blocked'); + const reviewEntry = waitingOn('review'); + const messageOf = (messageId: string | undefined) => + messageId === undefined + ? undefined + : detail?.thread.find((message) => message.id === messageId); + const blockedMessage = messageOf(blockedEntry?.messageId); + const blockedRun = detail?.building.runs.find( + (run) => + run.blocked !== undefined && + run.blocked.messageId === blockedEntry?.messageId + )?.blocked; + const reviewMessage = messageOf(reviewEntry?.messageId); + + const runs = detail?.building.runs.filter((run) => run.id === ticketId) ?? []; + const lastRun = runs.at(-1); + const builtMs = + lastRun === undefined + ? null + : (lastRun.shippedAt === undefined + ? now + : Date.parse(lastRun.shippedAt)) - Date.parse(lastRun.startedAt); + const commit = page?.shipped?.hash ?? null; + const divider = phaseDivider(lambda, ticket, now, { + commit, + builtMs: phase === 'qa' || phase === 'shipped' ? builtMs : null, + }); + const planningMessages = (planning?.thread ?? []).filter( + (message) => message.from !== 'system' && message.text.trim() !== '' + ); + const checkpoints = (planning?.questions ?? []).filter( + (question) => question.kind === 'checkpoint' + ).length; + const activity = + page === null + ? [] + : ticketActivity(page.events, stepDisplayName, formatMinutes); + const talk = page?.talk ?? []; + const progress = phase === 'shipped' ? epicProgress(lambda) : null; + const action = mainAction(lambda); + const description = page?.ticket.description.trim() ?? ''; + const summary = page?.summary.state === 'ready' ? page.summary : null; + const queue = + ticket === null ? null : queuePlace(ticket, detail?.typicalMs ?? null); + const ahead = ticket?.where.kind === 'queued' ? ticket.where.ahead : []; + const computer = + ticket !== null && 'computer' in ticket.where + ? ticket.where.computer + : null; + const visualRounds = + ticketId === null ? [] : detail?.visual?.[ticketId] ?? []; + const shots = + ticketId === null + ? [] + : Object.entries(detail?.screenshots ?? {}) + .filter(([key]) => key.startsWith(`${ticketId}/`)) + .flatMap(([, list]) => list); + const ttsPod = buildPodId ?? lambda.planningPodId; + const buildingNow = detail === null ? null : currentRun(detail.building); + + const sections: ReadAloudSection[] = (() => { + switch (shown) { + case 'tickets': + return [ + ...(ahead.length === 0 + ? [] + : [ + { + id: 'ahead', + text: `Ahead of it: ${ahead + .map((one) => one.title) + .join(', ')}.`, + }, + ]), + ...(description === '' ? [] : [{ id: 'ticket', text: description }]), + ]; + case 'workflow': + return blockedMessage === undefined + ? [] + : [ + { + id: 'question', + text: + blockedRun === undefined + ? blockedMessage.text + : `${blockedRun.problem} Recommended: ${blockedRun.recommendation}`, + }, + ]; + case 'review': + return reviewMessage === undefined + ? [] + : [{ id: 'review', text: reviewMessage.text }]; + case 'summary': + return [ + { + id: 'changed', + text: + summary?.after ?? + (description === '' ? lambda.title : description), + }, + ]; + case 'plan': + return summary === null + ? description === '' + ? [] + : [{ id: 'plan', text: description }] + : [ + { id: 'today', text: summary.today }, + { id: 'after', text: summary.after }, + ]; + default: + return []; + } + })(); + const read = useReadAloud(ttsPod, sections); + const readSection = (id: string) => () => read.playSection(id); + + const sendTalk = (): void => { + const text = draft.trim(); + + if (text === '' || busy || projectId === null || ticketId === null) { + return; + } + + // While it's blocked, words in the box answer the block. + if (buildPodId !== null && blockedEntry?.messageId !== undefined) { + void answerItem(buildPodId, blockedEntry.messageId, text).then(() => + setDraft('') + ); + + return; + } + + setBusy(true); + setActionError(null); + api + .talkTicket(projectId, ticketId, text) + .then(() => { + setDraft(''); + + return loadPage(api.ticketPage(projectId, ticketId)); + }) + .catch(failed) + .finally(() => setBusy(false)); + }; + + /** An answer on a building item: to its pod, about that item. */ + const answerItem = ( + podId: string, + messageId: string, + text: string + ): Promise<void> => { + setBusy(true); + setActionError(null); + + return api + .chat(podId, text, messageId) + .then(() => loadDetail(api.building(podId))) + .catch(failed) + .finally(() => setBusy(false)); + }; + + const answerBlocker = (): void => { + const text = blockerAnswer(blockedRun, choice, answer); + + if ( + text === '' || + buildPodId === null || + blockedEntry?.messageId === undefined + ) { + return; + } + + void answerItem(buildPodId, blockedEntry.messageId, text).then(() => + setAnswer('') + ); + }; + + const approve = (): void => { + if (buildPodId !== null && reviewEntry?.messageId !== undefined) { + void answerItem(buildPodId, reviewEntry.messageId, 'Approved: ship it.'); + } + }; + + const sendBack = (): void => { + const text = draft.trim(); + + if (buildPodId === null || reviewEntry?.messageId === undefined) { + return; + } + + if (text === '') { + setActionError('Say what should change in the box first.'); + + return; + } + + void answerItem( + buildPodId, + reviewEntry.messageId, + `Send back: ${text}` + ).then(() => setDraft('')); + }; + + const startPlanMore = (): void => { + if (lambda.planningPodId === null) { + onPlanNew( + projectId, + ticketId === null + ? `Plan more on ${lambda.title}: ` + : `Plan more on ${ticketId} ${lambda.title}: ` + ); + + return; + } + + setPlanMore(''); + }; + + const sendPlanMore = (): void => { + const text = (planMore ?? '').trim(); + + if ( + text === '' || + projectId === null || + lambda.planningPodId === null || + busy + ) { + return; + } + + const { planningPodId } = lambda; + setBusy(true); + api + .planMore( + projectId, + planningPodId, + ticketId === null ? [] : [ticketId], + text + ) + .then(() => { + setPlanMore(null); + onOpenPod(planningPodId); + }) + .catch(failed) + .finally(() => setBusy(false)); + }; + + const planNext = (): void => + onPlanNew(projectId, `What's next after ${lambda.title}: `); + + const reportProblem = (): void => { + const text = (problem ?? '').trim(); + + if (text === '' || projectId === null || busy) { + return; + } + + setBusy(true); + api + .newTicket(projectId, { + title: text.split('\n')[0]?.slice(0, 120) ?? text, + description: `${text}\n\nFound after ${ + ticketId ?? lambda.title + } shipped.`, + status: 'planning', + labels: ['Bug'], + }) + .then(() => setProblem(null)) + .catch(failed) + .finally(() => setBusy(false)); + }; + + const runAction = (): void => { + switch (action) { + case 'Plan more': + startPlanMore(); + + break; + case 'Answer, and carry on': + answerBlocker(); + + break; + case 'Approve and merge': + approve(); + + break; + case "Plan what's next": + planNext(); + + break; + default: + } + }; + + // ⌘⏎ runs the main action, from anywhere on the page. + const runActionRef = useRef(runAction); + runActionRef.current = runAction; + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + runActionRef.current(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, []); + + const player = ( + <PlayerBar read={read} action={action} busy={busy} onAction={runAction} /> + ); + const compose = COMPOSE[phase]; + const buildingSummary = state.pods.find( + (summaryOf) => summaryOf.pod.id === buildPodId + ); + const computerPaused = buildingSummary?.pod.status === 'paused'; + + return ( + <div + className="workspace" + style={{ + gridTemplateColumns: + shown === null || expanded + ? 'minmax(0, 1fr)' + : `minmax(${MIN_CHAT}px, 1fr) ${rails.width}px`, + }} + > + <main className="main lambda-page" hidden={expanded}> + <div className="toolbar lambda-page__toolbar"> + <h1 className="toolbar__title"> + {lambda.title} + <span className="lambda-page__where">{project}</span> + {projectId !== null && ticketId !== null && ( + <button + type="button" + className="toolbar__ticket" + title="Open the ticket" + onClick={() => onOpenTicket(projectId, ticketId)} + > + {ticketId} + </button> + )} + <StarButton on={starred} onToggle={onStar} what="Lambda" /> + </h1> + <div className="toolbar__right"> + {phase === 'queued' && ( + <button + type="button" + className="lambda-page__button" + onClick={startPlanMore} + > + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + aria-hidden="true" + > + <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /> + </svg> + Plan more + </button> + )} + {phase === 'shipped' && ( + <button + type="button" + className="lambda-page__button" + onClick={() => setProblem('')} + > + Report a problem + </button> + )} + <div className="lambda-page__menu" ref={menu}> + <button + type="button" + className="lambda-page__more" + aria-label="More" + title="Pause, rename, delete…" + aria-expanded={menuOpen} + onClick={() => setMenuOpen(!menuOpen)} + > + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + > + <circle cx="5" cy="12" r="1.6" /> + <circle cx="12" cy="12" r="1.6" /> + <circle cx="19" cy="12" r="1.6" /> + </svg> + </button> + {menuOpen && ( + <div + className="context-menu lambda-page__menu-list" + role="menu" + > + {lambda.planningPodId !== null && ( + <button + type="button" + role="menuitem" + onClick={() => { + setMenuOpen(false); + + if (lambda.planningPodId !== null) { + onOpenPod(lambda.planningPodId); + } + }} + > + Open the planning chat + </button> + )} + {projectId !== null && ticketId !== null && ( + <button + type="button" + role="menuitem" + onClick={() => { + setMenuOpen(false); + onOpenTicket(projectId, ticketId); + }} + > + Open the ticket + </button> + )} + {buildPodId !== null && ( + <button + type="button" + role="menuitem" + onClick={() => { + setMenuOpen(false); + (computerPaused + ? api.resume(buildPodId) + : api.pause(buildPodId) + ).catch(failed); + }} + > + {computerPaused + ? `Resume ${computer ?? 'the computer'}` + : `Pause ${computer ?? 'the computer'}`} + </button> + )} + {lambda.planningPodId !== null && ( + <> + <button + type="button" + role="menuitem" + onClick={() => { + setMenuOpen(false); + const title = window.prompt( + 'Rename this Lambda', + lambda.title + ); + + if ( + title !== null && + title.trim() !== '' && + lambda.planningPodId !== null + ) { + onRename(lambda.planningPodId, title.trim()); + } + }} + > + Rename + </button> + <button + type="button" + role="menuitem" + className="context-menu__danger" + onClick={() => { + setMenuOpen(false); + + if ( + lambda.planningPodId !== null && + window.confirm( + `Delete "${lambda.title}"? Its planning chat goes; its tickets stay.` + ) + ) { + onDelete(lambda.planningPodId); + } + }} + > + Delete + </button> + </> + )} + </div> + )} + </div> + <RailSwitcher + rails={rails} + shown={shown} + badges={{ + workflow: blockedEntry !== undefined && shown !== 'workflow', + review: reviewEntry !== undefined && shown !== 'review', + }} + ids={ids} + /> + </div> + </div> + <div className="lambda-page__strip"> + <PhaseStrip at={strip} qaOn={qaOn} /> + </div> + {error !== null && ( + <button + type="button" + className="error" + onClick={() => setError(null)} + > + {error} (dismiss) + </button> + )} + {actionError !== null && ( + <button + type="button" + className="error" + onClick={() => setActionError(null)} + > + {actionError} (dismiss) + </button> + )} + + <section className="lambda-thread" aria-label="Conversation"> + <div className="lambda-thread__log"> + {lambda.planningPodId !== null && ( + <button + type="button" + className="lambda-thread__fold" + aria-expanded={foldOpen} + onClick={() => + lambda.planningPodId !== null && !foldOpen + ? setFoldOpen(true) + : setFoldOpen(!foldOpen) + } + > + <svg + width="10" + height="10" + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + className={foldOpen ? 'lambda-thread__caret--open' : ''} + > + <path d="M8 5l9 7-9 7z" /> + </svg> + {foldedLine(strip, planningMessages.length, checkpoints)} + </button> + )} + {foldOpen && ( + <div className="lambda-thread__folded"> + {planningMessages.slice(-12).map((message) => ( + <div + key={message.id} + className={ + message.from === 'human' + ? 'msg msg--you' + : 'msg msg--claude' + } + > + {message.from !== 'human' && ( + <span className="msg__label">Lambda</span> + )} + <Markdown text={message.text} /> + </div> + ))} + {lambda.planningPodId !== null && ( + <button + type="button" + className="link-button" + onClick={() => + lambda.planningPodId !== null && + onOpenPod(lambda.planningPodId) + } + > + Open the whole planning chat › + </button> + )} + </div> + )} + {divider !== '' && ( + <div className="lambda-thread__divider" role="separator"> + <span>{divider}</span> + </div> + )} + {activity.length > 0 && ( + <div className="lambda-thread__activity"> + {activity.map((entry) => ( + <div + key={`${entry.at}/${entry.text}`} + className="lambda-thread__line" + > + <span aria-hidden="true">•</span> + <span> + <strong>{entry.who}</strong> {entry.text} + </span> + </div> + ))} + </div> + )} + {talk.map((message) => + message.from === 'you' ? ( + <div key={message.at} className="msg msg--you"> + <Markdown text={message.text} /> + </div> + ) : ( + <div key={message.at} className="lambda-thread__said"> + <span className="lambda-thread__who"> + {TALKERS[message.from]} · {ago(message.at, now)} + </span> + <Markdown text={message.text} /> + </div> + ) + )} + {blockedEntry !== undefined && ( + <BlockerCard + blocker={blockedRun} + computer={computer} + fallback={ + blockedMessage === undefined ? ( + <p className="muted">Loading the question…</p> + ) : ( + <div className="lambda-card__body"> + <Markdown text={blockedMessage.text} /> + </div> + ) + } + choice={choice} + words={answer} + busy={busy} + onChoice={setChoice} + onWords={setAnswer} + onAnswer={answerBlocker} + onPlanMore={startPlanMore} + /> + )} + {reviewMessage !== undefined && ( + <ChatLine message={reviewMessage} /> + )} + {progress !== null && ( + <article className="lambda-card"> + <strong>{progress.line}</strong> + <span className="muted">{progress.rest}</span> + <span className="lambda-card__actions"> + {projectId !== null && lambda.ticketId !== null && ( + <button + type="button" + className="lambda-page__button" + onClick={() => + lambda.ticketId !== null && + onOpenTicket(projectId, lambda.ticketId) + } + > + Open the epic + </button> + )} + <button + type="button" + className="lambda-page__button" + onClick={planNext} + > + Plan what's next + </button> + </span> + </article> + )} + {planMore !== null && ( + <article className="lambda-card"> + <strong>Plan more</strong> + <span className="muted"> + Back to its planning chat with this. Its tickets go back to + Planning until you approve again. + </span> + <textarea + className="lambda-card__answer" + rows={3} + autoFocus + value={planMore} + placeholder="What should the plan cover?" + aria-label="What should the plan cover?" + onChange={(event) => setPlanMore(event.target.value)} + /> + <span className="lambda-card__actions"> + <button + type="button" + className="primary" + disabled={busy || planMore.trim() === ''} + onClick={sendPlanMore} + > + Plan more + </button> + <button + type="button" + className="lambda-page__button" + onClick={() => setPlanMore(null)} + > + Cancel + </button> + </span> + </article> + )} + {problem !== null && ( + <article className="lambda-card"> + <strong>Report a problem</strong> + <span className="muted"> + A new ticket in Planning, labelled Bug. + </span> + <textarea + className="lambda-card__answer" + rows={3} + autoFocus + value={problem} + placeholder="What's wrong?" + aria-label="What's wrong?" + onChange={(event) => setProblem(event.target.value)} + /> + <span className="lambda-card__actions"> + <button + type="button" + className="primary" + disabled={busy || problem.trim() === ''} + onClick={reportProblem} + > + Make the ticket + </button> + <button + type="button" + className="lambda-page__button" + onClick={() => setProblem(null)} + > + Cancel + </button> + </span> + </article> + )} + </div> + <footer className="lambda-compose"> + <label> + <span className="lambda-compose__to"> + {blockedEntry === undefined + ? compose.label + : 'To Lambda · while it’s blocked, this answers the block'} + </span> + <textarea + rows={2} + value={draft} + placeholder={compose.placeholder} + readOnly={busy} + onChange={(event) => setDraft(event.target.value)} + onKeyDown={(event) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey + ) { + event.preventDefault(); + sendTalk(); + } + }} + /> + </label> + </footer> + </section> + </main> + + {shown === 'plan' && ( + <RailHost rails={rails} title="Plan"> + <div className="rail__scroll lambda-rail"> + {summary !== null ? ( + <> + <Heading onRead={readSection('today')}>Today</Heading> + <p className="lambda-rail__text">{summary.today}</p> + <Heading onRead={readSection('after')}>After</Heading> + <p className="lambda-rail__text">{summary.after}</p> + </> + ) : ( + description !== '' && ( + <> + <Heading onRead={readSection('plan')}>The ticket</Heading> + <div className="lambda-rail__text"> + <Markdown text={description} /> + </div> + </> + ) + )} + {lambda.tickets.length > 1 && ( + <> + <Heading>Its tickets</Heading> + <ul className="lambda-rail__list"> + {lambda.tickets.map((one) => ( + <li key={one.id}> + <button + type="button" + className={`lambda-rail__ticket${ + one.id === ticketId ? ' lambda-rail__ticket--on' : '' + }`} + onClick={() => setPicked(one.id)} + > + <TicketMark where={one.where} /> + <span className="lambda-rail__grow">{one.title}</span> + <span className="lambda-rail__note">{one.id}</span> + </button> + </li> + ))} + </ul> + </> + )} + {lambda.planningPodId !== null && ( + <button + type="button" + className="link-button" + onClick={() => + lambda.planningPodId !== null && + onOpenPod(lambda.planningPodId) + } + > + Open the planning chat › + </button> + )} + </div> + {player} + </RailHost> + )} + + {shown === 'tickets' && ( + <RailHost rails={rails} title="Tickets"> + <div className="rail__scroll lambda-rail"> + {queue !== null && ( + <div className="lambda-rail__place"> + <div className="lambda-rail__big">{queue.headline}</div> + <div className="lambda-rail__muted">{queue.detail}</div> + </div> + )} + {ticket?.where.kind === 'queued' && ( + <> + <Heading + onRead={ahead.length > 0 ? readSection('ahead') : undefined} + > + Ahead of it + </Heading> + {ahead.map((one, index) => { + const current = + buildingNow?.id === one.id ? buildingNow : null; + const firstQueued = + index === (buildingNow?.id === ahead[0]?.id ? 1 : 0); + + return ( + <div key={one.id} className="lambda-rail__row"> + <TicketMark + where={ + current === null + ? { kind: 'waiting' } + : { + kind: 'building', + step: current.step, + startedAt: '', + computer: '', + podId: '', + } + } + /> + <span className="lambda-rail__grow">{one.title}</span> + <span className="lambda-rail__note"> + {current !== null + ? stepDisplayName(current.step).toLowerCase() + : firstQueued + ? 'next' + : ''} + </span> + </div> + ); + })} + <div className="lambda-rail__row lambda-rail__row--this"> + <TicketMark where={{ kind: 'waiting' }} /> + <span className="lambda-rail__grow">{ticket.title}</span> + <span className="lambda-rail__note">this one</span> + </div> + {ahead.length > 0 && projectId !== null && ( + <button + type="button" + className="link-button" + onClick={() => { + const before = ahead.at(-2); + api + .moveTicket(projectId, ticket.id, before?.id ?? null) + .catch(failed); + }} + > + Move it up + </button> + )} + </> + )} + {description !== '' && ( + <> + <Heading onRead={readSection('ticket')}>The ticket</Heading> + <div className="lambda-rail__text"> + <Markdown text={description} /> + </div> + </> + )} + </div> + {player} + </RailHost> + )} + + {shown === 'workflow' && ( + <RailHost rails={rails} title="Workflow"> + <div className="rail__scroll lambda-rail"> + {computer !== null && ( + <div className="lambda-rail__muted"> + On {computer} + {builtMs !== null && phase === 'building' + ? ` · this ticket ${formatMinutes(builtMs)}` + : ''} + </div> + )} + {detail === null || ticketId === null ? ( + <p className="rail__empty"> + {buildPodId === null + ? 'No computer has taken it yet.' + : 'Loading…'} + </p> + ) : ( + <WorkflowRail + detail={detail} + now={now} + ticketId={ticketId} + onShot={setShot} + onPair={setPair} + /> + )} + {blockedRun !== undefined ? ( + <> + <Heading onRead={readSection('question')}>The question</Heading> + <p className="lambda-rail__text"> + {blockedRun.problem} Recommended: {blockedRun.recommendation} + </p> + </> + ) : ( + blockedMessage !== undefined && ( + <> + <Heading onRead={readSection('question')}> + The question + </Heading> + <div className="lambda-rail__text"> + <Markdown text={blockedMessage.text} /> + </div> + </> + ) + )} + </div> + {player} + </RailHost> + )} + + {shown === 'review' && ( + <RailHost rails={rails} title="Review"> + <div className="rail__scroll lambda-rail"> + <Heading + onRead={ + reviewMessage === undefined ? undefined : readSection('review') + } + > + Pictures + </Heading> + {visualRounds.length === 0 && shots.length === 0 ? ( + <p className="lambda-rail__muted">No pictures for this one.</p> + ) : ( + <div className="lambda-rail__pictures"> + {buildPodId !== null && ticketId !== null && ( + <VisualPairs + podId={buildPodId} + ticketId={ticketId} + rounds={visualRounds} + onPair={setPair} + /> + )} + </div> + )} + <button + type="button" + className="link-button" + onClick={() => rails.setRail('made')} + > + All pictures, in Made › + </button> + <Heading>Checks</Heading> + <div className="lambda-rail__checks"> + <span> + {page?.verdict === 'COMPLETE' ? '✓' : '○'} Check ticket:{' '} + {page?.verdict ?? 'not yet'} + </span> + {activity + .filter((entry) => entry.text.includes('✓')) + .slice(-1) + .map((entry) => ( + <span key={entry.at}>{entry.text}</span> + ))} + </div> + <button + type="button" + className="lambda-page__button lambda-rail__send-back" + disabled={busy || reviewEntry === undefined} + onClick={sendBack} + > + Send back + </button> + </div> + {player} + </RailHost> + )} + + {shown === 'summary' && ( + <RailHost rails={rails} title="Summary"> + <div className="rail__scroll lambda-rail"> + <div className="lambda-rail__facts"> + <div className="lambda-rail__fact"> + <span>Commit</span> + <code>{commit === null ? '—' : commit.slice(0, 7)}</code> + </div> + <div className="lambda-rail__fact"> + <span>Build time</span> + <span>{builtMs === null ? '—' : formatMinutes(builtMs)}</span> + </div> + <div className="lambda-rail__fact"> + <span>Files</span> + <span> + {page?.shipped === null || page?.shipped === undefined + ? '—' + : filesLine(page.shipped.files)} + </span> + </div> + <div className="lambda-rail__fact"> + <span>Built on</span> + <span>{computer ?? '—'}</span> + </div> + </div> + <Heading onRead={readSection('changed')}>What changed</Heading> + <p className="lambda-rail__text"> + {summary?.after ?? + (description === '' ? lambda.title : description)} + </p> + {(page?.shipped?.files ?? []).map((file) => ( + <div key={file.path} className="lambda-rail__file"> + <span className="lambda-rail__added">+{file.added}</span> + {file.removed > 0 && ( + <span className="lambda-rail__removed">−{file.removed}</span> + )} + <span>{file.path}</span> + </div> + ))} + </div> + {player} + </RailHost> + )} + + {shown === 'made' && ( + <RailHost rails={rails} title="Made"> + <div className="rail__scroll lambda-rail"> + <div className="lambda-rail__chips" role="group" aria-label="Phase"> + {MADE_FILTERS.map((one) => ( + <button + key={one} + type="button" + aria-pressed={madeFilter === one} + className={`lambda-rail__chip${ + madeFilter === one ? ' lambda-rail__chip--on' : '' + }`} + onClick={() => setMadeFilter(one)} + > + {one === 'all' + ? 'All' + : one === 'planning' + ? 'Planning' + : 'Building'} + </button> + ))} + </div> + {madeFilter !== 'planning' && + buildPodId !== null && + ticketId !== null && + visualRounds.length > 0 && ( + <> + <h3 className="lambda-rail__group"> + Building · visual check, {visualRounds.length}{' '} + {visualRounds.length === 1 ? 'round' : 'rounds'} + </h3> + <VisualPairs + podId={buildPodId} + ticketId={ticketId} + rounds={visualRounds} + onPair={setPair} + /> + </> + )} + {madeFilter !== 'planning' && shots.length > 0 && ( + <> + <h3 className="lambda-rail__group">Building · tests</h3> + <div className="lambda-rail__shots"> + {shots.map((one) => ( + <button + key={`${one.attempt}/${one.file}`} + type="button" + className="build-shot" + title={one.conclusion} + onClick={() => setShot(one)} + > + <img src={screenshotUrl(buildPodId ?? '', one)} alt="" /> + </button> + ))} + </div> + </> + )} + {visualRounds.length === 0 && + shots.length === 0 && + (madeFilter === 'building' || lambda.planningPodId === null) && ( + <p className="lambda-rail__muted"> + Nothing made here yet: designs, prototypes and the + build's pictures show up as they're made. + </p> + )} + {madeFilter !== 'building' && lambda.planningPodId !== null && ( + <> + <h3 className="lambda-rail__group"> + Planning · designs and prototypes + </h3> + <ViewerProvider + podId={lambda.planningPodId} + refreshKey={planning?.thread.length ?? 0} + onAddToChat={(text) => setDraft(`${draft}${text}\n`)} + > + <MadeRail thread={planning?.thread ?? []} /> + </ViewerProvider> + </> + )} + </div> + </RailHost> + )} + + {shown === 'agents' && ( + <RailHost + rails={rails} + title={`Agents · ${ + buildPodId !== null ? computer ?? 'Building' : 'Planning' + }`} + dark + > + {buildPodId !== null && detail !== null ? ( + <WindowPicker + podId={buildPodId} + project={detail.pod.projectId ?? project} + agents={detail.windows} + labelOf={buildingWindowName} + fallback="manager" + onError={failed} + /> + ) : lambda.planningPodId !== null && planning !== null ? ( + <WindowPicker + podId={lambda.planningPodId} + project={planning.pod.projectId ?? project} + agents={planning.windows} + labelOf={PLANNING_WINDOWS} + fallback="dev" + onError={failed} + /> + ) : ( + <p className="rail__empty">Nothing running for it.</p> + )} + </RailHost> + )} + + {shot !== null && buildPodId !== null && ( + <ScreenshotViewer + podId={buildPodId} + shot={shot} + onClose={() => setShot(null)} + /> + )} + {pair !== null && buildPodId !== null && ( + <VisualPairViewer + podId={buildPodId} + open={pair} + onClose={() => setPair(null)} + /> + )} + </div> + ); +}; diff --git a/src/app/client/LambdaTickets.tsx b/src/app/client/LambdaTickets.tsx new file mode 100644 index 0000000..ae31693 --- /dev/null +++ b/src/app/client/LambdaTickets.tsx @@ -0,0 +1,151 @@ +import type { Lambda, LambdaTicket } from '../shared/lambdas'; + +/** + * A Lambda's tickets, beside its planning chat: the ticket or epic it's + * about, then each ticket it planned and where it is, each opening its + * ticket page. The way back from a chat to its tickets. + */ + +/** The ticket marks of the Tickets screen, small. */ +export const TicketMark = ({ where }: { where: LambdaTicket['where'] }) => { + if (where.kind === 'building' || where.kind === 'blocked') { + const colour = where.kind === 'blocked' ? '#8e2626' : '#c27b12'; + + return ( + <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"> + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke={colour} + strokeWidth="1.5" + /> + <path d="M8 3.2a4.8 4.8 0 010 9.6z" fill={colour} /> + </svg> + ); + } + + if (where.kind === 'shipped') { + return ( + <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"> + <circle cx="8" cy="8" r="7" fill="#5e6ad2" /> + <path + d="M5 8.2l2 2 4-4.2" + fill="none" + stroke="#fff" + strokeWidth="1.6" + /> + </svg> + ); + } + + return ( + <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"> + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="#52514e" + strokeWidth="1.5" + /> + </svg> + ); +}; + +/** Where a ticket is, in a word or two. */ +const whereWord = (where: LambdaTicket['where']): string => { + switch (where.kind) { + case 'building': + return `building on ${where.computer}`; + case 'blocked': + return 'blocked'; + case 'queued': + return `in line on ${where.computer}`; + case 'shipped': + return 'merged'; + default: + return ''; + } +}; + +export const LambdaTicketsRail = ({ + lambda, + onOpenTicket, +}: { + lambda: Lambda | undefined; + onOpenTicket: (projectId: string, ticketId: string) => void; +}) => { + const projectId = lambda?.projectId ?? null; + const about = lambda?.ticketId ?? null; + const planned = (lambda?.tickets ?? []).filter((one) => one.id !== about); + const open = (ticketId: string): void => { + if (projectId !== null) { + onOpenTicket(projectId, ticketId); + } + }; + + if (lambda === undefined || (about === null && planned.length === 0)) { + return ( + <p className="rail__empty"> + No tickets yet. The tickets this chat is about, or writes, show here. + </p> + ); + } + + return ( + <div className="lambda-rail"> + {about !== null && ( + <> + <h3 className="lambda-rail__head"> + {planned.length > 0 ? 'The epic' : 'The ticket'} + </h3> + <button + type="button" + className="lambda-rail__ticket lambda-rail__ticket--about" + onClick={() => open(about)} + > + <TicketMark + where={ + lambda.tickets.find((one) => one.id === about)?.where ?? { + kind: 'waiting', + } + } + /> + <span className="lambda-rail__grow"> + {lambda.tickets.find((one) => one.id === about)?.title ?? + lambda.title} + </span> + <span className="lambda-rail__note">{about} ›</span> + </button> + </> + )} + {planned.length > 0 && ( + <> + <h3 className="lambda-rail__head"> + {planned.length === 1 + ? 'Its ticket' + : `Its tickets · ${planned.length}`} + </h3> + <ul className="lambda-rail__list"> + {planned.map((one) => ( + <li key={one.id}> + <button + type="button" + className="lambda-rail__ticket" + title={whereWord(one.where)} + onClick={() => open(one.id)} + > + <TicketMark where={one.where} /> + <span className="lambda-rail__grow">{one.title}</span> + <span className="lambda-rail__note">{one.id}</span> + </button> + </li> + ))} + </ul> + </> + )} + </div> + ); +}; diff --git a/src/app/client/LaneTag.tsx b/src/app/client/LaneTag.tsx new file mode 100644 index 0000000..818c212 --- /dev/null +++ b/src/app/client/LaneTag.tsx @@ -0,0 +1,22 @@ +import type { LANES } from '../../pod-store'; + +type Lane = (typeof LANES)[number]; + +const LANE_NAMES: Record<Lane, string> = { + normal: 'Normal build', + 'quick-fix': 'Quick fix', + investigation: 'Investigation', +}; + +/** + * A ticket's lane — "Quick fix", or "Investigation → Normal build" when + * triage changes it. A glance, never read aloud. + */ +export const LaneTag = ({ lane, was }: { lane: Lane; was?: Lane }) => + was === undefined || was === lane ? ( + <span className="lane-tag">{LANE_NAMES[lane]}</span> + ) : ( + <span className="lane-tag lane-tag--changed"> + {`${LANE_NAMES[was]} → ${LANE_NAMES[lane]}`} + </span> + ); diff --git a/src/app/client/Message.tsx b/src/app/client/Message.tsx new file mode 100644 index 0000000..66d6fb2 --- /dev/null +++ b/src/app/client/Message.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from 'react'; +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { imageUrl, podImageSrc, splitImages } from '../shared/images'; +import { podFileOf, splitPrototypeLines } from '../shared/prototypes'; +import { PrototypeCard, useViewer } from './Prototypes'; + +/** + * A picture in agent text. One of this Lambda's own opens the lightbox + * (every picture it made, ← →); anything else opens in a tab. + */ +const PictureLink = ({ src, alt }: { src?: string; alt?: string }) => { + const viewer = useViewer(); + const own = src === undefined ? null : podFileOf(src); + + return ( + <a + href={src} + target="_blank" + rel="noreferrer" + onClick={(event) => { + if (viewer !== null && own !== null && own.podId === viewer.podId) { + event.preventDefault(); + viewer.openPicture(own.path); + } + }} + > + <img className="md__img" src={src} alt={alt} /> + </a> + ); +}; + +/** Agent text as markdown (GitHub flavour: tables, task lists, strikethrough). */ +export const Markdown = ({ text }: { text: string }) => ( + <div className="md"> + <ReactMarkdown + remarkPlugins={[remarkGfm]} + components={{ + a: ({ href, children }) => ( + <a href={href} target="_blank" rel="noreferrer"> + {children} + </a> + ), + img: ({ src, alt }) => <PictureLink src={src} alt={alt} />, + }} + // A pod's own picture, linked by its path on disk, is served by the + // inbox; the default's unsafe-protocol check still applies. + urlTransform={(url) => defaultUrlTransform(podImageSrc(url))} + > + {text} + </ReactMarkdown> + </div> +); + +const Images = ({ paths }: { paths: string[] }) => + paths.length === 0 ? null : ( + <div className="msg__images"> + {paths.map((filePath) => { + const url = imageUrl(filePath); + + return url === null ? ( + <code key={filePath}>{filePath}</code> + ) : ( + <a key={filePath} href={url} target="_blank" rel="noreferrer"> + <img src={url} alt="Attached" /> + </a> + ); + })} + </div> + ); + +/** + * One message, laid out like Claude Code: Claude's text sits on the page as + * markdown; the owner's is a right-aligned bubble, as typed, with any images they + * attached. A label (the manager speaking, a progress update) is a quiet note under it. + */ +export const Message = ({ + from, + text, + label, + progress = false, + latest = false, + className = '', + children, +}: { + from: 'you' | 'claude'; + text: string; + /** A quiet note under the text — omit for a plain "You"/"Claude". */ + label?: string; + progress?: boolean; + /** The newest message on an attention card (outlined). */ + latest?: boolean; + className?: string; + /** Extra controls under the text (e.g. Retry). */ + children?: ReactNode; +}) => { + const { text: withoutImages, images } = splitImages(text); + const { text: words, prototypes } = splitPrototypeLines(withoutImages); + + return ( + <div + className={`msg msg--${from}${progress ? ' msg--progress' : ''}${ + latest ? ' msg--latest' : '' + }${className === '' ? '' : ` ${className}`}`} + > + {from === 'claude' ? ( + <Markdown text={words} /> + ) : ( + words !== '' && <p className="msg__text">{words}</p> + )} + {prototypes.map((ref) => ( + <PrototypeCard key={`${ref.slug}@${ref.version}`} refOf={ref} /> + ))} + <Images paths={images} /> + {label !== undefined && <span className="msg__label">{label}</span>} + {children} + </div> + ); +}; diff --git a/src/app/client/NewPodForm.tsx b/src/app/client/NewPodForm.tsx new file mode 100644 index 0000000..ab54755 --- /dev/null +++ b/src/app/client/NewPodForm.tsx @@ -0,0 +1,566 @@ +import { + type KeyboardEvent, + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +import { splitImages, withWords, wordsOf } from '../shared/images'; +import type { LambdaInfo } from '../shared/types'; +import { useImageDrop } from './ImageDrop'; +import { ProjectDialog, type ProjectMade } from './ProjectDialog'; +import { chosenProjectId, ticketShortcut } from './projects'; + +export interface NewPodRequest { + text: string; + freeform: boolean; + /** Several ticket ids, but one pod about the first — not a batch. */ + single: boolean; + /** The project it goes to (the menu under the box). */ + projectId?: string; +} + +interface Props { + onCreate: (request: NewPodRequest) => Promise<void>; + lambda: LambdaInfo | undefined; + /** A project was added or made: re-read the state. */ + onProjectsChanged: () => void; + /** New project started the idea as a pod: open it, as a created pod is. */ + onStarted: (podId: string) => void; + /** The project picked to start with ("Plan with Lambda" from a ticket). */ + startProjectId?: string | null; +} + +/** + * Does this key send the box? ⏎ in the box itself only: the Add a project / + * New project dialog sits inside the section, so its keys bubble here too. + */ +export const sendsNewPod = (press: { + key: string; + shiftKey: boolean; + composing: boolean; + inBox: boolean; +}): boolean => + press.key === 'Enter' && !press.shiftKey && !press.composing && press.inBox; + +/** + * What the box does once a project was added or made: pick it, and for New + * project with an idea, empty the box and open the pod it started. + */ +export const projectDone = ( + kind: 'add' | 'new', + text: string, + result: ProjectMade +): { chosen: string; clearBox: boolean; open: string | null } => { + const started = kind === 'new' && text.trim() !== ''; + + return { + chosen: result.projectId, + clearBox: started, + open: started ? result.podId ?? null : null, + }; +}; + +/** `PAWNEE-12`, and a sub-ticket's `PAWNEE-2a`: its letter stays small. */ +const TICKET = /\b([A-Za-z]{2,})-(\d+[a-z]?)\b/g; + +/** The ticket ids in the text, in order, each once (as the server reads them). */ +export const ticketIdsIn = (text: string): string[] => + Array.from( + new Set( + Array.from( + text.matchAll(TICKET), + (match) => `${(match[1] ?? '').toUpperCase()}-${match[2] ?? ''}` + ) + ) + ); + +/** A ticket the box is about ("Chat with Lambda" on its page): attached, not typed. */ +export interface AboutTicket { + id: string; + title: string; +} + +/** + * What a chat about a ticket starts with: the ticket, then what was typed, + * or, with nothing typed, a summary and what to change. + */ +export const aboutTicketText = (about: AboutTicket, typed: string): string => + typed.trim() === '' + ? `About ${about.id} ${about.title}: sum up this ticket, then ask me what I'd like to work out or change.` + : `About ${about.id} ${about.title}: ${typed.trim()}`; + +/** The question over the box: a different one each visit, as ChatGPT does. */ +const GREETINGS = [ + 'What are we building next?', + 'What should we build?', + 'Where should we start?', + "What's on your mind?", + "What's worth building today?", + 'Got an idea? Let’s shape it.', + 'Which ticket should we take on?', + 'What are we planning today?', + 'What should we make better?', +]; + +const GREETING_KEY = 'pods.greeting'; +const DRAFT_KEY = 'pods.newDraft'; +const ABOUT_KEY = 'pods.newAbout'; + +/** What was typed into the box, so a refresh doesn't lose it. */ +const loadDraft = (): string => { + try { + return window.localStorage.getItem(DRAFT_KEY) ?? ''; + } catch { + return ''; + } +}; + +const saveDraft = (text: string): void => { + try { + if (text.trim() === '') { + window.localStorage.removeItem(DRAFT_KEY); + } else { + window.localStorage.setItem(DRAFT_KEY, text); + } + } catch { + // Private mode or full: the draft just won't survive a refresh. + } +}; + +const loadAbout = (): AboutTicket | null => { + try { + const saved: unknown = JSON.parse( + window.localStorage.getItem(ABOUT_KEY) ?? 'null' + ); + + return typeof saved === 'object' && + saved !== null && + 'id' in saved && + typeof saved.id === 'string' && + 'title' in saved && + typeof saved.title === 'string' + ? { id: saved.id, title: saved.title } + : null; + } catch { + return null; + } +}; + +const saveAbout = (about: AboutTicket | null): void => { + try { + if (about === null) { + window.localStorage.removeItem(ABOUT_KEY); + } else { + window.localStorage.setItem(ABOUT_KEY, JSON.stringify(about)); + } + } catch { + // Private mode: the attached ticket just won't survive a refresh. + } +}; + +/** The Lambda started: the draft (and any ticket it was about) is spent. */ +export const clearNewDraft = (): void => { + saveDraft(''); + saveAbout(null); +}; + +/** "Plan with Lambda": what the New ticket dialog held, typed in already. */ +export const startNewDraft = (text: string): void => { + saveDraft(text); + saveAbout(null); +}; + +/** "Chat with Lambda" on a ticket: the box empty, the ticket attached. */ +export const startNewAbout = (about: AboutTicket): void => { + saveDraft(''); + saveAbout(about); +}; + +/** A greeting other than the one shown last time. */ +const pickGreeting = (): string => { + const last = (() => { + try { + return Number(window.localStorage.getItem(GREETING_KEY) ?? -1); + } catch { + return -1; + } + })(); + const others = GREETINGS.map((_, index) => index).filter( + (index) => index !== last + ); + const index = others[Math.floor(Math.random() * others.length)] ?? 0; + + try { + window.localStorage.setItem(GREETING_KEY, String(index)); + } catch { + // Private mode: it may repeat, that's all. + } + + return GREETINGS[index] ?? GREETINGS[0] ?? ''; +}; + +interface Suggestion { + icon: ReactNode; + color: string; + label: string; + fill: string; +} + +/** A ticket's icon, in a tracker's violet. */ +const TICKET_ICON: Omit<Suggestion, 'label' | 'fill'> = { + icon: ( + <> + <path d="M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5z" /> + <path d="M9 9.5h6M9 14.5h4" /> + </> + ), + color: '#5e6ad2', +}; + +/** + * Starting points under the box: each fills in a beginning, nothing is sent. + * Their icons carry a little colour, as ChatGPT's do — the one place the + * app's black-and-grey palette makes room for it. The ticket one uses the + * project's own prefix, and isn't there when the project has none. + */ +const suggestions = (prefix: string | null | undefined): Suggestion[] => { + const ticket = ticketShortcut(prefix); + + return [ + ...(ticket === null ? [] : [{ ...TICKET_ICON, ...ticket }]), + { + // A bug. + icon: ( + <> + <rect x="7" y="8" width="10" height="12" rx="5" /> + <path d="M9 8a3 3 0 0 1 6 0M12 12v8M4 13h3M17 13h3M5 8l2.5 2M19 8l-2.5 2M5 19l2.5-2M19 19l-2.5-2" /> + </> + ), + color: '#e5484d', + label: 'Plan a few bugs together', + fill: 'Plan these bugs together: ', + }, + { + // A light bulb. + icon: ( + <> + <path d="M9 18h6M10 21h4" /> + <path d="M12 3a6 6 0 0 0-3.6 10.8c.6.5 1.1 1.3 1.1 2.2h5c0-.9.5-1.7 1.1-2.2A6 6 0 0 0 12 3z" /> + </> + ), + color: '#e8a317', + label: 'Talk through a new idea', + fill: '', + }, + ]; +}; + +/** + * "+ New planning Lambda", laid out like an empty ChatGPT chat: a question, + * one rounded box (+ for screenshots, ↑ to send), the project it goes to, + * and a few starting points. Whatever is written — an idea, a ticket or + * epic, a few tickets, screenshots — starts a conversation with the dev, + * who names it and, when it's a real planning job, proposes structured + * planning. ⏎ sends (⇧⏎ a new line). What's typed survives a refresh until + * it's sent. + */ +export const NewPodForm = ({ + onCreate, + lambda, + onProjectsChanged, + onStarted, + startProjectId = null, +}: Props) => { + const [text, setText] = useState(loadDraft); + const [about, setAbout] = useState(loadAbout); + const [busy, setBusy] = useState(false); + const [greeting] = useState(pickGreeting); + const [chosen, setChosen] = useState<string | null>(startProjectId); + const [menuOpen, setMenuOpen] = useState(false); + const [dialog, setDialog] = useState<'add' | 'new' | null>(null); + const box = useRef<HTMLTextAreaElement>(null); + const picker = useRef<HTMLInputElement>(null); + const { zone, over, tray, attach } = useImageDrop(text, setText); + const words = wordsOf(text); + const ids = ticketIdsIn(splitImages(text).text).filter( + (id) => id !== about?.id + ); + const ready = (text.trim() !== '' || about !== null) && !busy; + const projects = lambda?.projects ?? []; + // A new idea goes to the last project used; the menu changes it. + const projectId = chosenProjectId(chosen, lambda); + const project = projects.find((candidate) => candidate.id === projectId); + + useEffect(() => saveDraft(text), [text]); + useEffect(() => saveAbout(about), [about]); + + // The box grows with what's in it, up to a point (then it scrolls). + useLayoutEffect(() => { + const area = box.current; + + if (area !== null) { + area.style.height = 'auto'; + area.style.height = `${Math.min(area.scrollHeight, 240)}px`; + } + }, [words]); + + // Any click elsewhere closes the project menu. + useEffect(() => { + if (!menuOpen) { + return undefined; + } + + const close = (): void => setMenuOpen(false); + window.addEventListener('click', close); + + return () => window.removeEventListener('click', close); + }, [menuOpen]); + + const submit = (): void => { + if (!ready) { + return; + } + + setBusy(true); + onCreate({ + text: about === null ? text.trim() : aboutTicketText(about, text), + freeform: true, + single: false, + ...(projectId === null ? {} : { projectId }), + }).finally(() => setBusy(false)); + }; + + const onKey = (event: KeyboardEvent<HTMLElement>): void => { + if ( + sendsNewPod({ + key: event.key, + shiftKey: event.shiftKey, + composing: event.nativeEvent.isComposing, + inBox: event.target === box.current, + }) + ) { + event.preventDefault(); + submit(); + } + }; + + const start = (fill: string): void => { + setText(withWords(text, fill)); + window.requestAnimationFrame(() => { + const area = box.current; + area?.focus(); + area?.setSelectionRange(fill.length, fill.length); + }); + }; + + return ( + <section className="new-chat" onKeyDown={onKey}> + <h2 className="new-chat__ask"> + {about === null ? greeting : 'Let’s talk about this ticket.'} + </h2> + <div + className={`new-chat__box${over ? ' new-chat__box--over' : ''}${ + words.includes('\n') || + words.length > 60 || + tray !== null || + about !== null + ? ' new-chat__box--tall' + : '' + }${about === null ? '' : ' new-chat__box--about'}`} + {...zone} + > + {about !== null && ( + <span className="new-chat__about"> + <svg + viewBox="0 0 24 24" + width="14" + height="14" + aria-hidden="true" + style={{ color: TICKET_ICON.color }} + className="new-chat__suggestion-icon" + > + {TICKET_ICON.icon} + </svg> + <span className="new-chat__about-text"> + {about.id} {about.title} + </span> + <button + type="button" + className="new-chat__about-remove" + aria-label={`Not about ${about.id}`} + title="Not about this ticket" + onClick={() => { + setAbout(null); + box.current?.focus(); + }} + > + × + </button> + </span> + )} + <textarea + ref={box} + autoFocus + rows={1} + value={words} + disabled={busy} + placeholder={ + about === null + ? 'Ask Lambda…' + : `What do you want to work out about ${about.title}?` + } + aria-label="What to plan" + onChange={(event) => setText(withWords(text, event.target.value))} + /> + {tray} + <div className="new-chat__controls"> + <button + type="button" + className="new-chat__icon" + title="Attach screenshots (or drop or paste them)" + aria-label="Attach screenshots" + onClick={() => picker.current?.click()} + > + + + </button> + <input + ref={picker} + type="file" + accept="image/*" + multiple + hidden + onChange={(event) => { + attach(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> + <button + type="button" + className="new-chat__send" + title="Start (⏎)" + aria-label="Start" + disabled={!ready} + onClick={submit} + > + ↑ + </button> + </div> + {over && <div className="image-drop__hint">Drop to attach</div>} + </div> + <div className="new-chat__project"> + <button + type="button" + className="chip new-chat__project-button" + aria-haspopup="menu" + aria-expanded={menuOpen} + onClick={(event) => { + event.stopPropagation(); + setMenuOpen(!menuOpen); + }} + > + {project?.name ?? 'Add a codebase'} ▾ + </button> + {menuOpen && ( + <div + className="context-menu new-chat__project-menu" + role="menu" + onClick={(event) => event.stopPropagation()} + > + {projects.map((candidate) => ( + <button + key={candidate.id} + type="button" + role="menuitemradio" + aria-checked={candidate.id === projectId} + onClick={() => { + setChosen(candidate.id); + setMenuOpen(false); + }} + > + {candidate.id === projectId ? '✓ ' : ''} + {candidate.name} + </button> + ))} + {projects.length > 0 && <hr />} + <button + type="button" + role="menuitem" + onClick={() => { + setMenuOpen(false); + setDialog('add'); + }} + > + Add a codebase… + </button> + <button + type="button" + role="menuitem" + onClick={() => { + setMenuOpen(false); + setDialog('new'); + }} + > + New codebase… + </button> + </div> + )} + </div> + {ids.length > 0 && ( + <div className="new-chat__ids"> + {ids.map((id) => ( + <span key={id} className="chip"> + {id} + </span> + ))} + </div> + )} + {words.trim() === '' && about === null && !busy && ( + <ul className="new-chat__suggestions"> + {suggestions(project?.ticketPrefix).map((suggestion) => ( + <li key={suggestion.label}> + <button type="button" onClick={() => start(suggestion.fill)}> + <svg + className="new-chat__suggestion-icon" + viewBox="0 0 24 24" + aria-hidden="true" + style={{ color: suggestion.color }} + > + {suggestion.icon} + </svg> + {suggestion.label} + </button> + </li> + ))} + </ul> + )} + {busy && <p className="muted new-chat__busy">Starting…</p>} + {dialog !== null && ( + <ProjectDialog + kind={dialog} + idea={text.trim()} + onClose={() => setDialog(null)} + onDone={(result) => { + const done = projectDone(dialog, text, result); + setDialog(null); + setChosen(done.chosen); + + // New project starts the idea as its first plan: the box is spent. + if (done.clearBox) { + setText(''); + clearNewDraft(); + } + + onProjectsChanged(); + + if (done.open !== null) { + onStarted(done.open); + } + }} + /> + )} + </section> + ); +}; diff --git a/src/app/client/PhaseStrip.tsx b/src/app/client/PhaseStrip.tsx new file mode 100644 index 0000000..316fa3b --- /dev/null +++ b/src/app/client/PhaseStrip.tsx @@ -0,0 +1,36 @@ +import { type StripStep, phaseStrip } from '../shared/lambdaPage'; + +/** + * Planning → Ready → Building → (QA) → Merged, under a Lambda's header in + * every phase: done steps filled, this one black, the rest waiting. + */ +export const PhaseStrip = ({ + at, + qaOn, +}: { + at: StripStep; + /** The project's review step is on. */ + qaOn: boolean; +}) => ( + <nav className="phase-strip" aria-label="Phases"> + {phaseStrip(at, qaOn).map((step, index) => ( + <span key={step.id} className="phase-strip__item"> + {index > 0 && ( + <span + className={`phase-strip__line${ + step.state === 'todo' ? '' : ' phase-strip__line--done' + }`} + aria-hidden="true" + /> + )} + <span + className={`phase-strip__step phase-strip__step--${step.state}`} + aria-current={step.state === 'current' ? 'step' : undefined} + > + <span className="phase-strip__dot" aria-hidden="true" /> + {step.label} + </span> + </span> + ))} + </nav> +); diff --git a/src/app/client/PlanPlayer.tsx b/src/app/client/PlanPlayer.tsx new file mode 100644 index 0000000..98f250b --- /dev/null +++ b/src/app/client/PlanPlayer.tsx @@ -0,0 +1,261 @@ +import { type PointerEvent, type ReactNode, useRef, useState } from 'react'; + +import { MAX_RATE } from './speech'; +import { formatTime, sectionIndexAt } from './timeline'; +import type { Player } from './usePlayer'; + +/** + * The audio, in the Plan rail's footer next to Done: one row (prev, play, + * next, speed, Autoplay) with a hairline of progress along the footer's top + * edge — itself a seek bar: click or drag anywhere on it; hovering names the + * time and section. Anything about the audio that needs them ("click to enable + * audio", out of budget…) shows above the row. + */ + +/** The speed button steps through these, then back to 1× (W/S fine-tune). */ +const STEPS = [1, 1.25, 1.5, 1.75, 2, 2.5, 3, MAX_RATE]; + +const nextStep = (rate: number): number => + STEPS.find((step) => step > rate + 0.01) ?? STEPS[0] ?? 1; + +const Glyph = ({ children }: { children: ReactNode }) => ( + <svg + viewBox="0 0 24 24" + width="16" + height="16" + fill="currentColor" + aria-hidden="true" + > + {children} + </svg> +); + +/** + * A control's tooltip, above it (the player sits at the bottom): what it does, + * and its keys. Shown on hover or keyboard focus, after a beat. + */ +const Tip = ({ + text, + keys, + children, +}: { + text: string; + /** Each a key or chord with what it does, e.g. ['Space'] or ['W', 'faster']. */ + keys?: [string, string?][]; + children: ReactNode; +}) => ( + <span className="control-tip"> + {children} + <span className="control-tip__box" role="tooltip"> + {text} + {keys !== undefined && ( + <span className="control-tip__keys"> + {keys.map(([key, what]) => ( + <span key={key}> + <kbd>{key}</kbd> + {what === undefined ? '' : ` ${what}`} + </span> + ))} + </span> + )} + </span> + </span> +); + +export const PlanPlayer = ({ + player, + notices, + actions, + onSeekSection, + onStep, +}: { + player: Player; + /** A click or drag on the line landed in this section: bring it into view. */ + onSeekSection: (key: string) => void; + /** ⏮ / ⏭: a section step they asked for — the rail brings it to the top. */ + onStep: () => void; + /** "Click to enable audio", out of budget… — shown above the row. */ + notices: ReactNode; + /** Done and friends, at the row's right end. */ + actions: ReactNode; +}) => { + const line = useRef<HTMLDivElement>(null); + /** Where a drag along the line has got to (seconds), until it's let go. */ + const [drag, setDrag] = useState<number | null>(null); + const [hover, setHover] = useState<{ x: number; time: number } | null>(null); + const disabled = player.source === 'none' || player.duration <= 0; + const progress = + player.duration > 0 ? Math.min(1, player.time / player.duration) : 0; + + // The section under the pointer, named as the big bar names it. + const hoverLabel = + hover === null + ? undefined + : player.sections[sectionIndexAt(player.sections, hover.time)]?.label; + + const xAt = (event: PointerEvent<HTMLDivElement>): number => { + const rect = line.current?.getBoundingClientRect(); + + return rect === undefined + ? 0 + : Math.min(Math.max(0, event.clientX - rect.left), rect.width); + }; + + const timeAt = (event: PointerEvent<HTMLDivElement>): number => { + const width = line.current?.getBoundingClientRect().width ?? 0; + + return width === 0 ? 0 : (xAt(event) / width) * player.duration; + }; + + return ( + <div className="plan-player"> + {/* The hairline is the seek bar: click or drag anywhere along it. */} + <div + ref={line} + className={`plan-player__scrub${ + disabled ? ' plan-player__scrub--off' : '' + }`} + role="slider" + aria-label="Audio position" + aria-valuemin={0} + aria-valuemax={Math.round(player.duration)} + aria-valuenow={Math.round(drag ?? player.time)} + onPointerDown={(event) => { + if (disabled) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setDrag(timeAt(event)); + }} + onPointerMove={(event) => { + setHover({ x: xAt(event), time: timeAt(event) }); + + if (drag !== null) { + setDrag(timeAt(event)); + } + }} + onPointerUp={() => { + if (drag !== null) { + player.seek(drag); + setDrag(null); + const landed = + player.sections[sectionIndexAt(player.sections, drag)]?.key; + + if (landed !== undefined) { + onSeekSection(landed); + } + } + }} + onPointerLeave={() => setHover(null)} + > + <div + className="plan-player__progress" + style={{ + width: `${ + (drag === null + ? progress + : player.duration > 0 + ? drag / player.duration + : 0) * 100 + }%`, + }} + /> + {hover !== null && !disabled && ( + <span className="plan-player__time" style={{ left: hover.x }}> + {formatTime(hover.time)} + {hoverLabel === undefined ? '' : ` · ${hoverLabel}`} + </span> + )} + </div> + + {notices !== null && ( + <div className="plan-player__notices">{notices}</div> + )} + + <div className="plan-player__row"> + <Tip + text="Back to the start of this section — or, at its start, the one before." + keys={[['A'], ['⇧←']]} + > + <button + type="button" + className="plan-player__button" + aria-label="Previous section" + disabled={disabled} + onClick={() => { + onStep(); + player.previous(); + }} + > + <Glyph> + <path d="M6 5h2v14H6zM20 5v14L9.5 12z" /> + </Glyph> + </button> + </Tip> + <Tip + text={ + player.playing + ? 'Pause the plan being read aloud.' + : 'Read the plan aloud from here.' + } + keys={[['Space'], ['←', 'back 5s'], ['→', 'ahead 5s']]} + > + <button + type="button" + className={`plan-player__button plan-player__button--play${ + player.loadingKey !== null || player.extra?.loading === true + ? ' plan-player__button--loading' + : '' + }`} + aria-label={player.playing ? 'Pause' : 'Play'} + disabled={disabled} + onClick={player.toggle} + > + <Glyph> + {player.playing ? ( + <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /> + ) : ( + <path d="M8 5v14l11-7z" /> + )} + </Glyph> + </button> + </Tip> + <Tip text="Skip to the next section." keys={[['D'], ['⇧→']]}> + <button + type="button" + className="plan-player__button" + aria-label="Next section" + disabled={disabled} + onClick={() => { + onStep(); + player.next(); + }} + > + <Glyph> + <path d="M16 5h2v14h-2zM4 5v14l10.5-7z" /> + </Glyph> + </button> + </Tip> + <Tip + text="Reading speed. Click for the next step; after the fastest it goes back to 1×." + keys={[ + ['W', 'faster'], + ['S', 'slower'], + ]} + > + <button + type="button" + className="plan-player__rate" + aria-label="Reading speed" + onClick={() => player.setRate(nextStep(player.rate))} + > + {Number(player.rate.toFixed(2))}× + </button> + </Tip> + {/* Autoplay: not offered for now (speech.ts, AUTOPLAY_OFFERED). */} + <span className="plan-player__actions">{actions}</span> + </div> + </div> + ); +}; diff --git a/src/app/client/PodIdle.tsx b/src/app/client/PodIdle.tsx new file mode 100644 index 0000000..ac76dd1 --- /dev/null +++ b/src/app/client/PodIdle.tsx @@ -0,0 +1,106 @@ +import { PHASES, phaseName } from '../shared/phases'; +import { type PodSummary, isWorking } from '../shared/types'; +import { LambdaMark } from './CaughtUp'; + +/** One line: does the pod need the owner, and what is it doing. */ +const leadFor = (summary: PodSummary, answering: boolean): string => { + const phase = `phase ${summary.pod.phase} (${phaseName( + summary.pod.phase, + summary.pod + )})`; + + // Its items wait while it answers what they asked about one of them. + if (answering) { + return "Nothing to review — it's answering your message. Its items come back when it has."; + } + + if (summary.status === 'needs-you') { + return summary.needsYou === 1 + ? 'One item is waiting for you in the inbox.' + : `${summary.needsYou} items are waiting for you in the inbox.`; + } + + if ( + summary.pod.mode === 'freeform' && + summary.status === 'working' && + !isWorking(summary) + ) { + return 'Nothing to review — it said its piece. Write in the chat whenever you want to carry on.'; + } + + if (summary.pod.mode === 'freeform' && summary.status !== 'done') { + return "Nothing to review — it's answering your last message. Its reply comes to your inbox."; + } + + if (summary.request !== undefined) { + return `Nothing to review — it's working on your request: ${summary.request.summary}. Its items come back when that's done.`; + } + + if (summary.status === 'working') { + return summary.liveForks.length > 0 + ? `Nothing to review — you're in a conversation with it about ${phase}.` + : `Nothing to review — it's working on ${phase}.`; + } + + if (summary.status === 'stalled') { + return `Stalled on ${phase}: nobody is working and nothing is waiting on you. Nudge it from the inbox.`; + } + + if (summary.status === 'waiting-step0') { + return `Planned and waiting on the step-0 ticket (${ + summary.pod.waitingOnHuman?.ticketId ?? '' + }) — the held tickets move to your Backlog once it's done.`; + } + + if (summary.status === 'paused') { + return `Paused on ${phase}.`; + } + + return 'Done — every ticket has been handed off.'; +}; + +/** + * A pod's page when nothing of its is in the inbox: like inbox zero — one + * line on what it's doing, and the way back to Auto (nothing here is + * actionable; its items come to the queue when they're ready). + */ +export const PodIdle = ({ + summary, + answering, + onAuto, +}: { + summary: PodSummary | null; + /** Set aside: busy answering them, so what it has waiting is held back. */ + answering: boolean; + /** Turn Auto on: go to whatever's waiting, or all caught up. */ + onAuto: () => void; +}) => { + const purpose = + summary === null ? undefined : PHASES[summary.pod.phase]?.purpose; + const showPurpose = + summary !== null && + summary.status !== 'done' && + summary.status !== 'waiting-step0' && + summary.pod.mode !== 'freeform'; + + return ( + <div className="empty"> + {summary !== null && ( + <LambdaMark + working={ + answering || isWorking(summary) || summary.request !== undefined + } + /> + )} + {summary !== null && ( + <p className="lead">{leadFor(summary, answering)}</p> + )} + {showPurpose && purpose !== undefined && ( + <p className="muted empty__detail">{purpose}</p> + )} + <button type="button" className="ghost" onClick={onAuto}> + Turn on Auto + </button> + </div> + ); +}; diff --git a/src/app/client/PodScreen.tsx b/src/app/client/PodScreen.tsx new file mode 100644 index 0000000..3c42c20 --- /dev/null +++ b/src/app/client/PodScreen.tsx @@ -0,0 +1,16 @@ +/** + * The pod screen: a Lambda's chat, its cards, its workflow, its terminals + * and what it made. One module, loaded as its own chunk (App.tsx imports it + * lazily), so the shell around it — sidebar, queue, pages — loads first. + */ +export { StalledCard, YourTurnCard } from './AttentionCards'; +export { OldVersionNote, VersionMenu } from './CardVersions'; +export { ChatPanel } from './ChatPanel'; +export { PlanPlayer } from './PlanPlayer'; +export { PodIdle } from './PodIdle'; +export { MadeRail } from './Prototypes'; +export { QuestionCard } from './QuestionCard'; +export { StructureCard } from './StructureCard'; +export { TerminalView } from './TerminalView'; +export { WindowPicker } from './WindowPicker'; +export { WorkflowPanel } from './WorkflowPanel'; diff --git a/src/app/client/ProjectDialog.tsx b/src/app/client/ProjectDialog.tsx new file mode 100644 index 0000000..cc850d4 --- /dev/null +++ b/src/app/client/ProjectDialog.tsx @@ -0,0 +1,109 @@ +import { type FormEvent, useState } from 'react'; + +import { folderSlug } from '../shared/folderSlug'; +import { api } from './api'; + +/** + * Add a project (a folder on the computer running Lambda) or New project (a + * name; a folder with a starter app and one passing test is made for it). + * A folder that isn't a git repository is set up quietly. Refusals show here + * and create nothing. + */ +export interface ProjectMade { + projectId: string; + podId?: string | null; +} + +export const ProjectDialog = ({ + kind, + idea, + onClose, + onDone, +}: { + kind: 'add' | 'new'; + /** New project: the idea in the box becomes its first plan. */ + idea: string; + onClose: () => void; + /** New project answers the pod it started with the idea, if any. */ + onDone: (result: ProjectMade) => void; +}) => { + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const empty = value.trim() === ''; + + const submit = (event: FormEvent): void => { + event.preventDefault(); + + if (empty || busy) { + return; + } + + setBusy(true); + setError(null); + (kind === 'add' + ? api.addProject(value.trim()) + : api.newProject(value.trim(), idea) + ) + .then(onDone) + .catch((caught: unknown) => { + setError(caught instanceof Error ? caught.message : String(caught)); + setBusy(false); + }); + }; + + return ( + <form + className="dialog" + role="dialog" + aria-label={kind === 'add' ? 'Add a codebase' : 'New codebase'} + onSubmit={submit} + > + <h2>{kind === 'add' ? 'Add a codebase' : 'New codebase'}</h2> + {kind === 'add' ? ( + <label className="dialog__field"> + <span>Folder</span> + <input + autoFocus + value={value} + placeholder="~/Lambda/pawnee-parks" + onChange={(event) => setValue(event.target.value)} + /> + </label> + ) : ( + <> + <label className="dialog__field"> + <span>Name</span> + <input + autoFocus + value={value} + placeholder="Sweetums recipe app" + onChange={(event) => setValue(event.target.value)} + /> + </label> + <p className="dialog__field"> + <span>Folder</span> + <span className="muted">~/Lambda/{folderSlug(value) || '…'}</span> + </p> + <p className="muted"> + Starts with a small web app and one passing test. + {idea === '' ? '' : ' Your idea becomes its first plan.'} + </p> + </> + )} + {error !== null && ( + <p className="error" role="alert"> + {error} + </p> + )} + <div className="dialog__actions"> + <button type="button" className="ghost" onClick={onClose}> + Cancel + </button> + <button type="submit" className="primary" disabled={empty || busy}> + {kind === 'add' ? 'Add' : 'Create'} + </button> + </div> + </form> + ); +}; diff --git a/src/app/client/ProjectIcon.tsx b/src/app/client/ProjectIcon.tsx new file mode 100644 index 0000000..a10d629 --- /dev/null +++ b/src/app/client/ProjectIcon.tsx @@ -0,0 +1,117 @@ +import { iconEmoji } from '../shared/emoji'; + +/** + * A tracker project's icon, as Linear draws it: its emoji, or a cube in its + * colour when it has none Lambda can draw. And the star that favorites a + * view, project, ticket or Lambda. + */ + +export const ProjectIcon = ({ + icon, + color, + size = 14, +}: { + icon: string | null | undefined; + color: string | null | undefined; + size?: number; +}) => { + const emoji = iconEmoji(icon); + + if (emoji !== null) { + return ( + <span + className="project-icon project-icon--emoji" + style={{ fontSize: size - 1, width: size, height: size }} + aria-hidden="true" + > + {emoji} + </span> + ); + } + + return ( + <svg + className="project-icon" + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke={color ?? 'currentColor'} + strokeWidth="1.8" + aria-hidden="true" + > + <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" /> + <path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" /> + </svg> + ); +}; + +/** A saved view's icon: its emoji, or Linear's stacked layers. */ +export const ViewIcon = ({ + icon, + size = 14, +}: { + icon: string | null | undefined; + size?: number; +}) => { + const emoji = iconEmoji(icon); + + return emoji !== null ? ( + <span + className="project-icon project-icon--emoji" + style={{ fontSize: size - 1, width: size, height: size }} + aria-hidden="true" + > + {emoji} + </span> + ) : ( + <svg + className="project-icon" + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + aria-hidden="true" + > + <path d="M12 4l9 5-9 5-9-5z" /> + <path d="M3 14l9 5 9-5" /> + </svg> + ); +}; + +export const StarButton = ({ + on, + onToggle, + what, +}: { + on: boolean; + onToggle: () => void; + /** What's starred, for its label ("view", "project"). */ + what: string; +}) => ( + <button + type="button" + className={`star-button${on ? ' star-button--on' : ''}`} + aria-pressed={on} + aria-label={ + on ? `Remove the ${what} from favorites` : `Favorite the ${what}` + } + title={on ? 'Remove from favorites' : 'Add to favorites'} + onClick={onToggle} + > + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill={on ? 'currentColor' : 'none'} + stroke="currentColor" + strokeWidth="1.8" + strokeLinejoin="round" + aria-hidden="true" + > + <path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.8l-5.2 2.8 1-5.8-4.3-4.1 5.9-.9z" /> + </svg> + </button> +); diff --git a/src/app/client/ProjectPicker.tsx b/src/app/client/ProjectPicker.tsx new file mode 100644 index 0000000..70526ac --- /dev/null +++ b/src/app/client/ProjectPicker.tsx @@ -0,0 +1,24 @@ +import type { LambdaInfo } from '../shared/types'; + +/** The project menu Analytics and Settings → Process share with the new-idea box. */ +export const ProjectPicker = ({ + lambda, + value, + onChange, +}: { + lambda: LambdaInfo | undefined; + value: string; + onChange: (projectId: string) => void; +}) => + (lambda?.projects.length ?? 0) < 2 ? null : ( + <label className="project-picker"> + <span className="muted">Codebase</span> + <select value={value} onChange={(event) => onChange(event.target.value)}> + {(lambda?.projects ?? []).map((project) => ( + <option key={project.id} value={project.id}> + {project.name} + </option> + ))} + </select> + </label> + ); diff --git a/src/app/client/ProjectsPage.tsx b/src/app/client/ProjectsPage.tsx new file mode 100644 index 0000000..99b02c9 --- /dev/null +++ b/src/app/client/ProjectsPage.tsx @@ -0,0 +1,173 @@ +import { favoriteRef } from '../shared/savedViews'; +import { ProjectIcon, StarButton } from './ProjectIcon'; +import type { Workspace } from './useWorkspace'; + +/** + * Projects, as Linear lists them: every codebase's tracker projects in one + * table — name, lead, target date, status and progress. A row opens the + * project's own page (its tickets, and its details on the right). + */ + +const shortDay = (iso: string): string => { + const day = new Date(`${iso.slice(0, 10)}T12:00:00`); + + return Number.isNaN(day.getTime()) + ? iso + : day.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +/** Linear's status ring: how far along, in the status's colour. */ +const StatusRing = ({ + progress, + type, +}: { + progress: number | null; + type: string | null; +}) => { + const done = Math.max(0, Math.min(1, progress ?? 0)); + const colour = + type === 'completed' + ? 'var(--success)' + : type === 'started' + ? 'var(--amber)' + : 'var(--grey-500)'; + const radius = 5; + const around = 2 * Math.PI * radius; + + return ( + <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"> + <circle + cx="7" + cy="7" + r="6" + fill="none" + stroke={colour} + strokeWidth="1.5" + strokeDasharray={type === 'backlog' || type === 'planned' ? '2 2' : ''} + /> + <circle + cx="7" + cy="7" + r={radius / 2} + fill="none" + stroke={colour} + strokeWidth={radius} + strokeDasharray={`${(done * around) / 2} ${around}`} + transform="rotate(-90 7 7)" + /> + </svg> + ); +}; + +export const ProjectsPage = ({ + workspace, + onOpenProject, +}: { + workspace: Workspace; + onOpenProject: (codebaseId: string, name: string) => void; +}) => { + const codebases = workspace.trackerProjects?.codebases ?? null; + const rows = (codebases ?? []).flatMap((codebase) => + 'projects' in codebase + ? codebase.projects.map((project) => ({ codebase, project })) + : [] + ); + const several = (codebases ?? []).length > 1; + + return ( + <section className="tickets projects-page"> + <header className="tickets__head"> + <h1 className="tickets__title"> + Projects <span className="tickets__count">{rows.length}</span> + </h1> + </header> + {(codebases ?? []).flatMap((codebase) => + 'offline' in codebase + ? [ + <p key={codebase.projectId} className="tickets__offline muted"> + {codebase.name}: {codebase.offline} + </p>, + ] + : [] + )} + {codebases === null ? ( + <p className="muted tickets__loading">Loading…</p> + ) : rows.length === 0 ? ( + <p className="tickets__empty muted"> + No projects. They come from each codebase's tracker: + Linear's projects, or a markdown ticket's{' '} + <code>project:</code> line. + </p> + ) : ( + <div className="projects-table" role="table" aria-label="Projects"> + <div className="projects-table__head" role="row"> + <span role="columnheader">Name</span> + {several && <span role="columnheader">Codebase</span>} + <span role="columnheader">Lead</span> + <span role="columnheader">Target date</span> + <span role="columnheader">Status</span> + <span role="columnheader" aria-label="Favorite" /> + </div> + {rows.map(({ codebase, project }) => { + const ref = favoriteRef(codebase.projectId, project.name); + const starred = workspace.isFavorite('project', ref); + + return ( + <div + key={`${codebase.projectId}/${project.id}`} + className={`projects-table__row${ + several ? ' projects-table__row--several' : '' + }`} + role="row" + > + <button + type="button" + className="projects-table__name" + onClick={() => + onOpenProject(codebase.projectId, project.name) + } + > + <ProjectIcon icon={project.icon} color={project.color} /> + <span>{project.name}</span> + </button> + {several && ( + <span className="projects-table__cell muted"> + {codebase.name} + </span> + )} + <span className="projects-table__cell"> + {project.lead ?? <span className="muted">—</span>} + </span> + <span className="projects-table__cell"> + {project.targetDate === null ? ( + <span className="muted">—</span> + ) : ( + shortDay(project.targetDate) + )} + </span> + <span className="projects-table__cell projects-table__status"> + <StatusRing + progress={project.progress} + type={project.status?.type ?? null} + /> + {project.progress === null + ? project.status?.name ?? '' + : `${Math.round(project.progress * 100)}%`} + </span> + <span className="projects-table__star"> + <StarButton + what="project" + on={starred} + onToggle={() => + workspace.setFavorite('project', ref, !starred) + } + /> + </span> + </div> + ); + })} + </div> + )} + </section> + ); +}; diff --git a/src/app/client/Prototypes.tsx b/src/app/client/Prototypes.tsx new file mode 100644 index 0000000..34d8e6e --- /dev/null +++ b/src/app/client/Prototypes.tsx @@ -0,0 +1,1217 @@ +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +import type { + PrototypeBoard, + PrototypeGroup, + PrototypeManifest, +} from '../../pod-store'; +import type { ThreadMessage } from '../../pod-store'; +import { + type Device, + type Made, + type MadePicture, + type PrototypeRef, + messageShows, + pictureOnPrototype, +} from '../shared/prototypes'; +import { type ViewMode, type ViewTarget, groupOf, useViewer } from './Viewer'; +import { api } from './api'; + +export { groupOf, useViewer } from './Viewer'; +export type { ViewMode, ViewTarget } from './Viewer'; + +/** + * Trying a Lambda's prototypes in the inbox (2026-10-20): + * + * - a chat message with `[Prototype: <slug> v<n>]` shows a card — the + * board's picture, every board side by side, and Try it — pinned to the + * version it was sent with; + * - Try it opens the focus view over the inbox: the live prototype in a + * sandboxed frame, its situations switched from the inbox's own bar, one + * board, all side by side, or a phone; New tab opens one board full + * screen; Pick puts their choice in the chat box; + * - a picture opens the lightbox, every picture this Lambda made a ← → away; + * - the Made here rail lists all of it for the Lambda on screen. + * + * The frame only talks through the bridge the server adds to each board + * (server/prototypes.ts BRIDGE_SCRIPT). + */ + +export const fileSrc = (podId: string, file: string): string => + `/api/pod-file/${encodeURIComponent(podId)}/${file + .split('/') + .map(encodeURIComponent) + .join('/')}`; + +/** + * A board's URL: its version's folder as the mothership gave it (`bases`, + * from /api/made, carrying the link token a sandboxed frame needs), plus the + * board's file. No folder yet: nothing to load. + */ +const frameSrc = ( + bases: Made['bases'], + manifest: PrototypeManifest, + board: PrototypeBoard +): string => { + const base = bases?.[`${manifest.slug}/v${manifest.version}`]; + + return base === undefined + ? 'about:blank' + : `${base}${board.file.split('/').map(encodeURIComponent).join('/')}`; +}; + +const boardOf = ( + manifest: PrototypeManifest, + letter: string | undefined +): PrototypeBoard | undefined => + manifest.boards.find((board) => board.letter === letter) ?? + manifest.boards[0]; + +/** The board's picture in a situation, else its first, else nothing. */ +export const stillOf = ( + board: PrototypeBoard, + situation: string | null | undefined +): string | undefined => + (situation === null || situation === undefined + ? undefined + : board.stills[situation]) ?? Object.values(board.stills)[0]; + +/** The first situation the board was photographed in. */ +const firstSituation = ( + manifest: PrototypeManifest, + board: PrototypeBoard | undefined +): string | null => + manifest.situations.find( + (situation) => board?.stills[situation.slug] !== undefined + )?.slug ?? + manifest.situations[0]?.slug ?? + null; + +const situationTitle = ( + manifest: PrototypeManifest, + slug: string | null +): string => + slug === null + ? 'As it opens' + : manifest.situations.find((situation) => situation.slug === slug)?.title ?? + slug; + +export const PrototypeCard = ({ refOf }: { refOf: PrototypeRef }) => { + const viewer = useViewer(); + const group = groupOf(viewer?.made ?? null, refOf.slug); + const manifest = group?.versions.find( + (version) => version.version === refOf.version + ); + const latest = group?.versions[0]; + const [letter, setLetter] = useState<string | undefined>(undefined); + + if (viewer === null) { + return null; + } + + if (manifest === undefined) { + return ( + <div className="proto-card proto-card--missing"> + <span className="proto-card__kicker">Prototype</span> + <span className="muted"> + {viewer.made === null + ? 'Loading…' + : `${refOf.slug} v${refOf.version} isn't saved in this Lambda.`} + </span> + </div> + ); + } + + const board = boardOf(manifest, letter); + const situation = firstSituation(manifest, board); + const still = board === undefined ? undefined : stillOf(board, situation); + const open = (target: Partial<ViewTarget> = {}): void => + viewer.openPrototype({ + slug: manifest.slug, + version: manifest.version, + letter: board?.letter, + situation, + ...target, + }); + + return ( + <div + className="proto-card" + data-prototype={`${manifest.slug}@${manifest.version}`} + > + <header className="proto-card__head"> + <span className="proto-card__kicker">Prototype</span> + <strong>{manifest.title}</strong> + <span className="proto-card__version"> + v{manifest.version} + {latest !== undefined && latest.version > manifest.version ? ( + <button + type="button" + className="link-button" + onClick={() => + open({ version: latest.version, letter: board?.letter }) + } + > + v{latest.version} is newer → + </button> + ) : ( + <span className="proto-card__latest" title="The newest version"> + ● + </span> + )} + </span> + </header> + <button + type="button" + className="proto-card__still" + title="Try it" + onClick={() => open()} + > + {still === undefined ? ( + <span className="muted">{board?.title}</span> + ) : ( + <img + src={fileSrc(viewer.podId, still)} + alt={`${manifest.title} — ${board?.letter} ${ + board?.title + }, ${situationTitle(manifest, situation)}`} + /> + )} + <span className="proto-card__try">⤢ Try it</span> + </button> + {manifest.boards.length > 1 && ( + <div className="proto-card__boards" role="tablist"> + {manifest.boards.map((candidate) => { + const thumb = stillOf( + candidate, + firstSituation(manifest, candidate) + ); + + return ( + <button + key={candidate.letter} + type="button" + role="tab" + aria-selected={candidate.letter === board?.letter} + className={`proto-card__board${ + candidate.letter === board?.letter + ? ' proto-card__board--on' + : '' + }`} + onClick={() => setLetter(candidate.letter)} + > + {thumb !== undefined && ( + <img src={fileSrc(viewer.podId, thumb)} alt="" /> + )} + <span> + <strong>{candidate.letter}</strong> {candidate.title} + </span> + </button> + ); + })} + </div> + )} + {manifest.note !== undefined && ( + <p className="proto-card__note"> + {manifest.version > 1 + ? `Changed since v${manifest.version - 1}: ` + : ''} + {manifest.note} + </p> + )} + <footer className="proto-card__foot"> + <span className="muted"> + {manifest.situations.length === 0 + ? 'One situation' + : `${manifest.situations.length} situation${ + manifest.situations.length === 1 ? '' : 's' + }`}{' '} + · desktop + phone + </span> + {manifest.boards.length > 1 && ( + <button + type="button" + className="link-button" + onClick={() => open({ mode: 'side' })} + > + ⊞ Compare + </button> + )} + </footer> + </div> + ); +}; + +// ── the focus view ────────────────────────────────────────────────────────── + +const PHONE = { w: 390, h: 844 }; + +/** The size of an element, kept up to date. */ +const useSize = (): [ + (node: HTMLDivElement | null) => void, + { width: number; height: number } +] => { + const [size, setSize] = useState({ width: 0, height: 0 }); + const observer = useRef<ResizeObserver | null>(null); + const ref = useCallback((node: HTMLDivElement | null) => { + observer.current?.disconnect(); + + if (node === null) { + return; + } + + const measure = (): void => + setSize({ width: node.clientWidth, height: node.clientHeight }); + observer.current = new ResizeObserver(measure); + observer.current.observe(node); + measure(); + }, []); + + return [ref, size]; +}; + +/** + * Whether the bar's button groups fit on one line. Once they wrap (a narrow + * window, or text zoomed up) they become dropdowns, and come back when the + * bar is as wide as they needed. + */ +const useRoomyBar = (): [(node: HTMLElement | null) => void, boolean] => { + const [bar, setBar] = useState<HTMLElement | null>(null); + const [roomy, setRoomy] = useState(true); + const needed = useRef(0); + + useLayoutEffect(() => { + if (bar === null) { + return undefined; + } + + const check = (): void => { + if (!roomy) { + if (bar.clientWidth >= needed.current) { + setRoomy(true); + } + + return; + } + + const items = Array.from(bar.children).filter( + (item): item is HTMLElement => + item instanceof HTMLElement && + !item.classList.contains('proto-view__spacer') + ); + const firstRowEnd = Math.min( + ...items.map((item) => item.offsetTop + item.offsetHeight) + ); + + if (!items.some((item) => item.offsetTop >= firstRowEnd)) { + return; + } + + const style = getComputedStyle(bar); + needed.current = + items.reduce((sum, item) => sum + item.offsetWidth, 0) + + (parseFloat(style.columnGap) || 0) * items.length + + parseFloat(style.paddingLeft) + + parseFloat(style.paddingRight); + setRoomy(false); + }; + const observer = new ResizeObserver(check); + observer.observe(bar); + check(); + + return () => observer.disconnect(); + }, [bar, roomy]); + + return [setBar, roomy]; +}; + +const isBridgeMessage = ( + data: unknown +): data is { lambda: 'prototype'; type: string } & Record<string, unknown> => + typeof data === 'object' && + data !== null && + 'lambda' in data && + data.lambda === 'prototype' && + 'type' in data && + typeof data.type === 'string'; + +const FOCUS_KEYS = /^[1-9]$/; + +export const FocusView = ({ + podId, + bases, + group, + initial, + onClose, + onAddToChat, +}: { + podId: string; + bases: Made['bases']; + group: PrototypeGroup; + initial: ViewTarget; + onClose: () => void; + onAddToChat: (text: string) => void; +}) => { + const [newest] = group.versions; + const [version, setVersion] = useState(initial.version); + const manifest = + group.versions.find((candidate) => candidate.version === version) ?? newest; + const [letter, setLetter] = useState( + initial.letter ?? manifest?.boards[0]?.letter ?? 'A' + ); + const [situation, setSituation] = useState<string | null>( + initial.situation === undefined + ? manifest === undefined + ? null + : firstSituation(manifest, boardOf(manifest, initial.letter)) + : initial.situation + ); + const [device, setDevice] = useState<Device>(initial.device ?? 'desktop'); + const [mode, setMode] = useState<ViewMode>(initial.mode ?? 'single'); + const [fit, setFit] = useState<'fit' | 'actual'>('fit'); + /** Situations a board reports having (its own `data-state` switches). */ + const [reported, setReported] = useState<string[]>([]); + const frames = useRef(new Map<string, HTMLIFrameElement>()); + const [stageRef, stage] = useSize(); + const [barRef, roomy] = useRoomyBar(); + + const board = manifest === undefined ? undefined : boardOf(manifest, letter); + const shown = + manifest === undefined + ? [] + : mode === 'side' && device === 'desktop' + ? manifest.boards + : board === undefined + ? [] + : [board]; + const situations = + manifest === undefined + ? [] + : manifest.situations.length > 0 + ? manifest.situations + : reported.map((slug) => ({ slug, title: slug })); + + const post = useCallback( + (frameLetter: string | null, message: Record<string, unknown>) => { + frames.current.forEach((frame, key) => { + if (frameLetter === null || frameLetter === key) { + frame.contentWindow?.postMessage(message, '*'); + } + }); + }, + [] + ); + + // Every frame shows the situation. + useEffect(() => { + if (situation !== null) { + post(null, { type: 'state', slug: situation }); + } + }, [situation, post, version, mode, device]); + + // What the boards say: they're ready, a switch they clicked, Esc. + useEffect(() => { + const onMessage = (event: MessageEvent): void => { + const from = Array.from(frames.current.entries()).find( + ([, frame]) => frame.contentWindow === event.source + ); + + if (from === undefined || !isBridgeMessage(event.data)) { + return; + } + + const { data } = event; + + if (data.type === 'escape') { + onClose(); + + return; + } + + if (data.type === 'ready') { + const states = Array.isArray(data.states) + ? data.states.filter( + (state): state is string => typeof state === 'string' + ) + : []; + setReported((before) => + before.join() === states.join() ? before : states + ); + + return; + } + + // They switched it inside the board: the bar (and the other boards) follow. + if (data.type === 'switched' && typeof data.state === 'string') { + setSituation(data.state); + + return; + } + }; + window.addEventListener('message', onMessage); + + return () => window.removeEventListener('message', onMessage); + }, [onClose]); + + // Keys, when the frame and the box don't have them. + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + const typing = + event.target instanceof HTMLTextAreaElement || + event.target instanceof HTMLInputElement; + + if (event.key === 'Escape') { + if (typing && event.target instanceof HTMLElement) { + event.target.blur(); + + return; + } + + event.preventDefault(); + onClose(); + + return; + } + + if (typing || manifest === undefined) { + return; + } + + if (FOCUS_KEYS.test(event.key)) { + const next = manifest.boards[Number(event.key) - 1]; + + if (next !== undefined) { + event.preventDefault(); + setLetter(next.letter); + } + + return; + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, [manifest, onClose]); + + if (manifest === undefined || board === undefined) { + return null; + } + + /** Into the one chat box, to finish and send there; the view closes. */ + const addToChat = (text: string): void => { + onAddToChat(text); + onClose(); + }; + + const gap = 8; + const columns = shown.length; + const frameSize = (candidate: PrototypeBoard) => + device === 'phone' ? PHONE : { w: candidate.w, h: candidate.h }; + const scaleOf = (candidate: PrototypeBoard): number => { + const size = frameSize(candidate); + const room = Math.max( + 1, + (stage.width - gap * (columns + 1)) / Math.max(1, columns) + ); + + if (fit === 'actual' && columns === 1) { + return 1; + } + + if (device === 'phone') { + return Math.min( + 1, + room / size.w, + Math.max(0.3, (stage.height - 48) / size.h) + ); + } + + // The whole window in view, like a browser: the page scrolls inside it, + // never the view around it. Side by side leaves room for the names and + // Pick buttons. + const chrome = columns > 1 ? 88 : 16; + + return Math.min(1, room / size.w, (stage.height - chrome) / size.h); + }; + + const pickLine = (candidate: PrototypeBoard): string => + `Going with ${candidate.letter} (${candidate.title}).`; + + return ( + <div + className="proto-view" + role="dialog" + aria-modal="true" + aria-label={`${manifest.title} prototype`} + onMouseDown={(event) => { + // A click on the dimmed margin hands the keys back, then closes. + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + <div className="proto-view__panel"> + <header ref={barRef} className="proto-view__bar"> + <strong className="proto-view__title">{manifest.title}</strong> + <span className="proto-view__version"> + {group.versions.length > 1 ? ( + <select + aria-label="Version" + value={manifest.version} + onChange={(event) => setVersion(Number(event.target.value))} + > + {group.versions.map((candidate) => ( + <option key={candidate.version} value={candidate.version}> + v{candidate.version} + {candidate.version === newest?.version ? ' (newest)' : ''} + </option> + ))} + </select> + ) : ( + `v${manifest.version}` + )} + </span> + {manifest.boards.length > 1 && !roomy && ( + <select + className="proto-view__pick" + aria-label="Board" + value={board.letter} + onChange={(event) => setLetter(event.target.value)} + > + {manifest.boards.map((candidate) => ( + <option key={candidate.letter} value={candidate.letter}> + {candidate.letter} {candidate.title} + </option> + ))} + </select> + )} + {manifest.boards.length > 1 && roomy && ( + <span className="seg" role="tablist" aria-label="Board"> + {manifest.boards.map((candidate, index) => ( + <button + key={candidate.letter} + type="button" + role="tab" + aria-selected={candidate.letter === board.letter} + className={`seg__button${ + candidate.letter === board.letter ? ' seg__button--on' : '' + }`} + title={`${candidate.title} (${index + 1})`} + onClick={() => setLetter(candidate.letter)} + > + {candidate.letter} + <span className="proto-view__board-name"> + {' '} + {candidate.title} + </span> + </button> + ))} + </span> + )} + {!roomy && ( + <select + className="proto-view__pick" + aria-label="View" + value={device === 'phone' ? 'phone' : mode} + onChange={(event) => { + const { value } = event.target; + + if (value === 'phone') { + setDevice('phone'); + + return; + } + + setMode(value === 'side' ? 'side' : 'single'); + setDevice('desktop'); + }} + > + <option value="single">▭ One board</option> + {manifest.boards.length > 1 && ( + <option value="side">⊞ Side by side</option> + )} + <option value="phone">▯ Phone</option> + </select> + )} + {roomy && ( + <span className="seg" aria-label="View"> + <button + type="button" + className={`seg__button${ + mode === 'single' && device === 'desktop' + ? ' seg__button--on' + : '' + }`} + title="One board" + onClick={() => { + setMode('single'); + setDevice('desktop'); + }} + > + ▭ + </button> + {manifest.boards.length > 1 && ( + <button + type="button" + className={`seg__button${ + mode === 'side' && device === 'desktop' + ? ' seg__button--on' + : '' + }`} + title="Side by side" + onClick={() => { + setMode('side'); + setDevice('desktop'); + }} + > + ⊞ + </button> + )} + <button + type="button" + className={`seg__button${ + device === 'phone' ? ' seg__button--on' : '' + }`} + title="Phone (390 × 844)" + onClick={() => setDevice('phone')} + > + ▯ + </button> + </span> + )} + {situations.length > 0 && !roomy && ( + <select + className="proto-view__pick" + aria-label="Situation" + value={situation ?? ''} + onChange={(event) => setSituation(event.target.value)} + > + {situations.map((candidate) => ( + <option key={candidate.slug} value={candidate.slug}> + {candidate.title} + </option> + ))} + </select> + )} + {situations.length > 0 && roomy && ( + <span className="seg" role="tablist" aria-label="Situation"> + {situations.map((candidate) => ( + <button + key={candidate.slug} + type="button" + role="tab" + aria-selected={candidate.slug === situation} + className={`seg__button${ + candidate.slug === situation ? ' seg__button--on' : '' + }`} + onClick={() => setSituation(candidate.slug)} + > + {candidate.title} + </button> + ))} + </span> + )} + <span className="proto-view__spacer" /> + {columns === 1 && device === 'desktop' && ( + <select + className="proto-view__zoom" + aria-label="Zoom" + value={fit} + onChange={(event) => + setFit(event.target.value === 'actual' ? 'actual' : 'fit') + } + > + <option value="fit"> + Fit {Math.round(scaleOf(board) * 100)}% + </option> + <option value="actual">100%</option> + </select> + )} + <a + className="proto-view__tool" + href={`${frameSrc(bases, manifest, board)}${ + situation === null ? '' : `?state=${situation}` + }`} + target="_blank" + rel="noopener noreferrer" + title="Open this board full screen in a new tab" + > + ⧉ New tab + </a> + <button + type="button" + className="proto-view__tool" + title="Close (Esc)" + aria-label="Close" + onClick={onClose} + > + ✕ + </button> + </header> + <div + ref={stageRef} + className={`proto-view__stage${ + device === 'phone' ? ' proto-view__stage--phone' : '' + }`} + > + {stage.width > 0 && + shown.map((candidate) => { + const size = frameSize(candidate); + const scale = scaleOf(candidate); + + return ( + <div key={candidate.letter} className="proto-view__column"> + {columns > 1 && ( + <div className="proto-view__column-head"> + <strong>{candidate.letter}</strong> {candidate.title} + </div> + )} + <div + className="proto-view__frame-box" + style={{ width: size.w * scale, height: size.h * scale }} + > + <iframe + ref={(node) => { + if (node === null) { + frames.current.delete(candidate.letter); + } else { + frames.current.set(candidate.letter, node); + } + }} + key={`${manifest.version}:${candidate.file}:${device}`} + title={`${candidate.letter} · ${candidate.title}`} + src={frameSrc(bases, manifest, candidate)} + // Scripts only: no same-origin (it can't read the + // inbox), no forms, popups or top navigation. + sandbox="allow-scripts" + style={{ + width: size.w, + height: size.h, + transform: `scale(${scale})`, + }} + onLoad={() => { + post(candidate.letter, { type: 'hello' }); + + if (situation !== null) { + post(candidate.letter, { + type: 'state', + slug: situation, + }); + } + }} + /> + </div> + {device === 'phone' && ( + <div className="muted proto-view__size">390 × 844</div> + )} + {columns > 1 && ( + <button + type="button" + className="chip" + title="Add “Going with …” to the chat box" + onClick={() => addToChat(pickLine(candidate))} + > + Pick {candidate.letter} + </button> + )} + </div> + ); + })} + </div> + </div> + </div> + ); +}; + +// ── the lightbox ──────────────────────────────────────────────────────────── + +/** The prototype and spot a picture shows, when it shows one we have. */ +export const tryTargetOf = ( + made: Made | null, + picture: string +): ViewTarget | null => { + const stillsOf = + /^designs\/([a-z0-9-]+)\/prototype\/v(\d+)\/stills\/([A-Z])\/([a-z0-9-]+)\.png$/.exec( + picture + ); + + if (stillsOf !== null) { + const slug = stillsOf[1] ?? ''; + + return groupOf(made, slug) === undefined + ? null + : { + slug, + version: Number(stillsOf[2]), + letter: stillsOf[3], + situation: stillsOf[4] === 'poster' ? null : stillsOf[4], + }; + } + + const spot = pictureOnPrototype(picture); + const newest = + spot === null ? undefined : groupOf(made, spot.slug)?.versions[0]; + + if (spot === null || newest === undefined) { + return null; + } + + return { + slug: spot.slug, + version: newest.version, + letter: spot.letter ?? newest.boards[0]?.letter, + situation: newest.situations.some( + (situation) => situation.slug === spot.situation + ) + ? spot.situation + : undefined, + }; +}; + +const pictureLabel = (picture: string): string => + picture + .replace(/^designs\//, '') + .replace(/\.png$/, '') + .split('/') + .join(' · '); + +export const Lightbox = ({ + podId, + made, + start, + onClose, + onTry, +}: { + podId: string; + made: Made | null; + start: string; + onClose: () => void; + onTry: (target: ViewTarget) => void; +}) => { + const all = useMemo(() => { + const listed = (made?.pictures ?? []).map((picture) => picture.path); + + return listed.includes(start) ? listed : [start, ...listed]; + }, [made, start]); + const [index, setIndex] = useState(() => Math.max(0, all.indexOf(start))); + const picture = all[Math.min(index, all.length - 1)] ?? start; + const target = tryTargetOf(made, picture); + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + event.preventDefault(); + const step = event.key === 'ArrowRight' ? 1 : -1; + setIndex((now) => (now + step + all.length) % all.length); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, [all.length, onClose]); + + return ( + <div + className="lightbox" + role="dialog" + aria-modal="true" + aria-label="Picture" + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + <button + type="button" + className="lightbox__close" + aria-label="Close" + onClick={onClose} + > + ✕ + </button> + {all.length > 1 && ( + <button + type="button" + className="lightbox__step lightbox__step--back" + aria-label="Previous picture" + onClick={() => setIndex((now) => (now - 1 + all.length) % all.length)} + > + ‹ + </button> + )} + <figure className="lightbox__figure"> + <img src={fileSrc(podId, picture)} alt={pictureLabel(picture)} /> + <figcaption> + <span>{pictureLabel(picture)}</span> + <span className="muted"> + {index + 1} of {all.length} + </span> + {target !== null && ( + <button + type="button" + className="chip" + onClick={() => onTry(target)} + > + ⤢ Try it live + </button> + )} + </figcaption> + </figure> + {all.length > 1 && ( + <button + type="button" + className="lightbox__step lightbox__step--next" + aria-label="Next picture" + onClick={() => setIndex((now) => (now + 1) % all.length)} + > + › + </button> + )} + </div> + ); +}; + +// ── the Made here rail ────────────────────────────────────────────────────── + +/** Scroll the chat to a message and flash it. */ +const jumpTo = (msgId: string): void => { + const node = document.querySelector(`[data-msg="${CSS.escape(msgId)}"]`); + + if (node instanceof HTMLElement) { + node.scrollIntoView({ block: 'center', behavior: 'smooth' }); + node.classList.remove('msg-flash'); + // Restart the animation when it's the same message again. + void node.offsetWidth; + node.classList.add('msg-flash'); + } +}; + +const sentIn = ( + thread: ThreadMessage[], + item: Parameters<typeof messageShows>[1] +): string | null => + [...thread].reverse().find((message) => messageShows(message.text, item)) + ?.id ?? null; + +const JumpLink = ({ msgId }: { msgId: string | null }) => + msgId === null ? ( + <span className="muted made__unsent">not sent</span> + ) : ( + <button + type="button" + className="link-button made__jump" + onClick={() => jumpTo(msgId)} + > + ↳ jump to message + </button> + ); + +type MadeTab = 'all' | 'prototypes' | 'pictures'; + +const groupLabel = (group: string): string => + group === 'shots' + ? 'Screenshots' + : group.startsWith('shots/') + ? `Screenshots · ${group.slice('shots/'.length)}` + : `Pictures · ${group.replace(/^designs\/?/, '') || 'designs'}`; + +export const MadeRail = ({ thread }: { thread: ThreadMessage[] }) => { + const viewer = useViewer(); + const [tab, setTab] = useState<MadeTab>('all'); + const refresh = viewer?.refresh; + + // Fresh each time the rail opens. + useEffect(() => { + refresh?.(); + }, [refresh]); + + if (viewer === null) { + return <p className="rail__empty">Nothing on screen.</p>; + } + + const { made, podId } = viewer; + const groups = (made?.pictures ?? []).reduce<Map<string, MadePicture[]>>( + (byGroup, picture) => + byGroup.set(picture.group, [ + ...(byGroup.get(picture.group) ?? []), + picture, + ]), + new Map() + ); + const empty = + made !== null && made.prototypes.length === 0 && made.pictures.length === 0; + + return ( + <div className="rail__scroll made"> + <div className="seg made__tabs" role="tablist"> + {(['all', 'prototypes', 'pictures'] as const).map((candidate) => ( + <button + key={candidate} + type="button" + role="tab" + aria-selected={tab === candidate} + className={`seg__button${ + tab === candidate ? ' seg__button--on' : '' + }`} + onClick={() => setTab(candidate)} + > + {candidate === 'all' + ? 'All' + : candidate === 'prototypes' + ? 'Prototypes' + : 'Pictures'} + </button> + ))} + </div> + {made === null && <p className="muted">Loading…</p>} + {empty && ( + <p className="muted"> + Nothing yet. Prototypes, design pictures and screenshots this Lambda + makes show up here. + </p> + )} + {tab !== 'pictures' && + (made?.prototypes ?? []).map((group) => { + const [latest, ...older] = group.versions; + + if (latest === undefined) { + return null; + } + + const [first] = latest.boards; + const still = + first === undefined + ? undefined + : stillOf(first, firstSituation(latest, first)); + + return ( + <section key={group.slug} className="made__item"> + <h4 className="made__kicker">Prototype</h4> + <button + type="button" + className="made__proto" + onClick={() => + viewer.openPrototype({ + slug: group.slug, + version: latest.version, + }) + } + > + {still !== undefined && ( + <img src={fileSrc(podId, still)} alt="" /> + )} + <span className="made__proto-title"> + <strong>{latest.title}</strong> + <span className="muted"> + v{latest.version} · {latest.boards.length} board + {latest.boards.length === 1 ? '' : 's'} + </span> + </span> + </button> + <JumpLink + msgId={sentIn(thread, { + prototype: { slug: group.slug, version: latest.version }, + })} + /> + {older.length > 0 && ( + <details className="made__older"> + <summary>Older versions ({older.length})</summary> + <ul> + {older.map((manifest) => ( + <li key={manifest.version}> + <button + type="button" + className="link-button" + onClick={() => + viewer.openPrototype({ + slug: group.slug, + version: manifest.version, + }) + } + > + v{manifest.version} + </button> + <span className="muted"> + {' '} + {new Date(manifest.createdAt).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} + {manifest.note === undefined + ? '' + : ` · ${manifest.note}`} + </span>{' '} + <JumpLink + msgId={sentIn(thread, { + prototype: { + slug: group.slug, + version: manifest.version, + }, + })} + /> + </li> + ))} + </ul> + </details> + )} + </section> + ); + })} + {tab !== 'prototypes' && + Array.from(groups.entries()).map(([group, pictures]) => { + const shownIn = pictures + .map((picture) => sentIn(thread, { picture: picture.path })) + .find((msgId) => msgId !== null); + + return ( + <section key={group} className="made__item"> + <h4 className="made__kicker">{groupLabel(group)}</h4> + <div className="made__grid"> + {pictures.map((picture) => ( + <button + key={picture.path} + type="button" + className="made__thumb" + title={pictureLabel(picture.path)} + onClick={() => viewer.openPicture(picture.path)} + > + <img + src={fileSrc(podId, picture.path)} + alt={pictureLabel(picture.path)} + loading="lazy" + /> + <span> + {picture.path + .split('/') + .at(-1) + ?.replace(/\.png$/, '')} + </span> + </button> + ))} + </div> + <JumpLink msgId={shownIn ?? null} /> + </section> + ); + })} + </div> + ); +}; diff --git a/src/app/client/QuestionCard.tsx b/src/app/client/QuestionCard.tsx new file mode 100644 index 0000000..9be3de5 --- /dev/null +++ b/src/app/client/QuestionCard.tsx @@ -0,0 +1,1116 @@ +import { + Fragment, + type KeyboardEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react'; + +import type { + AutonomousDecision, + Pod, + Question, + Screen, + ThreadMessage, +} from '../../pod-store'; +import { + type Timeline, + decisionCardKey, + screenCardKey, +} from '../shared/cardHistory'; +import { + checkpointGoal, + checkpointTip, + phaseName, + phaseTip, +} from '../shared/phases'; +import { podName } from '../shared/podName'; +import { + engineeringDecisionSpeech, + flowSectionKey, + screenSectionKey, + screenStatesSpeech, + ticketSectionKey, +} from '../shared/speech'; +import { CardMark } from './CardVersions'; +import { + Changes, + Proposal, + SuggestedJudgements, + Understood, +} from './CheckpointExtras'; +import { LedTo, OriginBlock } from './ItemContext'; +import { LaneTag } from './LaneTag'; +import { Markdown } from './Message'; +import { fileSrc, groupOf, useViewer } from './Prototypes'; +import { SpeakerIcon } from './SpeakerIcon'; +import { Tip } from './Tip'; +import { + type SectionKey, + type UxView, + loadUxView, + missingPictureText, + numberedDecisionsOf, + saveUxView, + screenDecisionIds, + screenView, +} from './speech'; +import type { ItemTimes } from './useActiveTime'; + +interface Props { + pod: Pod; + question: Question; + position: string; + playing: SectionKey | null; + onPlaySection: (key: SectionKey) => void; + /** The pod's whole thread and questions — for the chat and the links. */ + thread: ThreadMessage[]; + questions: Question[]; + decisions: AutonomousDecision[]; + onOpenItem: (qid: string) => void; + /** Shown when the item was opened from a link rather than the inbox. */ + status?: string; + /** On-demand audio outside the item's sequence (a screen's edge cases). */ + extra: { key: string; loading: boolean } | null; + onPlayExtra: (key: string, text: string) => void; + /** The option Done goes with (the recommendation until they pick another). */ + pickedOption?: string; + /** Clicking an option picks it. */ + onPickOption?: (optionId: string) => void; + /** Ticks on the suggested judgements (CP5), saved as they make them. */ + onKeepJudgements: (indices: number[]) => void; + /** + * Messages about this item in the chat beside the card (the card is the + * document; the conversation lives there) — and a way to jump to them. + */ + discussed: number; + onShowInChat: () => void; + /** Time on this item, beside "Discussed in chat". */ + timeNote?: ReactNode; + /** Above everything: e.g. "Viewing v2 of 4". */ + notice?: ReactNode; + /** An earlier version: shown as it was, nothing on it can be used. */ + readOnly?: boolean; + /** + * The checkpoint's versions (cardHistory.ts), the one on screen, and a way + * to show another — each card says when it last changed and opens them. + */ + versions?: { + timeline: Timeline; + viewing: number; + onView: (version: number | null, anchor: string) => void; + }; + /** Active time on this item and on each card in it. */ + times?: ItemTimes; +} + +const Section = ({ + sectionKey, + label, + playing, + onPlay, + children, +}: { + sectionKey: SectionKey; + label: string; + playing: SectionKey | null; + onPlay: (key: SectionKey) => void; + children: ReactNode; +}) => ( + <section + className={`section${playing === sectionKey ? ' section--playing' : ''}`} + data-section={sectionKey} + > + <header> + <h3>{label}</h3> + <button + className="icon" + type="button" + title={`Play ${label.toLowerCase()}`} + onClick={() => onPlay(sectionKey)} + > + <SpeakerIcon /> + </button> + </header> + {children} + </section> +); + +/** + * A screen's pictures, per situation: today's screenshot beside the design + * still, its numbered markers explained underneath. Shown, never spoken. The + * alt names the file's place, so a picture deleted after posting still says + * what it was. + */ +/** A picture of the pod's: a click opens the lightbox, every picture it made. */ +const PodPicture = ({ + podId, + file, + alt, +}: { + podId: string; + file: string; + alt: string; +}) => { + const viewer = useViewer(); + const image = ( + <img className="screen__picture" src={fileSrc(podId, file)} alt={alt} /> + ); + + return viewer === null || viewer.podId !== podId ? ( + image + ) : ( + <button + type="button" + className="screen__picture-button" + title="Open — ← → for every picture" + onClick={() => viewer.openPicture(file)} + > + {image} + </button> + ); +}; + +/** Try it: the screen's newest saved prototype, opened over the inbox. */ +const TryScreen = ({ podId, screen }: { podId: string; screen: Screen }) => { + const viewer = useViewer(); + const slug = (screen.pictures ?? []) + .flatMap((picture) => [picture.after, picture.before]) + .map((file) => /^designs\/([a-z0-9-]+)\//.exec(file ?? '')?.[1]) + .find((found) => found !== undefined); + const newest = + slug === undefined || viewer === null || viewer.podId !== podId + ? undefined + : groupOf(viewer.made, slug)?.versions[0]; + + return viewer === null || newest === undefined ? null : ( + <button + type="button" + className="link-button" + data-testid="screen-try-it" + onClick={() => + viewer.openPrototype({ slug: newest.slug, version: newest.version }) + } + > + ⤢ Try it + </button> + ); +}; + +const ScreenPictures = ({ + podId, + screen, +}: { + podId: string; + screen: Screen; +}) => ( + <> + {(screen.pictures ?? []).map((picture) => ( + <div className="screen__situation" key={picture.situation}> + <h4 className="screen__situation-title">{picture.situation}</h4> + <div className="screen__compare"> + {(['before', 'after'] as const).map((side) => { + const file = picture[side]; + + return ( + <figure key={side}> + <figcaption> + {side === 'before' ? 'Before' : 'After'} + </figcaption> + {file === undefined ? ( + <p className="muted">{missingPictureText(side, screen)}</p> + ) : ( + <PodPicture + podId={podId} + file={file} + alt={`${screen.name} — ${picture.situation}, ${side}`} + /> + )} + {side === 'after' && picture.legend.length > 0 && ( + <ol className="screen__legend"> + {picture.legend.map((entry) => ( + <li key={entry.n} value={entry.n}> + {entry.label} + </li> + ))} + </ol> + )} + </figure> + ); + })} + </div> + </div> + ))} + </> +); + +export const QuestionCard = ({ + pod, + question, + position, + playing, + onPlaySection, + thread, + questions, + decisions, + onOpenItem, + status, + extra, + onPlayExtra, + pickedOption, + onPickOption, + onKeepJudgements, + discussed, + onShowInChat, + timeNote, + notice, + readOnly = false, + versions, + times, +}: Props) => { + const isCheckpoint = + question.kind === 'checkpoint' && question.checkpoint !== undefined; + const cp = question.checkpoint?.cp; + // In the checkpoint's own order: these numbers are what the owner refers to, + // and the dev receives the same numbered list with their reply. + const reviewed = numberedDecisionsOf(question, decisions); + // Decisions a screen already shows are told under it, keeping their number. + const onScreens = screenDecisionIds(question); + const productListed = reviewed.filter( + (decision) => decision.kind !== 'engineering' && !onScreens.has(decision.id) + ).length; + const productOnScreens = reviewed.filter( + (decision) => decision.kind !== 'engineering' && onScreens.has(decision.id) + ).length; + // Details are opened per decision (click its headline); never read aloud. + const [expanded, setExpanded] = useState<Set<string>>(() => new Set()); + // The UX review's Pictures | ASCII switch, remembered across cards. + const [uxView, setUxView] = useState<UxView>(loadUxView); + const chooseUxView = (view: UxView): void => { + setUxView(view); + saveUxView(view); + }; + /** A card's "changed v8 · 2m" and its list of versions. */ + const markOf = (cardKey: string, title: string): ReactNode => + versions === undefined ? null : ( + <CardMark + cardKey={cardKey} + title={title} + timeline={versions.timeline} + viewing={versions.viewing} + onView={versions.onView} + msOf={(shownIn) => times?.cardMs(cardKey, shownIn) ?? 0} + /> + ); + /** The version on screen changed this card: tint it. */ + const changedHere = (cardKey: string): boolean => + versions !== undefined && + versions.viewing > 1 && + versions.timeline.cards.get(cardKey)?.has(versions.viewing) === true; + const toggle = (id: string): void => + setExpanded((previous) => + previous.has(id) + ? new Set([...previous].filter((candidate) => candidate !== id)) + : new Set([...previous, id]) + ); + + // The card is a live doc: when the dev edits it while the owner is looking, + // the sections whose words changed (the innermost ones) light up briefly. + const root = useRef<HTMLElement>(null); + const seen = useRef<{ id: string; texts: Map<string, string> } | null>(null); + + useEffect(() => { + const element = root.current; + + if (element === null) { + return undefined; + } + + const nodes = Array.from( + element.querySelectorAll<HTMLElement>('[data-section]') + ); + const textOf = (node: HTMLElement): string => node.innerText; + const keyOf = (node: HTMLElement): string => node.dataset.section ?? ''; + const before = seen.current; + seen.current = { + id: question.id, + texts: new Map(nodes.map((node) => [keyOf(node), textOf(node)])), + }; + + if (before === null || before.id !== question.id) { + return undefined; + } + + const changed = nodes.filter( + (node) => before.texts.get(keyOf(node)) !== textOf(node) + ); + const innermost = changed.filter( + (node) => !changed.some((other) => other !== node && node.contains(other)) + ); + innermost.forEach((node) => node.classList.add('section--updated')); + const timer = window.setTimeout( + () => + innermost.forEach((node) => node.classList.remove('section--updated')), + 6000 + ); + + return () => window.clearTimeout(timer); + }, [question]); + + const refreshError = question.checkpoint?.refreshError; + + return ( + <article className="card" ref={root}> + {notice} + {refreshError !== undefined && !readOnly && ( + <details className="card__stale"> + <summary> + The dev's last edit to this card didn't pass its checks — + showing the last good version. + </summary> + <pre>{refreshError}</pre> + </details> + )} + <div className="card__meta"> + <span className="kind">{isCheckpoint ? 'Checkpoint' : 'Decision'}</span> + <span>{podName(pod)}</span> + <span>·</span> + <Tip + className="phase-tip" + text={ + isCheckpoint && cp !== undefined + ? checkpointTip(cp, pod) + : phaseTip(question.phase, pod) + } + > + {isCheckpoint + ? `Checkpoint ${cp ?? ''}` + : `Phase ${question.phase} ${phaseName(question.phase, pod)}`} + </Tip> + {question.ticketId !== undefined && <span>· {question.ticketId}</span>} + <span>· {question.id}</span> + {status !== undefined && <span className="status-badge">{status}</span>} + <span className="card__position">{position}</span> + </div> + {(timeNote !== undefined || discussed > 0) && ( + <div className="card__versions"> + {timeNote} + {discussed > 0 && ( + <button + type="button" + className="link-button card__discussed" + title="Jump to this item's part of the chat" + onClick={onShowInChat} + > + Discussed in chat · {discussed}{' '} + {discussed === 1 ? 'message' : 'messages'} + </button> + )} + </div> + )} + {isCheckpoint && cp !== undefined && ( + <section + className={`checkpoint-goal${ + playing === 'goal' ? ' section--playing' : '' + }`} + data-section="goal" + > + <span className="checkpoint-goal__label">This checkpoint</span> + <p>{checkpointGoal(cp, pod).goal}</p> + </section> + )} + + {/* What's new first: how this got here, then the latest exchange. */} + <div + className={`card__context${ + playing === 'origin' ? ' section--playing' : '' + }`} + data-section="origin" + > + <OriginBlock + question={question} + questions={questions} + thread={thread} + onOpen={onOpenItem} + /> + </div> + <LedTo + question={question} + questions={questions} + thread={thread} + onOpen={onOpenItem} + /> + + <Section + sectionKey="summary" + label={isCheckpoint ? 'What happened' : 'Summary'} + playing={playing} + onPlay={onPlaySection} + > + <p className="lead">{question.summary}</p> + </Section> + + {isCheckpoint && + question.checkpoint?.understood !== undefined && + question.checkpoint.understood !== '' && ( + <Understood + text={question.checkpoint.understood} + playing={playing} + onPlay={onPlaySection} + /> + )} + + {isCheckpoint && question.checkpoint?.changes !== undefined && ( + <Changes + changes={question.checkpoint.changes} + since={ + question.checkpoint.cp === 4 + ? 'you approved the split' + : 'the UX review' + } + playing={playing} + onPlay={onPlaySection} + /> + )} + + {isCheckpoint && + question.checkpoint?.proposal !== undefined && + question.checkpoint.proposal.length > 0 && ( + <Proposal + tickets={question.checkpoint.proposal} + playing={playing} + onPlay={onPlaySection} + markOf={markOf} + changedHere={changedHere} + /> + )} + + {isCheckpoint && + question.checkpoint?.plan !== undefined && + question.checkpoint.plan.trim() !== '' && ( + <Section + sectionKey="plan" + label="The plan" + playing={playing} + onPlay={onPlaySection} + > + <p className="plan">{question.checkpoint.plan}</p> + </Section> + )} + + {isCheckpoint && + question.checkpoint?.screens !== undefined && + question.checkpoint.screens.length > 0 && ( + <section className="section"> + <header> + <h3>Screens ({question.checkpoint.screens.length})</h3> + <span + className="ux-view" + role="group" + aria-label="Show the screens as" + > + {(['pictures', 'ascii'] as const).map((view) => ( + <button + key={view} + type="button" + className={`toggle${uxView === view ? ' toggle--on' : ''}`} + aria-pressed={uxView === view} + data-testid={`ux-view-${view}`} + onClick={() => chooseUxView(view)} + > + {view === 'pictures' ? 'Pictures' : 'ASCII'} + </button> + ))} + </span> + <button + className="icon" + type="button" + title="Play the screens from the first" + onClick={() => onPlaySection(screenSectionKey(0))} + > + <SpeakerIcon /> + </button> + </header> + {question.checkpoint.screens.map((screen, index, all) => { + const key = screenSectionKey(index); + const cardKey = screenCardKey(screen); + // A flow's header goes before its first screen. + const flow = + screen.flow === undefined + ? undefined + : question.checkpoint?.flows?.[screen.flow]; + const startsFlow = + flow !== undefined && + (index === 0 || all[index - 1]?.flow !== screen.flow); + const flowSize = all.filter( + (candidate) => candidate.flow === screen.flow + ).length; + + return ( + <Fragment key={key}> + {startsFlow && + flow !== undefined && + screen.flow !== undefined && ( + <header + className={`flow${ + playing === flowSectionKey(screen.flow) + ? ' section--playing' + : '' + }`} + data-section={flowSectionKey(screen.flow)} + > + <div className="flow__title"> + <span className="flow__label"> + Flow {screen.flow + 1} + </span> + <strong>{flow.name}</strong> + <span className="muted"> + {flowSize === 1 + ? '1 screen' + : `${flowSize} screens`} + </span> + <button + className="icon" + type="button" + title="Play this flow" + onClick={() => + screen.flow !== undefined && + onPlaySection(flowSectionKey(screen.flow)) + } + > + <SpeakerIcon /> + </button> + </div> + <p>{flow.summary}</p> + </header> + )} + <article + data-section={key} + data-card={cardKey} + className={`screen${ + playing === key ? ' screen--playing' : '' + }${changedHere(cardKey) ? ' card--changed-here' : ''}`} + > + <header className="screen__head"> + <strong> + {index + 1}. {screen.name} + </strong> + <span className="kind kind--small">{screen.change}</span> + <span className="screen__meta"> + <span className="muted"> + Tickets {screen.tickets.join(', ')} + </span> + {markOf(cardKey, `${index + 1}. ${screen.name}`)} + <TryScreen podId={pod.id} screen={screen} /> + {screen.prototypeUrl !== undefined && ( + <a + href={screen.prototypeUrl} + target="_blank" + rel="noopener noreferrer" + data-testid="screen-prototype-link" + > + Open prototype ↗ + </a> + )} + <button + className="icon" + type="button" + title="Play from this screen" + onClick={() => onPlaySection(key)} + > + <SpeakerIcon /> + </button> + </span> + </header> + {screenView(uxView, screen) === 'pictures' ? ( + <ScreenPictures podId={pod.id} screen={screen} /> + ) : ( + <div className="screen__compare"> + <figure> + <figcaption>Before</figcaption> + {screen.before === undefined ? ( + <p className="muted"> + New screen — nothing here today. + </p> + ) : ( + <pre className="ascii">{screen.before}</pre> + )} + </figure> + <figure> + <figcaption>After</figcaption> + {screen.after === undefined ? ( + <p className="muted">Removed.</p> + ) : ( + <pre className="ascii">{screen.after}</pre> + )} + </figure> + </div> + )} + <p>{screen.changes}</p> + {(screen.decisions ?? []).length > 0 && ( + <ul className="screen__decisions"> + {(screen.decisions ?? []) + .flatMap((id) => + reviewed.flatMap((decision, number) => + decision.id === id ? [{ decision, number }] : [] + ) + ) + .map(({ decision, number }) => ( + <li + key={decision.id} + data-card={decisionCardKey(decision.id)} + className={ + changedHere(decisionCardKey(decision.id)) + ? 'card--changed-here' + : undefined + } + > + <span className="screen__decision-head"> + Decision {number + 1} · {decision.decision} + </span> + <dl className="today-after"> + <dt>Why</dt> + <dd>{decision.why}</dd> + {decision.instead !== undefined && ( + <> + <dt>Instead</dt> + <dd className="muted"> + {decision.instead} + </dd> + </> + )} + </dl> + </li> + ))} + </ul> + )} + {screen.journey !== undefined && ( + <details className="screen__journey"> + <summary>The journey</summary> + <dl className="today-after journey"> + <dt>Who</dt> + <dd>{screen.journey.who}</dd> + <dt>Before</dt> + <dd>{screen.journey.before}</dd> + <dt>Here</dt> + <dd>{screen.journey.here}</dd> + <dt>After</dt> + <dd>{screen.journey.after}</dd> + </dl> + </details> + )} + {screen.states !== undefined && + screen.states.length > 0 && ( + <> + <h4 className="screen__label"> + States & edge cases + <button + className="icon screen__extra" + type="button" + title={ + extra?.key === `${key}-states` + ? 'Stop' + : "Play these edge cases (on demand — not part of the item's audio)" + } + onClick={() => + onPlayExtra( + `${key}-states`, + screenStatesSpeech(index, screen) + ) + } + > + {extra?.key === `${key}-states` ? ( + extra.loading ? ( + '…' + ) : ( + '■' + ) + ) : ( + <SpeakerIcon /> + )} + </button> + </h4> + <ul className="screen__states"> + {screen.states.map((state) => ( + <li key={state}>{state}</li> + ))} + </ul> + </> + )} + </article> + </Fragment> + ); + })} + </section> + )} + + {isCheckpoint && + question.checkpoint?.planTickets !== undefined && + question.checkpoint.planTickets.length > 0 && ( + <section className="section"> + <header> + <h3>Tickets ({question.checkpoint.planTickets.length})</h3> + <button + className="icon" + type="button" + title="Play the tickets from the first" + onClick={() => onPlaySection(ticketSectionKey(0))} + > + <SpeakerIcon /> + </button> + </header> + <ol className="plan-tickets"> + {question.checkpoint.planTickets.map((ticket, index) => { + const key = ticketSectionKey(index); + + return ( + <li + key={ticket.id} + className={ + playing === key ? 'plan-ticket--playing' : undefined + } + data-section={key} + > + <div> + <strong>{ticket.name ?? ticket.title}</strong> + {ticket.lane !== undefined && ( + <LaneTag lane={ticket.lane} was={ticket.laneWas} /> + )} + {ticket.today !== undefined && + ticket.after !== undefined ? ( + <dl className="today-after"> + <dt>Today</dt> + <dd>{ticket.today}</dd> + <dt>After</dt> + <dd>{ticket.after}</dd> + </dl> + ) : ( + <p className="muted">{ticket.summary}</p> + )} + </div> + <button + className="icon" + type="button" + title="Play from this ticket" + onClick={() => onPlaySection(key)} + > + <SpeakerIcon /> + </button> + </li> + ); + })} + </ol> + </section> + )} + + {question.context !== undefined && ( + <Section + sectionKey="context" + label="Context" + playing={playing} + onPlay={onPlaySection} + > + <Markdown text={question.context} /> + </Section> + )} + + {question.problem !== undefined && !isCheckpoint && ( + <Section + sectionKey="problem" + label="Problem" + playing={playing} + onPlay={onPlaySection} + > + <Markdown text={question.problem} /> + </Section> + )} + + {question.options.length > 0 && ( + <Section + sectionKey="options" + label="Options" + playing={playing} + onPlay={onPlaySection} + > + <ol className="options"> + {question.options.map((option) => ( + <li + key={option.id} + className={`option${ + pickedOption === option.id ? ' option--picked' : '' + }${onPickOption === undefined ? '' : ' option--pickable'}`} + {...(onPickOption === undefined + ? {} + : { + role: 'button', + tabIndex: 0, + 'aria-pressed': pickedOption === option.id, + onClick: () => onPickOption(option.id), + onKeyDown: (event: KeyboardEvent<HTMLLIElement>) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onPickOption(option.id); + } + }, + })} + > + <strong> + {option.id}) {option.label} + </strong> + {question.recommendation?.optionId === option.id && ( + <span className="option__rec">Recommended</span> + )} + <p>{option.detail}</p> + <p className="muted">Trade-offs: {option.tradeoffs}</p> + </li> + ))} + </ol> + </Section> + )} + + {question.recommendation !== undefined && ( + <Section + sectionKey="recommendation" + label="Recommendation" + playing={playing} + onPlay={onPlaySection} + > + <p> + <strong>{question.recommendation.optionId}</strong> —{' '} + {question.recommendation.why} + </p> + </Section> + )} + + {question.visual !== undefined && ( + <Section + sectionKey="visual" + label="UX" + playing={playing} + onPlay={onPlaySection} + > + <pre className="ascii">{question.visual.ascii}</pre> + <p>{question.visual.notes}</p> + </Section> + )} + + {isCheckpoint && ( + <section className="section"> + <header> + <h3>Decisions made for you ({productListed})</h3> + </header> + {productOnScreens > 0 && ( + <p className="muted"> + {productOnScreens === 1 + ? '1 more is' + : `${productOnScreens} more are`}{' '} + under the screens that show{' '} + {productOnScreens === 1 ? 'it' : 'them'}. + </p> + )} + {reviewed.length === 0 && <p className="muted">None this phase.</p>} + {[false, true].map((engineeringGroup) => { + // Product calls first; engineering calls in their own collapsed + // group (their 🔊 plays on demand — not part of the item's audio). + const group = reviewed + .map((decision, index) => ({ decision, index })) + .filter( + ({ decision }) => + (decision.kind === 'engineering') === engineeringGroup && + !onScreens.has(decision.id) + ); + + if (group.length === 0) { + return null; + } + + const list = ( + <ol className="decisions"> + {group.map(({ decision, index }) => { + const key = `decision-${index + 1}`; + const spokenOnDemand = engineeringGroup; + const open = expanded.has(decision.id); + // Newer decisions read like the tickets: Means / Why / Instead, + // always visible. Older ones keep the headline + why shape. + const structured = decision.means !== undefined; + const hasDetails = + decision.details !== undefined || + (!structured && decision.instead !== undefined); + + return ( + <li + key={decision.id} + data-section={key} + data-card={decisionCardKey(decision.id)} + className={`decision${ + playing === key ? ' decision--playing' : '' + }${hasDetails ? ' decision--expandable' : ''}${ + changedHere(decisionCardKey(decision.id)) + ? ' card--changed-here' + : '' + }`} + > + <span className="decision__number">{index + 1}</span> + <div className="decision__body"> + <div className="decision__mark"> + {markOf( + decisionCardKey(decision.id), + `Decision ${index + 1}` + )} + </div> + <button + type="button" + className="decision__headline" + disabled={!hasDetails} + aria-expanded={hasDetails ? open : undefined} + onClick={() => toggle(decision.id)} + > + {hasDetails && ( + <span className="decision__chevron"> + {open ? '▾' : '▸'} + </span> + )} + {decision.ux === true && ( + <span className="kind kind--small">UX</span> + )} + {decision.decision} + {decision.tickets !== undefined && + decision.tickets.length > 0 && ( + <span className="decision__tickets"> + {decision.tickets.length === 1 + ? 'Ticket' + : 'Tickets'}{' '} + {decision.tickets.join(', ')} + </span> + )} + {/* Inside the button: clicking the justification toggles too. */} + {!structured && ( + <span className="decision__why muted"> + {decision.why} + </span> + )} + </button> + {structured && ( + <dl className="today-after decision__parts"> + {decision.before !== undefined && ( + <> + <dt>Before</dt> + <dd>{decision.before}</dd> + </> + )} + <dt> + {decision.before === undefined + ? 'Means' + : 'After'} + </dt> + <dd>{decision.means}</dd> + <dt>Why</dt> + <dd>{decision.why}</dd> + {decision.instead !== undefined && ( + <> + <dt>Instead</dt> + <dd className="muted">{decision.instead}</dd> + </> + )} + </dl> + )} + {open && + !structured && + decision.instead !== undefined && ( + <p className="decision__instead"> + <strong>Instead of:</strong> {decision.instead} + </p> + )} + {open && decision.details !== undefined && ( + <pre className="decision__details"> + {decision.details} + </pre> + )} + </div> + <button + type="button" + className="icon" + title={ + spokenOnDemand + ? 'Play this engineering decision (on demand)' + : `Play decision ${index + 1}` + } + onClick={() => + spokenOnDemand + ? onPlayExtra( + key, + engineeringDecisionSpeech({ + number: index + 1, + headline: decision.decision, + why: decision.why, + before: decision.before, + means: decision.means, + instead: decision.instead, + }) + ) + : onPlaySection(key) + } + > + {spokenOnDemand && extra?.key === key ? ( + extra.loading ? ( + '…' + ) : ( + '■' + ) + ) : ( + <SpeakerIcon /> + )} + </button> + </li> + ); + })} + </ol> + ); + + return engineeringGroup ? ( + <details key="engineering" className="decisions__engineering"> + <summary> + Engineering decisions ({group.length}) — only the build + notices these + </summary> + {list} + </details> + ) : ( + <Fragment key="product">{list}</Fragment> + ); + })} + </section> + )} + + {isCheckpoint && + question.checkpoint?.suggestedJudgements !== undefined && + question.checkpoint.suggestedJudgements.length > 0 && ( + <SuggestedJudgements + judgements={question.checkpoint.suggestedJudgements} + kept={question.checkpoint.keptJudgements ?? []} + playing={playing} + onPlay={onPlaySection} + onKeep={onKeepJudgements} + /> + )} + + {isCheckpoint && ( + <Section + sectionKey="instructions" + label="How to respond" + playing={playing} + onPlay={onPlaySection} + > + {/* Approving is the footer's button (⌘⏎) — one place, not two. */} + <p className="muted"> + Type or say a decision’s number with what you want — “2: use the + opening date”, “explain 3”, “1: give me options”. When you’re happy, + approve below (⌘⏎) — that approves{' '} + {cp === undefined ? 'it' : checkpointGoal(cp, pod).approves}. + </p> + </Section> + )} + </article> + ); +}; diff --git a/src/app/client/Rails.tsx b/src/app/client/Rails.tsx new file mode 100644 index 0000000..972fb45 --- /dev/null +++ b/src/app/client/Rails.tsx @@ -0,0 +1,549 @@ +import { + type ReactNode, + type PointerEvent as ReactPointerEvent, + useCallback, + useRef, + useState, +} from 'react'; + +import { planningRailsOf, railFor } from '../shared/screenChoice'; +import type { Player } from './usePlayer'; + +/** + * The right-hand rails, as in Claude Code on the web: the chat stays in the + * center, and one rail at a time opens beside it from the icons in the chat's + * header. Plan (the item you're reviewing, with its audio in the footer), + * Workflow (the pod's pipeline), Made here (what this Lambda made: + * prototypes and pictures) and Agents (the live terminals). A building pod + * has Workflow, Tickets (done, current, up next) and Agents. + */ + +export type RailId = + | 'plan' + | 'workflow' + | 'made' + | 'tickets' + | 'review' + | 'summary' + | 'agents'; + +export const RAILS: { id: RailId; label: string; hint: string }[] = [ + { id: 'plan', label: 'Plan', hint: 'The item up for review' }, + { + id: 'workflow', + label: 'Workflow', + hint: "This Lambda's pipeline and tickets", + }, + { + id: 'made', + label: 'Made here', + hint: "This Lambda's prototypes, design pictures and screenshots", + }, + { + id: 'tickets', + label: 'Tickets', + hint: "This Lambda's tickets: done, current and up next", + }, + { + id: 'review', + label: 'Review', + hint: 'Ready for your review: approve it or send it back', + }, + { id: 'summary', label: 'Summary', hint: 'What shipped, and what changed' }, + { id: 'agents', label: 'Agents', hint: 'The live Dev, Manager and forks' }, +]; + +/** A planning pod's rails. */ +export const PLANNING_RAILS: RailId[] = planningRailsOf('planning'); + +/** A building pod's rails. */ +export const BUILDING_RAILS: RailId[] = ['workflow', 'tickets', 'agents']; + +const RAIL_KEY = 'pods.rail'; +/** Each Lambda's own rail: pod id → the rail open on it ('' = shut). */ +const BY_POD_KEY = 'pods.railByPod'; +/** Only the Lambdas seen most recently are remembered. */ +const BY_POD_MAX = 60; +/** The rail's share of the room beside the sidebar (0–1). */ +const SHARE_KEY = 'pods.railShare'; +/** The open rail is maximized ('yes'), and was when last closed. */ +const EXPANDED_KEY = 'pods.railExpanded'; +const LAST_EXPANDED_KEY = 'pods.railLastExpanded'; +/** Before shares: the rail's width in pixels, read once to carry it over. */ +const WIDTH_KEY = 'pods.railWidth'; +const MIN_WIDTH = 320; +/** + * The chat keeps at least this much room unless the rail is expanded — small, + * as in Claude Code, so the rail can take well over half the screen. + */ +export const MIN_CHAT = 300; +/** The main area runs flush to the window's right edge. */ +const INSET = 0; +const DEFAULT_WIDTH = 520; + +const read = (key: string): string | null => { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +}; + +const write = (key: string, value: string): void => { + try { + window.localStorage.setItem(key, value); + } catch { + // Private mode: it just isn't remembered. + } +}; + +const isRail = (value: string | null): value is RailId => + RAILS.some((rail) => rail.id === value); + +const readByPod = (): Record<string, string> => { + try { + const saved: unknown = JSON.parse(read(BY_POD_KEY) ?? '{}'); + + return typeof saved === 'object' && saved !== null + ? Object.fromEntries( + Object.entries(saved).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : {}; + } catch { + return {}; + } +}; + +const writeByPod = (podId: string, next: RailId | null): void => { + const kept = Object.entries(readByPod()).filter(([id]) => id !== podId); + write( + BY_POD_KEY, + JSON.stringify( + Object.fromEntries([ + ...kept.slice(-(BY_POD_MAX - 1)), + [podId, next ?? ''], + ]) + ) + ); +}; + +/** The room the chat and the rail share: the window less the sidebar. */ +const roomBeside = (sidebar: number): number => + Math.max(1, window.innerWidth - sidebar - INSET); + +/** As wide as asked, leaving the chat its minimum beside the sidebar. */ +const clampWidth = (width: number, sidebar: number): number => + Math.round( + Math.max( + MIN_WIDTH, + Math.min(width, window.innerWidth - sidebar - INSET - MIN_CHAT) + ) + ); + +/** + * Which rail is open, how wide it is, and whether it fills the chat's room. + * `sidebar`: the sidebar's width right now (it folds to an icon strip). + * Each Lambda keeps its own open rail (`showPod`); off a Lambda, the last + * one open is used. + */ +export const useRails = (sidebar: number) => { + const [rail, setRailNow] = useState<RailId | null>(() => { + const saved = read(RAIL_KEY); + + return isRail(saved) ? saved : 'plan'; + }); + // Kept as a share of the room beside the sidebar, so folding or opening the + // sidebar keeps the chat and the rail in the same proportion. + const [share, setShare] = useState(() => { + const saved = Number(read(SHARE_KEY)); + + if (Number.isFinite(saved) && saved > 0 && saved < 1) { + return saved; + } + + const pixels = Number(read(WIDTH_KEY)); + + return ( + (Number.isFinite(pixels) && pixels > 0 ? pixels : DEFAULT_WIDTH) / + roomBeside(sidebar) + ); + }); + // Maximized stays maximized across reloads, as does what `]` brings back. + const [expanded, setExpandedNow] = useState( + () => rail !== null && read(EXPANDED_KEY) === 'yes' + ); + const setExpanded = useCallback((next: boolean) => { + setExpandedNow(next); + write(EXPANDED_KEY, next ? 'yes' : ''); + }, []); + /** The Lambda on screen, whose rail this is (null: not on a Lambda). */ + const podNow = useRef<string | null>(null); + /** How it's shown: the rails of the screen it's on (its own page or not). */ + const railsNow = useRef<readonly RailId[]>([]); + /** The rail last open, for `]` to bring back — maximized if it was. */ + const last = useRef<RailId>(rail ?? 'plan'); + const lastExpanded = useRef(read(LAST_EXPANDED_KEY) === 'yes'); + const expandedNow = useRef(expanded); + expandedNow.current = expanded; + + const setRail = useCallback( + (next: RailId | null) => { + setRailNow(next); + write(RAIL_KEY, next ?? ''); + + if (podNow.current !== null) { + writeByPod(podNow.current, next); + } + + if (next !== null) { + last.current = next; + } + + if (next === null) { + lastExpanded.current = expandedNow.current; + write(LAST_EXPANDED_KEY, expandedNow.current ? 'yes' : ''); + setExpanded(false); + } + }, + [setExpanded] + ); + /** + * A Lambda came on screen, on a screen with `available` rails: show its + * rail as they left it, or `fallback` the first time (its own default — + * shut for a chat) or when the rail left open isn't one this screen has. + * The same Lambda moving between screens (the inbox's planning screen and + * a building pod's own page) counts as coming on screen again. + */ + const showPod = useCallback( + ( + podId: string | null, + fallback: RailId | null, + available: readonly RailId[] + ) => { + if ( + podId === podNow.current && + available.join() === railsNow.current.join() + ) { + return; + } + + podNow.current = podId; + railsNow.current = available; + + if (podId === null) { + return; + } + + setRailNow(railFor(readByPod()[podId], available, fallback)); + }, + [] + ); + /** Open `next` unless another rail they chose (not Workflow) is open. */ + const openIfShut = useCallback((next: RailId) => { + setRailNow((open) => { + if (open !== null && open !== 'workflow') { + return open; + } + + write(RAIL_KEY, next); + last.current = next; + + if (podNow.current !== null) { + writeByPod(podNow.current, next); + } + + return next; + }); + }, []); + const setWidth = useCallback( + (next: number) => { + const nextShare = clampWidth(next, sidebar) / roomBeside(sidebar); + setShare(nextShare); + write(SHARE_KEY, String(nextShare)); + }, + [sidebar] + ); + + /** Close the open rail, or bring back the last one as it was. */ + const toggleRail = useCallback(() => { + if (rail !== null) { + setRail(null); + + return; + } + + setRail(last.current); + setExpanded(lastExpanded.current); + }, [rail, setRail, setExpanded]); + /** Maximize the rail or shrink it back, opening the last one if shut. */ + const toggleExpanded = useCallback(() => { + if (rail === null) { + setRail(last.current); + setExpanded(true); + + return; + } + + setExpanded(!expanded); + }, [rail, expanded, setRail, setExpanded]); + + return { + rail, + setRail, + showPod, + openIfShut, + toggleRail, + toggleExpanded, + width: clampWidth(share * roomBeside(sidebar), sidebar), + setWidth, + expanded, + setExpanded, + }; +}; + +export type Rails = ReturnType<typeof useRails>; + +const ICON_PATHS: Record<RailId, ReactNode> = { + plan: ( + <> + <path d="M8 6h12M8 12h12M8 18h12" /> + <circle cx="4" cy="6" r="1" /> + <circle cx="4" cy="12" r="1" /> + <circle cx="4" cy="18" r="1" /> + </> + ), + workflow: ( + <> + <circle cx="5" cy="6" r="2" /> + <circle cx="5" cy="18" r="2" /> + <circle cx="19" cy="12" r="2" /> + <path d="M5 8v8M7 6h4a4 4 0 0 1 4 4v0a2 2 0 0 0 2 2M7 18h4a4 4 0 0 0 4-4" /> + </> + ), + made: ( + <> + <rect x="3" y="3" width="8" height="8" rx="1.5" /> + <rect x="13" y="3" width="8" height="8" rx="1.5" /> + <rect x="3" y="13" width="8" height="8" rx="1.5" /> + <path d="M13 17h8M17 13v8" /> + </> + ), + tickets: ( + <> + <path d="M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 0 0 4v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 0 0-4z" /> + <path d="M10 5v12" strokeDasharray="2 2" /> + </> + ), + review: ( + <> + <circle cx="12" cy="12" r="9" /> + <path d="M8.5 12.2l2.4 2.4 4.8-5" /> + </> + ), + summary: ( + <> + <path d="M6 3h9l3 3v15H6z" /> + <path d="M9 11h6M9 15h6" /> + </> + ), + agents: ( + <> + <rect x="3" y="4" width="18" height="16" rx="2" /> + <path d="M7 9l3 3-3 3M12 15h5" /> + </> + ), +}; + +export const RailIcon = ({ id }: { id: RailId }) => ( + <svg + viewBox="0 0 24 24" + width="18" + height="18" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + {ICON_PATHS[id]} + </svg> +); + +/** The icons in the chat's header; a dot marks a rail with something new. */ +export const RailSwitcher = ({ + rails, + shown, + badges, + ids = PLANNING_RAILS, +}: { + rails: Rails; + /** The rail actually on screen (Plan steps aside on the New pod/Time pages). */ + shown: RailId | null; + badges: Partial<Record<RailId, boolean>>; + /** Which rails this pod has (a building pod's differ). */ + ids?: RailId[]; +}) => ( + <div className="rail-switch" role="toolbar" aria-label="Rails"> + {RAILS.filter(({ id }) => ids.includes(id)).map(({ id, label, hint }) => ( + <button + key={id} + type="button" + className={`rail-switch__button${ + shown === id ? ' rail-switch__button--active' : '' + }`} + title={`${label} — ${hint}`} + aria-label={label} + aria-pressed={shown === id} + onClick={() => (shown === id ? rails.setRail(null) : rails.setRail(id))} + > + <RailIcon id={id} /> + {badges[id] === true && shown !== id && ( + <span className="rail-switch__badge" aria-hidden="true" /> + )} + </button> + ))} + </div> +); + +/** + * Play/pause with a progress ring, always in the chat's header — so audio is + * never stranded behind a closed Player rail. + */ +export const MiniPlayer = ({ player }: { player: Player }) => { + const disabled = player.source === 'none' || player.duration <= 0; + const progress = + player.duration > 0 ? Math.min(1, player.time / player.duration) : 0; + const radius = 15; + const around = 2 * Math.PI * radius; + + return ( + <button + type="button" + className={`mini-player${player.playing ? ' mini-player--playing' : ''}`} + title="Play / pause (Space)" + aria-label={player.playing ? 'Pause' : 'Play'} + disabled={disabled} + onClick={player.toggle} + > + <svg viewBox="0 0 36 36" width="34" height="34" aria-hidden="true"> + <circle className="mini-player__track" cx="18" cy="18" r={radius} /> + <circle + className="mini-player__ring" + cx="18" + cy="18" + r={radius} + strokeDasharray={around} + strokeDashoffset={around * (1 - progress)} + /> + </svg> + <span className="mini-player__glyph">{player.playing ? '❚❚' : '▶'}</span> + </button> + ); +}; + +const headIcon = (path: ReactNode) => ( + <svg + viewBox="0 0 24 24" + width="16" + height="16" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + {path} + </svg> +); + +/** The rail head's icons (as Claude Code's panel): expand, shrink, close. */ +const HEAD_ICONS = { + expand: headIcon(<path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7" />), + shrink: headIcon(<path d="M20 10h-6V4M4 14h6v6M14 10l7-7M10 14l-7 7" />), + close: headIcon(<path d="M6 6l12 12M18 6L6 18" />), +}; + +/** The open rail: its title, expand and close, and a draggable left edge. */ +export const RailHost = ({ + rails, + title, + dark = false, + hidden = false, + actions, + children, +}: { + rails: Rails; + title: ReactNode; + /** The Agents rail is a black terminal. */ + dark?: boolean; + /** Kept mounted but out of sight (the Plan rail keeps its scroll and state). */ + hidden?: boolean; + /** Buttons in the head, before expand and close (the Plan rail's Skip). */ + actions?: ReactNode; + children: ReactNode; +}) => { + const [dragging, setDragging] = useState(false); + + const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => { + if (rails.expanded) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(true); + }; + + const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => { + if (dragging) { + rails.setWidth(window.innerWidth - event.clientX); + } + }; + + return ( + <aside + className={`rail${dark ? ' rail--dark' : ''}${ + dragging ? ' rail--resizing' : '' + }`} + hidden={hidden} + > + <div + className="rail__edge" + role="separator" + aria-orientation="vertical" + aria-label="Resize" + title="Drag to resize" + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={() => setDragging(false)} + onPointerCancel={() => setDragging(false)} + /> + <div className="rail__head"> + <strong className="rail__title">{title}</strong> + {actions} + <button + type="button" + className="rail__action" + title={rails.expanded ? 'Shrink back beside the chat' : 'Expand'} + aria-label={rails.expanded ? 'Shrink' : 'Expand'} + onClick={() => rails.setExpanded(!rails.expanded)} + > + {rails.expanded ? HEAD_ICONS.shrink : HEAD_ICONS.expand} + </button> + <button + type="button" + className="rail__action" + title="Close" + aria-label="Close" + onClick={() => rails.setRail(null)} + > + {HEAD_ICONS.close} + </button> + </div> + {children} + </aside> + ); +}; diff --git a/src/app/client/RenewalBanner.tsx b/src/app/client/RenewalBanner.tsx new file mode 100644 index 0000000..aa0f0de --- /dev/null +++ b/src/app/client/RenewalBanner.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { bannerLine, renewalState } from '../../setup/tokenExpiry'; + +export const HOUR_MS = 60 * 60 * 1000; + +/** Calls `onTick` with the time every hour; returns what stops it. */ +export const everyHour = (onTick: (now: number) => void): (() => void) => { + const timer = setInterval(() => onTick(Date.now()), HOUR_MS); + + return () => clearInterval(timer); +}; + +/** + * From 21 days before the Claude sign-in runs out, every Lambda page says so + * (in a plain browser too, not only the Mac app), with a way to reconnect. + */ +export const RenewalBanner = ({ + expiresOn, + day, + zone, +}: { + expiresOn: string | null; + /** Now, as an ISO time (tests pin it). */ + day?: string; + /** The reader's time zone (tests pin it); default: the browser's. */ + zone?: string; +}) => { + // A window left open for days (the Mac app's) still crosses the 21 days. + const [clock, setClock] = useState(Date.now); + useEffect(() => everyHour(setClock), []); + const now = day === undefined ? clock : Date.parse(day); + const line = expiresOn === null ? null : bannerLine(expiresOn, now, zone); + // Once it has run out, agents already have stopped: the date says it all. + const expired = + expiresOn !== null && renewalState(expiresOn, now) === 'expired'; + + if (line === null) { + return null; + } + + return ( + <div className="renewal-banner" role="status"> + <span aria-hidden="true">⚠</span> + <span>{expired ? line : `${line} Agents stop working after that.`}</span> + <button + type="button" + onClick={() => window.location.assign('/setup?step=claude')} + > + Reconnect Claude + </button> + </div> + ); +}; diff --git a/src/app/client/Root.tsx b/src/app/client/Root.tsx new file mode 100644 index 0000000..be281f3 --- /dev/null +++ b/src/app/client/Root.tsx @@ -0,0 +1,152 @@ +import { lazy, useCallback, useEffect, useState } from 'react'; + +import type { SetupState } from '../../setup/routes'; +import { Lazy } from './ChunkBoundary'; +import { RenewalBanner } from './RenewalBanner'; +import { SignIn } from './SignIn'; +import { SIGNED_OUT_EVENT, api, setupApi } from './api'; +import { type Status, sessionStatus } from './sessionStatus'; +import { setupDone } from './setupSteps'; +import { setUiLogSignedIn } from './uiLog'; + +/** + * The page's first screen. Without a session nothing else loads: it's the + * sign-in screen. With one, the app (its own chunk) takes over. + */ +const App = lazy(() => + import('./App').then((module) => ({ default: module.App })) +); + +const Setup = lazy(() => + import('./Setup').then((module) => ({ default: module.Setup })) +); + +/** First run's state; `failed`: unreadable, so the app opens as it would. */ +type SetupStatus = + | { kind: 'checking' } + | { kind: 'read'; state: SetupState } + | { kind: 'failed' }; + +const onSetupPage = (): boolean => + window.location.pathname.startsWith('/setup'); + +export const Root = () => { + const [status, setStatus] = useState<Status>({ kind: 'checking' }); + + const check = useCallback( + () => + api + .session() + .then((session) => setStatus(sessionStatus({ session }))) + .catch((error: unknown) => setStatus(sessionStatus({ error }))), + [] + ); + + useEffect(() => { + check(); + // A request refused for want of a session (signed out on another device, + // or 30 days unused): back to the sign-in screen. + const onSignedOut = () => setStatus({ kind: 'out' }); + window.addEventListener(SIGNED_OUT_EVENT, onSignedOut); + + return () => window.removeEventListener(SIGNED_OUT_EVENT, onSignedOut); + }, [check]); + + useEffect(() => setUiLogSignedIn(status.kind === 'in'), [status.kind]); + + const [setup, setSetup] = useState<SetupStatus>({ kind: 'checking' }); + + useEffect(() => { + if (status.kind !== 'in') { + return; + } + + setupApi + .state() + .then((state) => setSetup({ kind: 'read', state })) + .catch(() => setSetup({ kind: 'failed' })); + }, [status.kind]); + + if (status.kind === 'checking') { + return null; + } + + if (status.kind === 'out') { + return <SignIn onSignedIn={() => window.location.reload()} />; + } + + if (status.kind === 'unreachable') { + return ( + <main className="sign-in"> + <div className="sign-in__card"> + <p className="sign-in__brand">Lambda</p> + <p className="sign-in__title">Can't reach Lambda — try again.</p> + <button + type="button" + onClick={() => { + setStatus({ kind: 'checking' }); + void check(); + }} + > + Try again + </button> + </div> + </main> + ); + } + + if (setup.kind === 'checking') { + return null; + } + + return ( + <SignedIn + state={setup.kind === 'read' ? setup.state : null} + setupPage={onSetupPage()} + /> + ); +}; + +/** + * Signed in: setup while it's unfinished (or on /setup, Run setup again…), + * else the app. The renewal banner sits above either: every page shows it. + */ +export const SignedIn = ({ + state, + setupPage, + day, + zone, +}: { + /** First run's state; null when it couldn't be read. */ + state: SetupState | null; + setupPage: boolean; + /** Today, for the banner (tests pin it). */ + day?: string; + /** The banner's time zone (tests pin it). */ + zone?: string; +}) => { + const showSetup = setupPage || (state !== null && !setupDone(state)); + + return ( + <div className="with-banner"> + <RenewalBanner + expiresOn={state?.claudeExpiresOn ?? null} + day={day} + zone={zone} + /> + <div className="with-banner__app"> + <Lazy fallback={null}> + {showSetup ? ( + <Setup + onFinished={() => { + window.location.assign('/'); + }} + /> + ) : ( + <App /> + )} + </Lazy> + </div> + </div> + ); +}; diff --git a/src/app/client/SettingsPage.tsx b/src/app/client/SettingsPage.tsx new file mode 100644 index 0000000..d2afbcc --- /dev/null +++ b/src/app/client/SettingsPage.tsx @@ -0,0 +1,505 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + DEFAULT_LOOK, + type DesignSystemPage, + type ForProject, + MAKE_IT_EXPLAINED, + type MadeDesignSystem, + NEEDS_CLAUDE_DESIGN, + type PageLine, + createdLine, + designSystemControls, + forProject, + openTicketLine, + rereadsDesignSystem, + showsMakeIt, + systemLines, +} from '../shared/designSystem'; +import type { LambdaInfo } from '../shared/types'; +import { ProjectPicker } from './ProjectPicker'; +import { type Code, type Device, type ProcessSettings, api } from './api'; +import { errorText, latestLoader } from './loading'; +import type { SettingsTab } from './page'; +import { processControls, rereadsOnTick } from './processControls'; +import { chosenProjectId } from './projects'; + +const ago = (at: number): string => { + const minutes = Math.floor((Date.now() - at) / 60_000); + + if (minutes < 2) { + return 'active now'; + } + + if (minutes < 60) { + return `${minutes} min ago`; + } + + const hours = Math.floor(minutes / 60); + + return hours < 24 ? `${hours} h ago` : `${Math.floor(hours / 24)} days ago`; +}; + +/** Settings → Devices: who's signed in, Sign out on one, Add a device. */ +const Devices = ({ tick }: { tick: number }) => { + const [devices, setDevices] = useState<Device[] | null>(null); + const [pairing, setPairing] = useState<Code | null>(null); + const [error, setError] = useState<string | null>(null); + const [now, setNow] = useState(Date.now()); + + // A good answer clears an old error (latestLoader). + const loadDevices = useMemo( + () => latestLoader<Device[]>({ value: setDevices, error: setError }), + [] + ); + const load = () => loadDevices(api.devices().then((data) => data.devices)); + + useEffect(() => { + void load(); + }, [tick]); + + // The code's countdown. + useEffect(() => { + if (pairing === null) { + return undefined; + } + + const id = window.setInterval(() => setNow(Date.now()), 1000); + + return () => window.clearInterval(id); + }, [pairing]); + + const left = pairing === null ? 0 : Math.max(0, pairing.expiresAt - now); + + return ( + <> + {error !== null && <p className="error">{error}</p>} + <ul className="account-list"> + {(devices ?? []).map((device) => ( + <li key={device.id} className="account-list__row"> + <strong> + {device.label} + {device.current ? ' (this device)' : ''} + </strong> + <span className="muted"> {ago(device.lastSeenAt)}</span> + {!device.current && ( + <button + type="button" + className="ghost" + onClick={() => + api + .signOutDevice(device.id) + .then(load) + .catch((caught: unknown) => setError(errorText(caught))) + } + > + Sign out + </button> + )} + </li> + ))} + </ul> + <div className="account-page__actions"> + <button + type="button" + className="primary" + onClick={() => + api + .deviceCode() + .then((code) => { + setNow(Date.now()); + setPairing(code); + }) + .catch((caught: unknown) => setError(errorText(caught))) + } + > + Add a device + </button> + </div> + {pairing !== null && ( + <div className="dialog" role="dialog" aria-label="Add a device"> + <h2>Add a device</h2> + <p>Enter this code on the other device’s sign-in screen:</p> + <p className="dialog__big-code">{pairing.code}</p> + <p className="muted"> + {left === 0 + ? 'Expired. Make a new one with Add a device.' + : `Works once · expires in ${Math.floor(left / 60_000)}:${String( + Math.floor((left % 60_000) / 1000) + ).padStart(2, '0')}`} + </p> + <button + type="button" + className="ghost" + onClick={() => setPairing(null)} + > + Done + </button> + </div> + )} + </> + ); +}; + +/** Settings → Process: switches for the optional steps; quality steps locked. */ +const Process = ({ + lambda, + tick, +}: { + lambda: LambdaInfo | undefined; + tick: number; +}) => { + const [picked, setPicked] = useState<string | null>(null); + const projectId = chosenProjectId(picked, lambda); + const [settings, setSettings] = useState<ProcessSettings | null>(null); + const [error, setError] = useState<string | null>(null); + const [busy, setBusy] = useState(false); + const controls = useMemo( + () => + processControls<ProcessSettings>( + { read: api.process, flip: api.switchStep }, + { + value: setSettings, + error: (text) => { + setError(text); + + if (text !== null) { + setSettings(null); + } + }, + busy: setBusy, + } + ), + [] + ); + + // Read when the page or project changes, and after each switch: each + // read is a trip to the computer, so not on every heartbeat. + useEffect(() => { + if (projectId === null) { + return; + } + + void controls.read(projectId); + }, [controls, projectId]); + + // After an error, each heartbeat tries again: the computer coming back + // online brings the switches back without a click. + useEffect(() => { + if (projectId !== null && rereadsOnTick({ error, busy })) { + void controls.read(projectId); + } + }, [tick]); + + if (projectId === null) { + return <p className="muted">Add a codebase first.</p>; + } + + const flip = (process: 'planning' | 'building', step: string, on: boolean) => + controls.flip(projectId, process, step, on); + + const section = (process: 'planning' | 'building', title: string) => ( + <div className="process"> + <h3>{title}</h3> + {(settings?.[process] ?? []).flatMap((stage) => + stage.steps.map((step) => ( + <div key={`${stage.id}/${step.id}`} className="process__row"> + <span> + {step.label} + {step.added ? ' (added)' : ''} + </span> + {step.setting === 'locked' ? ( + <span className="muted">locked</span> + ) : ( + <label className="switch"> + <input + type="checkbox" + role="switch" + checked={step.setting === 'on'} + disabled={busy} + onChange={(event) => + flip(process, step.id, event.target.checked) + } + /> + <span className="switch__track" aria-hidden="true"> + <span className="switch__thumb" /> + </span> + <span>{step.setting === 'on' ? 'On' : 'Off'}</span> + </label> + )} + </div> + )) + )} + </div> + ); + + return ( + <> + <ProjectPicker lambda={lambda} value={projectId} onChange={setPicked} /> + {error !== null && ( + <> + <p className="error">{error}</p> + <button + type="button" + className="ghost" + disabled={busy} + onClick={() => void controls.read(projectId)} + > + Try again + </button> + </> + )} + {settings !== null && ( + <> + {section('planning', 'Planning')} + {section('building', 'Building')} + <p className="muted">Anything more is set in .lambda/ files.</p> + </> + )} + </> + ); +}; + +const TicketLink = ({ url }: { url: string | null }) => + url === null ? null : ( + <> + {' '} + <a href={url} target="_blank" rel="noreferrer"> + Open ticket ↗ + </a> + </> + ); + +const Line = ({ line }: { line: PageLine }) => ( + <p className={line.issue === true ? 'design-system__issue' : undefined}> + {line.text} + {line.link !== undefined && + (line.link.url === null ? ( + line.link.label + ) : ( + <a href={line.link.url} target="_blank" rel="noreferrer"> + {`${line.link.label} ↗`} + </a> + ))} + </p> +); + +/** + * What Settings → Design system shows for a page state: the design system on + * main (view-only), the setup ticket, or Make it. Make it's "Created …" shows + * even with no page, when the read after it failed. + */ +export const DesignSystemView = ({ + page, + made, + busy, + onMake, +}: { + /** Null: not read, or the read failed. */ + page: DesignSystemPage | null; + /** Make it's answer this visit, when it created the ticket. */ + made: MadeDesignSystem | null; + busy: boolean; + onMake: () => void; +}) => { + if (page !== null && page.system !== null) { + return ( + <div className="design-system"> + {systemLines(page).map((line, index) => ( + <Line key={`${index}:${line.text}`} line={line} /> + ))} + </div> + ); + } + + if (made !== null && !made.existing) { + return ( + <div className="design-system"> + <p> + {createdLine(made)} + <TicketLink url={made.url} /> + </p> + </div> + ); + } + + if (page === null) { + return null; + } + + if (page.setupTicket !== null) { + return ( + <div className="design-system"> + <p> + {openTicketLine(page.setupTicket)} + <TicketLink url={page.setupTicket.url} /> + </p> + </div> + ); + } + + return ( + <div className="design-system"> + <p>No design system yet. {DEFAULT_LOOK}</p> + {showsMakeIt(page) && ( + <div className="design-system__make"> + <button + type="button" + className="primary" + disabled={busy || page.designAvailable === false} + onClick={onMake} + > + Make it + </button> + {page.designAvailable === false && ( + <span className="muted">{NEEDS_CLAUDE_DESIGN}</span> + )} + </div> + )} + <p className="muted">{MAKE_IT_EXPLAINED}</p> + </div> + ); +}; + +/** Settings → Design system: one page per project, read from its main branch. */ +const DesignSystem = ({ + lambda, + tick, +}: { + lambda: LambdaInfo | undefined; + tick: number; +}) => { + const [picked, setPicked] = useState<string | null>(null); + const projectId = chosenProjectId(picked, lambda); + const [page, setPage] = useState<DesignSystemPage | null>(null); + // Make it's answer and failure, each kept with the project it was pressed + // on: one that lands after a project change never shows on the other page. + const [madeFor, setMade] = useState<ForProject<MadeDesignSystem> | null>( + null + ); + const [error, setError] = useState<string | null>(null); + // Make it's failure: no read clears it, only the next press or project. + const [makeErrorFor, setMakeError] = useState<ForProject<string> | null>( + null + ); + const [busy, setBusy] = useState(false); + const [reading, setReading] = useState(false); + const shown = useRef(projectId); + shown.current = projectId; + const controls = useMemo( + () => + designSystemControls({ + read: api.designSystem, + make: api.makeDesignSystem, + load: latestLoader<DesignSystemPage>({ + value: setPage, + error: (text) => { + setError(text); + + if (text !== null) { + setPage(null); + } + }, + }), + errorText, + shown: () => shown.current, + target: { + page: setPage, + error: setError, + made: setMade, + makeError: setMakeError, + busy: setBusy, + reading: setReading, + }, + }), + [] + ); + + // Each read is a trip to the computer: on opening and on a project change. + useEffect(() => { + if (projectId === null) { + return; + } + + void controls.open(projectId); + }, [controls, projectId]); + + // After an error (the computer was offline), each heartbeat tries again. + useEffect(() => { + if ( + projectId !== null && + rereadsDesignSystem({ readError: error, busy: busy || reading }) + ) { + void controls.read(projectId); + } + }, [tick]); + + if (projectId === null) { + return <p className="muted">Add a codebase first.</p>; + } + + const made = forProject(madeFor, projectId); + const makeError = forProject(makeErrorFor, projectId); + + return ( + <> + <ProjectPicker lambda={lambda} value={projectId} onChange={setPicked} /> + {error !== null && ( + <> + <p className="error">{error}</p> + <button + type="button" + className="ghost" + disabled={busy || reading} + onClick={() => void controls.read(projectId)} + > + Try again + </button> + </> + )} + {makeError !== null && <p className="error">{makeError}</p>} + <DesignSystemView + page={page} + made={made} + busy={busy} + onMake={() => void controls.make(projectId)} + /> + </> + ); +}; + +const TABS: [SettingsTab, string][] = [ + ['devices', 'Devices'], + ['process', 'Process'], + ['design-system', 'Design system'], +]; + +export const SettingsPage = ({ + tab, + onTab, + lambda, + tick, +}: { + tab: SettingsTab; + onTab: (tab: SettingsTab) => void; + lambda: LambdaInfo | undefined; + tick: number; +}) => ( + <section className="account-page"> + <div className="tabs"> + {TABS.map(([id, label]) => ( + <button + key={id} + type="button" + className={`tab${tab === id ? ' tab--active' : ''}`} + onClick={() => onTab(id)} + > + {label} + </button> + ))} + </div> + {tab === 'devices' && <Devices tick={tick} />} + {tab === 'process' && <Process lambda={lambda} tick={tick} />} + {tab === 'design-system' && <DesignSystem lambda={lambda} tick={tick} />} + </section> +); diff --git a/src/app/client/Setup.tsx b/src/app/client/Setup.tsx new file mode 100644 index 0000000..0c483fc --- /dev/null +++ b/src/app/client/Setup.tsx @@ -0,0 +1,783 @@ +import { type FormEvent, useCallback, useEffect, useState } from 'react'; + +import type { FolderListing } from '../../setup/folders'; +import type { SetupState } from '../../setup/routes'; +import { renewsLine } from '../../setup/tokenExpiry'; +import { type AskWho, setupApi } from './api'; +import { STEPS, type Step, firstUnfinished, stepAfter } from './setupSteps'; + +/** + * First run, at `/setup`: Connect Claude, pick or make a project folder, + * choose where tickets live. The same pages in the Mac app's window and in + * any browser; the work runs on the mothership, and where setup stopped + * lives there too, so it resumes at the first unfinished step anywhere. + */ + +/** The Mac app's preload bridge: the native folder picker. */ +declare global { + interface Window { + lambda?: { pickFolder: () => Promise<string | null> }; + } +} + +const errorText = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const isRunning = (state: SetupState): boolean => { + const { phase } = state.steps.claude.run; + + return ( + phase === 'installing' || + phase === 'signing-in' || + state.steps.browser.phase === 'installing' + ); +}; + +const Bar = ({ percent }: { percent: number | null }) => ( + <div + className={`setup__bar${percent === null ? ' setup__bar--moving' : ''}`} + role="progressbar" + aria-valuenow={percent ?? undefined} + aria-valuemin={0} + aria-valuemax={100} + > + <div + className="setup__bar-fill" + style={percent === null ? undefined : { width: `${percent}%` }} + /> + </div> +); + +const Lines = ({ lines }: { lines: string[] }) => + lines.length === 0 ? null : ( + <pre className="setup__lines">{lines.join('\n')}</pre> + ); + +const StepIndicator = ({ + state, + current, + onPick, +}: { + state: SetupState; + current: Step; + onPick: (step: Step) => void; +}) => ( + <ol className="setup__steps"> + {STEPS.map(({ step, label }) => { + const { done } = state.steps[step]; + const mark = step === current ? '●' : done ? '✓' : '○'; + + return ( + <li key={step}> + <button + type="button" + className={`setup__step${ + step === current ? ' setup__step--current' : '' + }`} + disabled={!done && step !== current} + onClick={() => onPick(step)} + > + <span aria-hidden="true">{mark}</span> {label} + </button> + </li> + ); + })} + </ol> +); + +const BrowserProgress = ({ + state, + onRetry, +}: { + state: SetupState; + onRetry: () => void; +}) => { + const { browser } = state.steps; + + if (browser.phase === 'installing') { + return ( + <div className="setup__browser"> + <p className="muted">Getting the browser agents test with…</p> + <Bar percent={browser.percent} /> + </div> + ); + } + + if (browser.phase === 'failed') { + return ( + <div className="setup__browser"> + <p className="setup__error"> + The browser agents test with didn't download. + </p> + <Lines lines={browser.lines} /> + <button type="button" onClick={onRetry}> + Retry + </button> + </div> + ); + } + + return null; +}; + +export const ClaudeStep = ({ + state, + onContinue, + refresh, +}: { + state: SetupState; + onContinue: () => void; + refresh: () => void; +}) => { + const [code, setCode] = useState(''); + const [error, setError] = useState<string | null>(null); + const { run, done } = state.steps.claude; + const connect = () => { + setError(null); + setupApi + .connectClaude() + .then(refresh) + .catch((caught: unknown) => setError(errorText(caught))); + }; + const sendCode = (event: FormEvent) => { + event.preventDefault(); + setupApi + .sendCode(code) + .then(() => { + setCode(''); + refresh(); + }) + .catch((caught: unknown) => setError(errorText(caught))); + }; + + if (run.phase === 'installing') { + return ( + <section className="setup__body"> + <p>Lambda's agents run on Claude Code, with your own Claude account.</p> + <p>Installing Claude Code from Anthropic…</p> + <div className="setup__progress"> + <Bar percent={run.percent} /> + {run.percent !== null && <span>{run.percent}%</span>} + </div> + {run.percent === null && run.line !== '' && ( + <p className="muted setup__line">{run.line}</p> + )} + <p className="muted"> + Next, your browser opens so you can sign in to Claude. + </p> + </section> + ); + } + + if (run.phase === 'signing-in') { + return ( + <section className="setup__body"> + <p>Sign in to Claude in your browser.</p> + {run.url !== null && ( + <p> + <button + type="button" + className="primary" + onClick={() => { + // A browser on another device opens it there; the Mac app + // sends https links to the default browser. + window.open(run.url ?? '', '_blank', 'noreferrer'); + }} + > + Open the Claude sign-in + </button> + </p> + )} + {run.needsCode && ( + <form className="setup__row" onSubmit={sendCode}> + <label htmlFor="setup-code">Paste the code Claude shows you</label> + <input + id="setup-code" + autoFocus + spellCheck={false} + value={code} + onChange={(event) => setCode(event.target.value)} + /> + <button + type="submit" + className="primary" + disabled={code.trim() === ''} + > + Send + </button> + </form> + )} + {error !== null && <p className="setup__error">{error}</p>} + </section> + ); + } + + if (done && state.claudeExpiresOn !== null) { + return ( + <section className="setup__body"> + <p>✓ Claude connected · {renewsLine(state.claudeExpiresOn)}</p> + {/* A Reconnect that failed keeps the old sign-in: say why. */} + {run.phase === 'failed' && ( + <> + <p className="setup__error">{run.message}</p> + <Lines lines={run.lines} /> + </> + )} + <div className="setup__actions"> + <button type="button" onClick={connect}> + Reconnect Claude + </button> + <button type="button" className="primary" onClick={onContinue}> + Continue + </button> + </div> + {error !== null && <p className="setup__error">{error}</p>} + </section> + ); + } + + return ( + <section className="setup__body"> + <p>Lambda's agents run on Claude Code, with your own Claude account.</p> + {run.phase === 'failed' && ( + <> + <p className="setup__error">{run.message}</p> + <Lines lines={run.lines} /> + </> + )} + <div className="setup__actions"> + <button type="button" className="primary" onClick={connect}> + {run.phase === 'failed' + ? run.step === 'install' + ? 'Retry' + : 'Reconnect Claude' + : 'Connect Claude'} + </button> + </div> + {error !== null && <p className="setup__error">{error}</p>} + </section> + ); +}; + +/** The folder list a plain browser gets in place of the macOS picker. */ +const FolderBrowser = ({ + onUse, + onCancel, +}: { + onUse: (folder: string) => void; + onCancel: () => void; +}) => { + const [listing, setListing] = useState<FolderListing | null>(null); + const [typed, setTyped] = useState(''); + const [error, setError] = useState<string | null>(null); + const open = useCallback((folder: string) => { + setError(null); + setupApi + .folders(folder) + .then((found) => { + setListing(found); + setTyped(found.path); + }) + .catch((caught: unknown) => setError(errorText(caught))); + }, []); + + useEffect(() => open(''), [open]); + + return ( + <div className="setup__folders"> + <form + className="setup__row" + onSubmit={(event) => { + event.preventDefault(); + open(typed); + }} + > + <input + aria-label="Folder" + spellCheck={false} + value={typed} + onChange={(event) => setTyped(event.target.value)} + /> + <button type="submit">Go</button> + </form> + {error !== null && <p className="setup__error">{error}</p>} + {listing !== null && ( + <ul className="setup__folder-list"> + {listing.parent !== null && ( + <li> + <button type="button" onClick={() => open(listing.parent ?? '')}> + ↑ Up + </button> + </li> + )} + {listing.folders.map((name) => ( + <li key={name}> + <button + type="button" + onClick={() => open(`${listing.path}/${name}`)} + > + {name} + </button> + </li> + ))} + {listing.folders.length === 0 && ( + <li className="muted">No folders here.</li> + )} + </ul> + )} + <div className="setup__actions"> + <button type="button" onClick={onCancel}> + Cancel + </button> + <button + type="button" + className="primary" + // Never the whole home folder (the list starts there). + disabled={listing === null || listing.parent === null} + onClick={() => listing !== null && onUse(listing.path)} + > + Use this folder + </button> + </div> + </div> + ); +}; + +const ProjectStep = ({ onDone }: { onDone: () => void }) => { + const [mode, setMode] = useState<'choose' | 'browse' | 'new'>('choose'); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + const use = (body: { folder: string } | { name: string }) => { + setBusy(true); + setError(null); + setupApi + .project(body) + .then(onDone) + .catch((caught: unknown) => setError(errorText(caught))) + .finally(() => setBusy(false)); + }; + + const chooseFolder = () => { + const bridge = window.lambda; + + if (bridge === undefined) { + setMode('browse'); + + return; + } + + bridge + .pickFolder() + .then((folder) => { + if (folder !== null) { + use({ folder }); + } + }) + .catch((caught: unknown) => setError(errorText(caught))); + }; + + return ( + <section className="setup__body"> + <p className="setup__question">What are you working on?</p> + {mode === 'browse' ? ( + <FolderBrowser + onUse={(folder) => use({ folder })} + onCancel={() => setMode('choose')} + /> + ) : ( + <div className="setup__actions setup__actions--start"> + <button type="button" disabled={busy} onClick={chooseFolder}> + Choose a folder… + </button> + <button type="button" disabled={busy} onClick={() => setMode('new')}> + Start something new + </button> + </div> + )} + {mode === 'new' && ( + <form + className="setup__row" + onSubmit={(event) => { + event.preventDefault(); + use({ name }); + }} + > + <label htmlFor="setup-name">Name</label> + <input + id="setup-name" + autoFocus + spellCheck={false} + placeholder="pawnee-parks-app" + value={name} + onChange={(event) => setName(event.target.value)} + /> + <span className="muted"> + → ~/Lambda/{name.trim() === '' ? '…' : name.trim()} + </span> + <button + type="submit" + className="primary" + disabled={busy || name.trim() === ''} + > + Create + </button> + </form> + )} + {error !== null && <p className="setup__error">{error}</p>} + </section> + ); +}; + +const KEY_LINKS = { + linear: { + href: 'https://linear.app/settings/account/security', + text: 'Make one in Linear → Settings → API ↗', + }, + jira: { + href: 'https://id.atlassian.com/manage-profile/security/api-tokens', + text: 'Make one in Atlassian → Security → API tokens ↗', + }, +}; + +/** + * After the Tickets step saves: a checked key stays on the step, showing the + * "Connected as …" line it came back with, until Continue; markdown (nothing + * to check) carries straight on. + */ +export const afterTicketsSaved = ( + connectedAs: string | null +): 'stay' | 'continue' => (connectedAs === null ? 'continue' : 'stay'); + +/** + * After a key connects whose login isn't yours yet. A computer builds only + * tickets you reported, so: is this login you? Yes counts it as yours; No + * asks for the one that is (a build computer's own login is a No). + */ +export const WhoAreYou = ({ + ask, + tracker, +}: { + ask: AskWho; + tracker: 'Linear' | 'Jira'; +}) => { + const [answer, setAnswer] = useState<'ask' | 'other'>('ask'); + const [yours, setYours] = useState(ask.yourEmail ?? ''); + const [counted, setCounted] = useState<string | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + const claim = (login: string) => { + setBusy(true); + setError(null); + setupApi + .you(login) + .then((result) => setCounted(result.counts)) + .catch((caught: unknown) => setError(errorText(caught))) + .finally(() => setBusy(false)); + }; + + if (counted !== null) { + return <p>✓ Tickets reported by {counted} are yours</p>; + } + + const { name, email } = ask.login; + + return ( + <div className="setup__who"> + <p> + Lambda builds only the tickets you report. Is {name} + {email === null ? '' : ` (${email})`} you? + </p> + {answer === 'ask' ? ( + <div className="setup__actions setup__actions--start"> + <button + type="button" + disabled={busy} + onClick={() => claim(email ?? name)} + > + Yes, that's me + </button> + <button + type="button" + disabled={busy} + onClick={() => setAnswer('other')} + > + No, it's this computer's own login + </button> + </div> + ) : ( + <> + <label className="setup__field"> + Your email in {tracker} + <input + type="email" + spellCheck={false} + value={yours} + onChange={(event) => setYours(event.target.value)} + /> + </label> + <div className="setup__actions setup__actions--start"> + <button + type="button" + disabled={busy || yours.trim() === ''} + onClick={() => claim(yours)} + > + Save + </button> + </div> + </> + )} + {error !== null && <p className="setup__error">{error}</p>} + </div> + ); +}; + +export const TicketsStep = ({ + state, + onDone, + connected = null, +}: { + state: SetupState; + onDone: () => void; + /** Already connected as (tests start here). */ + connected?: string | null; +}) => { + const [source, setSource] = useState<'markdown' | 'linear' | 'jira'>( + state.ticketSource ?? 'markdown' + ); + const [apiKey, setApiKey] = useState(''); + const [site, setSite] = useState(''); + const [email, setEmail] = useState(''); + const [connectedAs, setConnectedAs] = useState<string | null>(connected); + const [askWho, setAskWho] = useState<AskWho | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + const submit = (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + const body = + source === 'markdown' + ? { source } + : source === 'linear' + ? { source, apiKey } + : { source, site, email, apiToken: apiKey }; + setupApi + .tickets(body) + .then((result) => { + setConnectedAs(result.connectedAs); + setAskWho(result.askWho); + + if (afterTicketsSaved(result.connectedAs) === 'continue') { + onDone(); + } + }) + .catch((caught: unknown) => setError(errorText(caught))) + .finally(() => setBusy(false)); + }; + + const options: { value: typeof source; label: string }[] = [ + { value: 'markdown', label: 'Right here in Lambda' }, + { value: 'linear', label: 'Linear' }, + { value: 'jira', label: 'Jira' }, + ]; + + return ( + <form className="setup__body" onSubmit={submit}> + <p className="setup__question">Where should tickets live?</p> + <fieldset className="setup__choices" aria-label="Where tickets live"> + {options.map((option) => ( + <label key={option.value}> + <input + type="radio" + name="ticket-source" + checked={source === option.value} + onChange={() => { + setSource(option.value); + setError(null); + setConnectedAs(null); + setAskWho(null); + }} + />{' '} + {option.label} + </label> + ))} + </fieldset> + {source === 'jira' && ( + <> + <label className="setup__field"> + Jira site + <input + spellCheck={false} + placeholder="https://pawnee.atlassian.net" + value={site} + onChange={(event) => setSite(event.target.value)} + /> + </label> + <label className="setup__field"> + Email + <input + type="email" + spellCheck={false} + value={email} + onChange={(event) => setEmail(event.target.value)} + /> + </label> + </> + )} + {source !== 'markdown' && ( + <> + <label className="setup__field"> + {source === 'linear' ? 'Linear API key' : 'Jira API token'} + <input + type="password" + autoComplete="off" + spellCheck={false} + value={apiKey} + onChange={(event) => setApiKey(event.target.value)} + /> + </label> + <a href={KEY_LINKS[source].href} target="_blank" rel="noreferrer"> + {KEY_LINKS[source].text} + </a> + </> + )} + {error !== null && <p className="setup__error">{error}</p>} + {connectedAs !== null && <p>✓ Connected as {connectedAs}</p>} + {askWho !== null && source !== 'markdown' && ( + <WhoAreYou + ask={askWho} + tracker={source === 'linear' ? 'Linear' : 'Jira'} + /> + )} + <div className="setup__actions"> + {connectedAs !== null ? ( + <button type="button" className="primary" onClick={onDone}> + Continue + </button> + ) : ( + <button + type="submit" + className="primary" + disabled={ + busy || + (source !== 'markdown' && apiKey.trim() === '') || + (source === 'jira' && (site.trim() === '' || email.trim() === '')) + } + > + Continue + </button> + )} + </div> + </form> + ); +}; + +/** `/setup?step=claude`: the renewal banner's Reconnect Claude. */ +const askedStep = (): Step | null => { + const asked = new URLSearchParams(window.location.search).get('step'); + + return STEPS.find(({ step }) => step === asked)?.step ?? null; +}; + +export const Setup = ({ onFinished }: { onFinished: () => void }) => { + const [state, setState] = useState<SetupState | null>(null); + const [step, setStep] = useState<Step | null>(askedStep); + const [error, setError] = useState<string | null>(null); + + const load = useCallback( + (options: { retryBrowser?: boolean } = {}) => + setupApi + .state(options) + .then((next): SetupState | null => { + setState(next); + setError(null); + + return next; + }) + .catch((caught: unknown) => { + setError(errorText(caught)); + + return null; + }), + [] + ); + + useEffect(() => { + void load(); + // The mothership says when anything changed (a step's progress included). + const events = new EventSource('/api/events'); + events.onmessage = () => void load(); + + return () => events.close(); + }, [load]); + + // Belt and braces while something runs: the bar keeps moving. + useEffect(() => { + if (state === null || !isRunning(state)) { + return undefined; + } + + const timer = setInterval(() => void load(), 1000); + + return () => clearInterval(timer); + }, [state, load]); + + if (state === null) { + return ( + <main className="setup"> + <div className="setup__card"> + <p className="setup__brand">Lambda · Set up</p> + {error !== null && <p className="setup__error">{error}</p>} + </div> + </main> + ); + } + + const current = step ?? firstUnfinished(state) ?? 'claude'; + const next = (after: Step) => { + void load().then((fresh) => { + // Couldn't reload: the error shows, and the step stays put. + if (fresh === null) { + return; + } + + const following = stepAfter(after, fresh); + setStep(following); + + if (following === null) { + onFinished(); + } + }); + }; + + return ( + <main className="setup"> + <div className="setup__card"> + <p className="setup__brand">Lambda · Set up</p> + <StepIndicator state={state} current={current} onPick={setStep} /> + <BrowserProgress + state={state} + onRetry={() => void load({ retryBrowser: true })} + /> + {current === 'claude' && ( + <ClaudeStep + state={state} + onContinue={() => next('claude')} + refresh={() => void load()} + /> + )} + {current === 'project' && ( + <ProjectStep onDone={() => next('project')} /> + )} + {current === 'tickets' && ( + <TicketsStep state={state} onDone={() => next('tickets')} /> + )} + {error !== null && <p className="setup__error">{error}</p>} + </div> + </main> + ); +}; diff --git a/src/app/client/Sidebar.tsx b/src/app/client/Sidebar.tsx new file mode 100644 index 0000000..e626355 --- /dev/null +++ b/src/app/client/Sidebar.tsx @@ -0,0 +1,1629 @@ +import { + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +import { agoShort } from '../shared/building'; +import { + type Lambda, + type LambdaTicket, + type SidebarSections, +} from '../shared/lambdas'; +import { phaseName } from '../shared/phases'; +import { podName } from '../shared/podName'; +import { orderPods } from '../shared/podOrder'; +import type { AccountPage } from '../shared/route'; +import type { FavoriteKind } from '../shared/savedViews'; +import { sideRowNeedsYou } from '../shared/screenChoice'; +import { + type InboxEntry, + type LambdaInfo, + type PodStatus, + type PodSummary, + isWorking, +} from '../shared/types'; +import { waitingFailureNote } from '../shared/waitingNote'; +import { ComputersLine } from './ComputersLine'; +import type { Page } from './page'; +import { groupByProject } from './projects'; + +/** + * The left sidebar, laid out like Claude Code's: the brand and a collapse + * button, search, the nav rows (New, Tickets, Time), then the lists in + * sections — Building, Conversations (each open "Talk about this ticket"), + * Pods (in priority order, drag to reorder) and Done. A section is + * generic, so Runners can join as one more later. At the bottom: Auto mode + * (‹ › through the queue, and its switch), then the account row (local for + * now; auth comes later). + */ + +/** One favorite as the sidebar draws it: App resolves its name and icon. */ +export interface FavoriteRow { + id: string; + kind: FavoriteKind; + ref: string; + label: string; + /** A tracker icon's emoji; null draws the kind's own glyph. */ + emoji: string | null; + color: string | null; + /** It's what's on screen. */ + active: boolean; +} + +/** A favorite with no emoji: Linear's cube for a project, layers for a view. */ +const FavoriteGlyph = ({ + kind, + color, +}: { + kind: FavoriteKind; + color: string | null; +}) => ( + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke={color ?? 'currentColor'} + strokeWidth="1.8" + aria-hidden="true" + > + {kind === 'project' ? ( + <> + <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" /> + <path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" /> + </> + ) : kind === 'view' ? ( + <> + <path d="M12 4l9 5-9 5-9-5z" /> + <path d="M3 14l9 5 9-5" /> + </> + ) : kind === 'ticket' ? ( + <circle cx="12" cy="12" r="8" /> + ) : ( + <path d="M8 5c3 0 4.2 1.6 5.4 4.4L19 20h2M13 12l-6 8" /> + )} + </svg> +); + +interface Props { + pods: PodSummary[]; + priority: string[]; + /** The pod on screen — picked, or brought up by Auto — highlighted. */ + activePodId: string | null; + /** The nav row that's on screen, if not a pod. */ + page: Page | null; + /** The mothership's: you, your projects, pods waiting for a computer. */ + lambda: LambdaInfo | undefined; + onOpenAccountPage: (page: AccountPage) => void; + onSignOut: () => void; + /** + * Auto mode, above the account: when on, the pod on screen follows the + * queue (every pod's items, by priority) and ‹ › step through it; picking + * a pod turns it off. + */ + auto: { + on: boolean; + /** The item on screen's place in the queue (-1: none). */ + index: number; + /** Items waiting. */ + total: number; + onToggle: (on: boolean) => void; + onPrev: () => void; + onNext: () => void; + }; + /** Pods with an item set aside ("Next item"): working, not a badge. */ + asidePods: Set<string>; + /** `via`: what selected it — for the page's log (uiLog). */ + onSelectPod: (podId: string, via: 'click' | 'enter-key') => void; + onReorder: (order: string[]) => void; + onNewPod: () => void; + onRename: (podId: string, title: string) => void; + onDelete: (podId: string) => void; + /** A building computer's ⋯ menu: Pause or Resume building. */ + onPauseBuilding: (podId: string, pause: boolean) => void; + /** A building computer's ⋯ menu: its pod, with the Agents rail open. */ + onOpenTerminal: (podId: string) => void; + /** + * Every Lambda, by section: Needs you, Planning, Queued, Building, + * Shipped (a day) and Done. A Lambda's row stays until it ships. + */ + sections: SidebarSections; + /** The Lambda on screen, highlighted. */ + activeLambdaKey: string | null; + onSelectLambda: (lambda: Lambda) => void; + /** A Queued row dropped on another: right after it in the build queue. */ + onMoveTicket: (moving: Lambda, after: Lambda) => void; + /** The computers footer: a computer's own page (its building pod). */ + onOpenComputer: (podId: string) => void; + /** A checkpoint item's number, for "checkpoint 3 · plan". */ + checkpointOf: (entry: InboxEntry) => number | null; + /** Starred views, projects, tickets and Lambdas, in their order. */ + favorites: FavoriteRow[]; + onOpenFavorite: (favorite: FavoriteRow) => void; + onUnfavorite: (favorite: FavoriteRow) => void; + onReorderFavorites: (ids: string[]) => void; + /** The workspace's tracker projects and saved views. */ + onOpenProjects: () => void; + /** The Tickets list is a project's or a view's: Projects or Views is lit, not Tickets. */ + ticketsScoped: boolean; + onOpenViews: () => void; + /** Active planning time today (see useActiveTime). */ + stopwatch: { ticking: boolean; today: string }; + onOpenTickets: () => void; + /** The Time row opens the Time page. */ + onOpenTime: () => void; + /** Pods with something new since the current item came up (a dot). */ + freshPods: Set<string>; + collapsed: boolean; + /** Sliding shut: still laid out open while it narrows to the strip. */ + closing: boolean; + onCollapse: (collapsed: boolean) => void; +} + +/** Every pod reads as exactly one of these — Needs you, or Working, or something is wrong. */ +const STATUS_LABEL: Record<PodStatus, (summary: PodSummary) => string> = { + 'needs-you': () => 'Needs you', + working: (summary) => + summary.request !== undefined + ? 'Working · your request' + : summary.chatHold + ? 'Answering your chat ⌘⏎' + : summary.liveForks.length > 0 + ? 'Working · talking' + : isWorking(summary) + ? 'Working…' + : 'Waiting for your reply', + stalled: () => 'Stalled', + 'waiting-step0': (summary) => + `Waiting on step 0 (${summary.pod.waitingOnHuman?.ticketId ?? ''})`, + paused: () => 'Paused', + done: () => 'Done', +}; + +export { orderPods }; + +const Icon = ({ children }: { children: ReactNode }) => ( + <svg + className="side-icon" + viewBox="0 0 24 24" + width="18" + height="18" + fill="none" + stroke="currentColor" + strokeWidth="1.7" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + {children} + </svg> +); + +const ICONS = { + search: ( + <Icon> + <circle cx="11" cy="11" r="6.5" /> + <path d="M20 20l-4.2-4.2" /> + </Icon> + ), + new: ( + <Icon> + <circle cx="12" cy="12" r="9" /> + <path d="M12 8v8M8 12h8" /> + </Icon> + ), + projects: ( + <Icon> + <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" /> + <path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" /> + </Icon> + ), + views: ( + <Icon> + <path d="M12 4l9 5-9 5-9-5z" /> + <path d="M3 14l9 5 9-5" /> + </Icon> + ), + tickets: ( + <Icon> + <path d="M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 0 0 4v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 0 0-4z" /> + <path d="M14 5v12" strokeDasharray="2 2" /> + </Icon> + ), + time: ( + <Icon> + <circle cx="12" cy="13" r="8" /> + <path d="M12 9v4l2.5 2M10 2h4" /> + </Icon> + ), + plus: ( + <Icon> + <path d="M12 6v12M6 12h12" /> + </Icon> + ), + chevron: ( + <Icon> + <path d="M7 10l5 5 5-5" /> + </Icon> + ), + prev: ( + <Icon> + <path d="M15 6l-6 6 6 6" /> + </Icon> + ), + next: ( + <Icon> + <path d="M9 6l6 6-6 6" /> + </Icon> + ), + more: ( + <Icon> + <circle cx="6" cy="12" r="1" /> + <circle cx="12" cy="12" r="1" /> + <circle cx="18" cy="12" r="1" /> + </Icon> + ), + pause: ( + <Icon> + <path d="M9 6v12M15 6v12" /> + </Icon> + ), + resume: ( + <Icon> + <path d="M8 5.5v13l10-6.5z" /> + </Icon> + ), + terminal: ( + <Icon> + <rect x="3" y="4.5" width="18" height="15" rx="2" /> + <path d="M7 9.5l3 2.5-3 2.5M12.5 15h4.5" /> + </Icon> + ), + pausedBars: ( + <svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor"> + <rect x="1.5" y="1" width="2.5" height="8" rx="0.6" /> + <rect x="6" y="1" width="2.5" height="8" rx="0.6" /> + </svg> + ), + stalled: ( + <Icon> + <path d="M12 3l9.5 17h-19z" /> + <path d="M12 10v4M12 17v.5" /> + </Icon> + ), + paused: ( + <Icon> + <path d="M9 6v12M15 6v12" /> + </Icon> + ), + done: ( + <Icon> + <path d="M5 12.5l4.5 4.5L19 7.5" /> + </Icon> + ), + waiting: ( + <Icon> + <path d="M7 3h10M7 21h10M8 3c0 5 8 5 8 9s-8 4-8 9M16 3c0 5-8 5-8 9s8 4 8 9" /> + </Icon> + ), +}; + +/** The right end of a pod row: what it needs from you, else its state. */ +const StatusMark = ({ + summary, + aside, +}: { + summary: PodSummary; + /** An item of its is set aside until the dev's done: it's working. */ + aside: boolean; +}) => { + if (aside && summary.needsYou <= 1) { + return <span className="side-row__working" aria-label="Working" />; + } + + if (summary.needsYou > 0) { + return <span className="count">{summary.needsYou}</span>; + } + + if (isWorking(summary)) { + return <span className="side-row__working" aria-label="Working" />; + } + + if (summary.status === 'stalled') { + return ICONS.stalled; + } + + if (summary.status === 'paused') { + return ICONS.paused; + } + + if (summary.status === 'waiting-step0') { + return ICONS.waiting; + } + + return summary.status === 'done' ? ICONS.done : null; +}; + +/** A planning row's state in a word, at its end: "working", "paused". */ +const stateWord = (summary: PodSummary, aside: boolean): string => { + if (aside || isWorking(summary)) { + return 'working'; + } + + if (summary.needsYou > 0) { + return 'your turn'; + } + + switch (summary.status) { + case 'stalled': + return 'stalled'; + case 'paused': + return 'paused'; + case 'waiting-step0': + return 'step 0'; + case 'done': + return ''; + default: + return 'idle'; + } +}; + +const SECTIONS_KEY = 'pods.sections'; + +/** Queued shows its first few; the rest behind "+ N more". */ +const QUEUED_SHOWN = 3; + +const readCollapsedSections = (): Record<string, boolean> => { + try { + const saved: unknown = JSON.parse( + window.localStorage.getItem(SECTIONS_KEY) ?? '{}' + ); + + return typeof saved === 'object' && saved !== null + ? Object.fromEntries( + Object.entries(saved).filter( + (entry): entry is [string, boolean] => typeof entry[1] === 'boolean' + ) + ) + : {}; + } catch { + return {}; + } +}; + +/** + * One list in the sidebar, under a quiet header: Planning (or, with two or + * more projects, one per project) and Done. Collapsing it is remembered. + */ +const Section = ({ + id, + title, + action, + defaultCollapsed = false, + count, + hint, + children, +}: { + id: string; + title: string; + /** A count beside the title (Needs you's badge, Queued's total). */ + count?: number; + /** A quiet note after the title: Done's "chats and plans with no tickets". */ + hint?: string; + /** e.g. "+" for a new pod. */ + action?: { label: string; onClick: () => void }; + defaultCollapsed?: boolean; + children: ReactNode; +}) => { + const [collapsed, setCollapsed] = useState( + () => readCollapsedSections()[id] ?? defaultCollapsed + ); + + const toggle = (): void => { + const next = !collapsed; + setCollapsed(next); + + try { + window.localStorage.setItem( + SECTIONS_KEY, + JSON.stringify({ ...readCollapsedSections(), [id]: next }) + ); + } catch { + // Private mode: it just isn't remembered. + } + }; + + return ( + <section className={`side-section side-section--${id}`}> + <div className="side-section__head"> + <button + type="button" + className="side-section__title" + aria-expanded={!collapsed} + onClick={toggle} + > + {title} + {count !== undefined && id !== 'needs-you' && ( + <span className="side-section__count">· {count}</span> + )} + <span + className={`side-section__chevron${ + collapsed ? ' side-section__chevron--closed' : '' + }`} + > + {ICONS.chevron} + </span> + {hint !== undefined && ( + <span className="side-section__hint">{hint}</span> + )} + </button> + {count !== undefined && id === 'needs-you' && ( + <span className="side-section__count side-section__count--badge"> + {count} + </span> + )} + {action !== undefined && ( + <button + type="button" + className="side-section__action" + title={action.label} + aria-label={action.label} + onClick={action.onClick} + > + {ICONS.plus} + </button> + )} + </div> + {!collapsed && children} + </section> + ); +}; + +export const Sidebar = ({ + pods, + priority, + activePodId, + page, + auto, + asidePods, + onSelectPod, + onReorder, + onNewPod, + onRename, + onDelete, + onPauseBuilding, + onOpenTerminal, + sections, + activeLambdaKey, + onSelectLambda, + onMoveTicket, + onOpenComputer, + checkpointOf, + stopwatch, + favorites, + onOpenFavorite, + onUnfavorite, + onReorderFavorites, + onOpenProjects, + onOpenViews, + ticketsScoped, + onOpenTickets, + onOpenTime, + freshPods, + collapsed, + closing, + onCollapse, + lambda, + onOpenAccountPage, + onSignOut, +}: Props) => { + const account = lambda?.owner ?? { name: 'You', initials: '?' }; + const [dragging, setDragging] = useState<string | null>(null); + const [menu, setMenu] = useState<{ + podId: string; + x: number; + y: number; + /** A building computer's menu, not a planning pod's. */ + building?: boolean; + } | null>(null); + /** Re-read each minute, for "shipped … 4m ago". */ + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 60_000); + + return () => window.clearInterval(id); + }, []); + const [renaming, setRenaming] = useState<{ + podId: string; + value: string; + } | null>(null); + const [query, setQuery] = useState(''); + const [accountOpen, setAccountOpen] = useState(false); + /** Bumped on each click of the brand, to replay its load animation. */ + const [brandPlays, setBrandPlays] = useState(0); + const brandText = useRef<HTMLSpanElement>(null); + /** + * How the brand animates. On load, or on a click of "Lambda", the λ flies + * in ('load'). As the sidebar slides shut the word slides left into the λ + * and fades ('leave'), and back out of it as the sidebar opens ('arrive'). + */ + const [brandMode, setBrandMode] = useState<'load' | 'leave' | 'arrive'>( + 'load' + ); + const mounted = useRef(false); + + useLayoutEffect(() => { + if (!mounted.current) { + mounted.current = true; + + return; + } + + setBrandMode(closing ? 'leave' : 'arrive'); + }, [collapsed, closing]); + const brandMark = useRef<SVGSVGElement>(null); + + // The λ flies in from just past the word to the toggle: how far that is. + useLayoutEffect(() => { + const text = brandText.current?.getBoundingClientRect(); + const mark = brandMark.current; + + if (text === undefined || mark === null) { + return; + } + + mark.style.setProperty( + '--brand-from', + `${text.right + 3 - mark.getBoundingClientRect().left}px` + ); + }, [brandPlays]); + const search = useRef<HTMLInputElement>(null); + const ordered = orderPods(pods, priority); + + // Any click or Escape elsewhere closes the right-click menu and the + // account menu. + useEffect(() => { + if (menu === null && !accountOpen) { + return undefined; + } + + const close = (): void => { + setMenu(null); + setAccountOpen(false); + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + close(); + } + }; + window.addEventListener('click', close); + window.addEventListener('keydown', onKey); + + return () => { + window.removeEventListener('click', close); + window.removeEventListener('keydown', onKey); + }; + }, [menu, accountOpen]); + + // ⇧⌘O starts a new pod, as in Claude Code. + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if ( + (event.metaKey || event.ctrlKey) && + event.shiftKey && + event.key.toLowerCase() === 'o' + ) { + event.preventDefault(); + onNewPod(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, [onNewPod]); + + /** Enter (or clicking away) saves; an empty or unchanged title is a no-op. */ + const commitRename = (): void => { + if (renaming === null) { + return; + } + + const title = renaming.value.trim(); + const target = pods.find((summary) => summary.pod.id === renaming.podId); + setRenaming(null); + + if (title !== '' && target !== undefined && title !== podName(target.pod)) { + onRename(renaming.podId, title); + } + }; + + const drop = (targetId: string): void => { + if (dragging === null || dragging === targetId) { + return; + } + + const ids = ordered + .map((summary) => summary.pod.id) + .filter((id) => id !== dragging); + const at = ids.indexOf(targetId); + onReorder([...ids.slice(0, at), dragging, ...ids.slice(at)]); + setDragging(null); + }; + + const needle = query.trim().toLowerCase(); + const matches = (summary: PodSummary): boolean => + needle === '' || + [podName(summary.pod), summary.pod.id, summary.pod.epicId ?? ''] + .join(' ') + .toLowerCase() + .includes(needle); + const summaryOf = (podId: string | null): PodSummary | undefined => + podId === null + ? undefined + : ordered.find((summary) => summary.pod.id === podId); + const matchesLambda = (one: Lambda): boolean => + needle === '' || + [one.title, one.ticketId ?? '', ...one.tickets.map((ticket) => ticket.id)] + .join(' ') + .toLowerCase() + .includes(needle); + /** Planning: the planning pods, in priority order, as before. */ + const planningPods = sections.planning + .filter(matchesLambda) + .flatMap((one) => { + const summary = summaryOf(one.planningPodId); + + return summary === undefined ? [] : [summary]; + }); + const doneRows = sections.done.filter(matchesLambda); + + const menuPod = pods.find((summary) => summary.pod.id === menu?.podId); + + const tooltip = (summary: PodSummary): string => { + const origin = + summary.pod.from === undefined + ? undefined + : pods.find( + (candidate) => candidate.pod.id === summary.pod.from?.podId + ); + const from = + summary.pod.from === undefined + ? '' + : `\nfrom ${ + origin === undefined ? summary.pod.from.podId : podName(origin.pod) + }`; + const phase = + summary.pod.mode === 'freeform' + ? 'Freeform' + : `P${summary.pod.phase} ${phaseName(summary.pod.phase, summary.pod)}`; + + return `${podName(summary.pod)}\n${ + asidePods.has(summary.pod.id) + ? 'Working on your reply · comes back to you when done' + : STATUS_LABEL[summary.status](summary) + } · ${phase}${from}`; + }; + + const podRow = (summary: PodSummary) => { + const { id } = summary.pod; + const isRenaming = renaming?.podId === id; + // Reordering is the queue's priority: only the full, unfiltered list. + const draggable = !isRenaming && needle === '' && summary.status !== 'done'; + + return ( + <div + key={id} + role="button" + tabIndex={0} + title={tooltip(summary)} + draggable={draggable} + onDragStart={() => setDragging(id)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => drop(id)} + onContextMenu={(event) => { + event.preventDefault(); + setMenu({ podId: id, x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (!isRenaming && event.key === 'Enter') { + onSelectPod(id, 'enter-key'); + } + }} + className={`side-row side-row--pod${ + activePodId === id && page === null ? ' side-row--active' : '' + }${dragging === id ? ' side-row--dragging' : ''}${sideRowNeedsYou( + summary + )}${menu?.podId === id ? ' side-row--menu' : ''}`} + onClick={() => { + if (!isRenaming) { + onSelectPod(id, 'click'); + } + }} + > + {freshPods.has(id) && ( + <span + className="side-row__fresh" + title="Something new arrived for this Lambda since your current item came up" + /> + )} + {isRenaming ? ( + <input + className="rename" + autoFocus + value={renaming.value} + onFocus={(event) => event.target.select()} + onClick={(event) => event.stopPropagation()} + onChange={(event) => + setRenaming({ podId: id, value: event.target.value }) + } + onKeyDown={(event) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + commitRename(); + } + + if (event.key === 'Escape') { + setRenaming(null); + } + }} + onBlur={commitRename} + /> + ) : ( + <> + <span className="side-row__lead" aria-hidden="true"> + <StatusMark summary={summary} aside={asidePods.has(id)} /> + </span> + <span className="side-row__label">{podName(summary.pod)}</span> + </> + )} + <span className="side-row__end"> + <span className="side-row__status side-row__state"> + {stateWord(summary, asidePods.has(id))} + </span> + <button + type="button" + className="side-row__more" + aria-label={`More for ${podName(summary.pod)}`} + onClick={(event) => { + event.stopPropagation(); + const box = event.currentTarget.getBoundingClientRect(); + setMenu({ podId: id, x: box.left, y: box.bottom + 4 }); + }} + > + {ICONS.more} + </button> + </span> + </div> + ); + }; + + /** What a Needs-you row waits on, in a few words. */ + const needsLine = (one: Lambda): { text: string; urgent: boolean } => { + const [first] = one.needsYou; + const blocked = one.tickets.find( + (ticket) => ticket.where.kind === 'blocked' + ); + + if (first?.tag === 'blocked') { + return { + text: `blocked · ${ + blocked?.where.kind === 'blocked' + ? blocked.where.computer + : 'a computer' + } is waiting`, + urgent: true, + }; + } + + if (first?.tag === 'review') { + return { text: 'ready to review', urgent: false }; + } + + if (first?.kind === 'checkpoint') { + const cp = checkpointOf(first); + + return { + text: cp === null ? 'checkpoint · plan' : `checkpoint ${cp} · plan`, + urgent: false, + }; + } + + const words: Record<string, string> = { + question: 'a question for you', + reply: 'your turn', + stalled: 'stalled', + structure: 'start planning?', + }; + + return { + text: words[first?.kind ?? 'reply'] ?? 'your turn', + urgent: false, + }; + }; + + const lambdaRowClass = (one: Lambda, extra = ''): string => + `side-row side-row--pod side-row--lambda${ + activeLambdaKey === one.key && page === null ? ' side-row--active' : '' + }${extra}`; + + const needsRow = (one: Lambda) => { + const line = needsLine(one); + + return ( + <button + key={one.key} + type="button" + className={lambdaRowClass(one)} + title={`${one.title}\n${line.text}`} + onClick={() => onSelectLambda(one)} + onContextMenu={(event) => { + if (one.planningPodId !== null) { + event.preventDefault(); + setMenu({ + podId: one.planningPodId, + x: event.clientX, + y: event.clientY, + }); + } + }} + > + <span + className={`side-row__dot side-row__dot--${ + line.urgent ? 'blocked' : 'needs' + }`} + aria-hidden="true" + /> + <span className="side-row__label"> + {one.title} + <span + className={`side-row__sub${ + line.urgent ? ' side-row__sub--urgent' : '' + }`} + > + {line.text} + </span> + </span> + {one.needsYou.length > 1 && ( + <span className="side-row__end"> + <span className="count">{one.needsYou.length}</span> + </span> + )} + </button> + ); + }; + + const ordinal = (place: number): string => + `${place}${ + place % 10 === 1 && place % 100 !== 11 + ? 'st' + : place % 10 === 2 && place % 100 !== 12 + ? 'nd' + : place % 10 === 3 && place % 100 !== 13 + ? 'rd' + : 'th' + }`; + + const ticketLine = (ticket: LambdaTicket): string => { + const { where } = ticket; + + switch (where.kind) { + case 'building': + return `${where.step} · ${Math.max( + 0, + Math.round((now - Date.parse(where.startedAt)) / 60_000) + )}m · ${where.computer}`; + case 'queued': + return `${ordinal(where.place)} · ${where.computer}`; + case 'blocked': + return `blocked · ${where.computer}`; + case 'shipped': + return `shipped ${agoShort(Date.parse(where.at), now)}`; + default: + return 'ready'; + } + }; + + const [movingTicket, setMovingTicket] = useState<Lambda | null>(null); + const [draggingFavorite, setDraggingFavorite] = useState<string | null>(null); + const [allQueued, setAllQueued] = useState(false); + + const queuedRow = (one: Lambda) => { + const next = + one.tickets.find((ticket) => ticket.where.kind === 'queued') ?? + one.tickets[0]; + + return ( + <button + key={one.key} + type="button" + className={lambdaRowClass(one)} + title={`${one.title}\nDrag to reorder the build queue`} + draggable={needle === ''} + onDragStart={() => setMovingTicket(one)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => { + if (movingTicket !== null && movingTicket.key !== one.key) { + onMoveTicket(movingTicket, one); + } + + setMovingTicket(null); + }} + onClick={() => onSelectLambda(one)} + > + <svg + className="side-row__ring" + width="12" + height="12" + viewBox="0 0 16 16" + aria-hidden="true" + > + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="currentColor" + strokeWidth="1.6" + /> + </svg> + <span className="side-row__label"> + {one.title} + {next !== undefined && ( + <span className="side-row__sub">{ticketLine(next)}</span> + )} + </span> + </button> + ); + }; + + const buildingLambdaRow = (one: Lambda) => { + const live = one.tickets.filter( + (ticket) => + ticket.where.kind === 'building' || ticket.where.kind === 'blocked' + ); + const shipped = one.tickets.filter( + (ticket) => ticket.where.kind === 'shipped' + ).length; + + if (one.tickets.length <= 1) { + const [only] = one.tickets; + + return ( + <button + key={one.key} + type="button" + className={lambdaRowClass(one)} + onClick={() => onSelectLambda(one)} + > + <span + className="side-row__dot side-row__dot--building" + aria-hidden="true" + /> + <span className="side-row__label"> + {one.title} + {only !== undefined && ( + <span className="side-row__sub">{ticketLine(only)}</span> + )} + </span> + </button> + ); + } + + return ( + <div key={one.key} className="side-epic"> + <button + type="button" + className={lambdaRowClass(one, ' side-row--epic')} + onClick={() => onSelectLambda(one)} + > + <span className="side-row__caret" aria-hidden="true"> + {ICONS.chevron} + </span> + <span className="side-row__label">{one.title}</span> + <span className="side-row__end"> + <span className="side-chip"> + {shipped}/{one.tickets.length} + </span> + </span> + </button> + {live.map((ticket) => ( + <button + key={ticket.id} + type="button" + className="side-row side-row--pod side-row--child" + onClick={() => onSelectLambda(one)} + > + <span + className={`side-row__dot side-row__dot--${ + ticket.where.kind === 'blocked' ? 'blocked' : 'building' + }`} + aria-hidden="true" + /> + <span className="side-row__label"> + {ticket.title} + <span className="side-row__sub">{ticketLine(ticket)}</span> + </span> + </button> + ))} + </div> + ); + }; + + const shippedRow = (one: Lambda) => ( + <button + key={one.key} + type="button" + className={lambdaRowClass(one)} + onClick={() => onSelectLambda(one)} + > + <svg + className="side-row__shipped" + width="12" + height="12" + viewBox="0 0 16 16" + aria-hidden="true" + > + <circle cx="8" cy="8" r="7" fill="currentColor" /> + <path + d="M4.8 8.2l2.2 2.2 4.2-4.6" + fill="none" + stroke="#fff" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + <span className="side-row__label">{one.title}</span> + <span className="side-row__end side-row__when"> + {one.shippedAt === null ? '' : agoShort(Date.parse(one.shippedAt), now)} + </span> + </button> + ); + + const navRow = ({ + icon, + label, + on, + onClick, + end, + hint, + id, + }: { + icon: ReactNode; + label: string; + on: boolean; + onClick: () => void; + end?: ReactNode; + hint?: string; + id: string; + }) => ( + <button + type="button" + className={`side-row side-row--nav side-row--${id}${ + on ? ' side-row--active' : '' + }`} + title={collapsed ? label : hint} + onClick={onClick} + > + {icon} + <span className="side-row__label">{label}</span> + {end !== undefined && <span className="side-row__end">{end}</span>} + </button> + ); + + return ( + <nav + className={`sidebar${collapsed ? ' sidebar--collapsed' : ''}${ + brandMode === 'load' ? '' : ` sidebar--brand-${brandMode}` + }`} + > + <div className="sidebar__head"> + <button + type="button" + className="side-icon-button" + title={collapsed ? 'Open sidebar' : 'Close sidebar'} + aria-label={collapsed ? 'Open sidebar' : 'Close sidebar'} + onClick={() => onCollapse(!collapsed)} + > + <svg + key={brandPlays} + ref={brandMark} + className="sidebar__brand-mark" + viewBox="6 4 20 24" + aria-hidden="true" + > + <path d="M9 6.5c3 0 4.2 1.6 5.4 4.4L21 25.5h3" /> + <path d="M15.4 14.2L8.5 25.5" /> + </svg> + </button> + <button + type="button" + className="sidebar__brand" + title="New planning Lambda" + onClick={() => { + setBrandPlays((plays) => plays + 1); + setBrandMode('load'); + onNewPod(); + }} + > + <span key={brandPlays} className="sidebar__brand-word"> + <span ref={brandText} className="sidebar__brand-text"> + Lambda + </span> + </span> + </button> + </div> + + {collapsed ? ( + <button + type="button" + className="side-row side-row--nav side-row--search" + title="Search" + onClick={() => { + onCollapse(false); + window.setTimeout(() => search.current?.focus(), 0); + }} + > + {ICONS.search} + </button> + ) : ( + <label className="side-search"> + {ICONS.search} + <input + ref={search} + value={query} + placeholder="Search" + onChange={(event) => setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setQuery(''); + event.currentTarget.blur(); + } + }} + /> + </label> + )} + + <div className="sidebar__nav"> + {navRow({ + icon: ICONS.new, + id: 'new', + label: 'New', + on: page === 'new', + onClick: onNewPod, + hint: 'New planning Lambda (⇧⌘O)', + })} + {navRow({ + id: 'tickets', + icon: ICONS.tickets, + label: 'Tickets', + on: page === 'tickets' && !ticketsScoped, + onClick: onOpenTickets, + })} + {navRow({ + id: 'projects', + icon: ICONS.projects, + label: 'Projects', + on: page === 'projects', + onClick: onOpenProjects, + })} + {navRow({ + id: 'views', + icon: ICONS.views, + label: 'Views', + on: page === 'views', + onClick: onOpenViews, + })} + {navRow({ + icon: ICONS.time, + id: 'time', + label: 'Time', + on: page === 'time', + onClick: onOpenTime, + // Black while it counts, grey when paused. + end: ( + <span + className={`side-row__number${ + stopwatch.ticking ? ' side-row__number--ticking' : '' + }`} + > + {stopwatch.today} + </span> + ), + hint: `${ + stopwatch.ticking ? 'Planning' : 'Paused' + } — active planning time today. Counts only while you scroll, type, click, listen to audio or wait on a live reply; idle for 15 seconds and it stops. Click for the breakdown.`, + })} + </div> + + {!collapsed && ( + <div className="sidebar__lists"> + {favorites.length > 0 && ( + <Section id="favorites" title="Favorites"> + {favorites.map((favorite) => ( + <button + key={favorite.id} + type="button" + className={`side-row side-row--favorite${ + favorite.active ? ' side-row--active' : '' + }`} + title={favorite.label} + draggable + onDragStart={() => setDraggingFavorite(favorite.id)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => { + if ( + draggingFavorite === null || + draggingFavorite === favorite.id + ) { + return; + } + + const ids = favorites + .map((one) => one.id) + .filter((id) => id !== draggingFavorite); + ids.splice(ids.indexOf(favorite.id), 0, draggingFavorite); + setDraggingFavorite(null); + onReorderFavorites(ids); + }} + onContextMenu={(event) => { + event.preventDefault(); + onUnfavorite(favorite); + }} + onClick={() => onOpenFavorite(favorite)} + > + <span className="side-row__favicon" aria-hidden="true"> + {favorite.emoji ?? ( + <FavoriteGlyph + kind={favorite.kind} + color={favorite.color} + /> + )} + </span> + <span className="side-row__label">{favorite.label}</span> + </button> + ))} + </Section> + )} + {sections.needsYou.filter(matchesLambda).length > 0 && ( + <Section + id="needs-you" + title="Needs you" + count={sections.needsYou.filter(matchesLambda).length} + > + {sections.needsYou.filter(matchesLambda).map(needsRow)} + </Section> + )} + + <Section + id="planning" + title="Planning" + action={{ label: 'New planning Lambda', onClick: onNewPod }} + > + {groupByProject(planningPods, lambda).map((group) => ( + <div + key={group.project?.id ?? (group.other ? 'other' : 'pods')} + className="side-group" + > + {(group.project !== null || group.other) && ( + <p className="side-group__name"> + {group.project?.name ?? 'No project'} + </p> + )} + {group.pods.map(podRow)} + {group.waiting.map((waiting) => ( + <div + key={waiting.id} + className="side-row side-row--pod side-row--waiting" + title={waiting.label} + > + <span className="side-row__label"> + {waiting.title} + <span className="side-row__sub"> + waiting for a computer + </span> + {waiting.failure !== undefined && ( + <span className="side-row__sub side-row__sub--error"> + {waitingFailureNote(waiting.failure, Date.now())} + </span> + )} + </span> + </div> + ))} + {group.pods.length === 0 && group.waiting.length === 0 && ( + <p className="muted sidebar__empty"> + {needle === '' + ? 'No planning Lambdas yet. Start one with New.' + : 'Nothing matches.'} + </p> + )} + </div> + ))} + </Section> + + {sections.queued.filter(matchesLambda).length > 0 && ( + <Section + id="queued" + title="Queued" + count={sections.queued.filter(matchesLambda).length} + > + {sections.queued + .filter(matchesLambda) + .slice(0, allQueued || needle !== '' ? undefined : QUEUED_SHOWN) + .map(queuedRow)} + {!allQueued && + needle === '' && + sections.queued.length > QUEUED_SHOWN && ( + <button + type="button" + className="side-more" + onClick={() => setAllQueued(true)} + > + + {sections.queued.length - QUEUED_SHOWN} more + </button> + )} + </Section> + )} + + {sections.building.filter(matchesLambda).length > 0 && ( + <Section id="building" title="Building"> + {sections.building.filter(matchesLambda).map(buildingLambdaRow)} + </Section> + )} + + {sections.shipped.filter(matchesLambda).length > 0 && ( + <Section id="shipped" title="Shipped · last 24h"> + {sections.shipped.filter(matchesLambda).map(shippedRow)} + </Section> + )} + + {doneRows.length > 0 && ( + <Section + id="done" + title="Done" + hint="chats and plans with no tickets" + defaultCollapsed + > + {doneRows.flatMap((one) => { + const summary = summaryOf(one.planningPodId); + + return summary === undefined ? [] : [podRow(summary)]; + })} + </Section> + )} + </div> + )} + + {menu !== null && menu.building === true && ( + <div + className="context-menu" + role="menu" + aria-label={lambda?.podComputers?.[menu.podId] ?? 'Building'} + style={{ left: menu.x, top: menu.y }} + onClick={(event) => event.stopPropagation()} + > + <button + type="button" + role="menuitem" + onClick={() => { + onPauseBuilding(menu.podId, menuPod?.pod.status !== 'paused'); + setMenu(null); + }} + > + {menuPod?.pod.status === 'paused' ? ( + <>{ICONS.resume} Resume building</> + ) : ( + <>{ICONS.pause} Pause building</> + )} + </button> + <button + type="button" + role="menuitem" + onClick={() => { + onOpenTerminal(menu.podId); + setMenu(null); + }} + > + {ICONS.terminal} Open terminal + </button> + </div> + )} + + {menu !== null && menu.building !== true && ( + <div + className="context-menu" + role="menu" + style={{ left: menu.x, top: menu.y }} + onClick={(event) => event.stopPropagation()} + > + <button + type="button" + role="menuitem" + onClick={() => { + const target = pods.find( + (summary) => summary.pod.id === menu.podId + ); + setRenaming({ + podId: menu.podId, + value: target === undefined ? '' : podName(target.pod), + }); + setMenu(null); + }} + > + Rename + </button> + <button + type="button" + role="menuitem" + className="context-menu__danger" + onClick={() => { + const target = pods.find( + (summary) => summary.pod.id === menu.podId + ); + const name = + target === undefined ? menu.podId : podName(target.pod); + setMenu(null); + + if ( + window.confirm( + `Delete "${name}"?\n\nIts agents stop and it leaves the app. Its tickets are not touched, and its files move to the pods' trash so it can be restored.` + ) + ) { + onDelete(menu.podId); + } + }} + > + Delete pod… + </button> + </div> + )} + + <div className="sidebar__foot"> + {!collapsed && (lambda?.computers ?? []).length > 0 && ( + <ComputersLine + pods={pods} + lambda={lambda} + now={now} + onPause={onPauseBuilding} + onOpenComputer={onOpenComputer} + onOpenTerminal={onOpenTerminal} + onOpenPage={() => onOpenAccountPage('computers')} + onOpenTickets={onOpenTickets} + /> + )} + <div + className={`side-auto${auto.on ? ' side-auto--on' : ''}`} + title={ + auto.on + ? 'Auto: the screen follows whatever needs you next. Picking a Lambda turns it off.' + : 'Auto is off: you picked a Lambda. Turn it on to be taken to whatever needs you next.' + } + > + {!collapsed && auto.on && auto.total > 0 && ( + <> + <button + type="button" + className="side-icon-button side-auto__step" + aria-label="Previous item (P)" + title="Previous item (P)" + disabled={auto.index <= 0} + onClick={auto.onPrev} + > + {ICONS.prev} + </button> + <button + type="button" + className="side-icon-button side-auto__step" + aria-label="Next item (N)" + title="Next item (N)" + disabled={auto.index < 0 || auto.index >= auto.total - 1} + onClick={auto.onNext} + > + {ICONS.next} + </button> + <span className="side-auto__count"> + {auto.index >= 0 ? auto.index + 1 : '–'} of {auto.total} + </span> + </> + )} + {!collapsed && auto.on && auto.total === 0 && ( + <span className="side-auto__count">All caught up</span> + )} + {!collapsed && !auto.on && auto.total > 0 && ( + <span className="side-auto__count">{auto.total} waiting</span> + )} + <label className="switch side-auto__switch"> + <input + type="checkbox" + role="switch" + checked={auto.on} + onChange={(event) => auto.onToggle(event.target.checked)} + /> + <span className="switch__track" aria-hidden="true"> + <span className="switch__thumb" /> + </span> + {!collapsed && <span>Auto</span>} + </label> + {collapsed && auto.total > 0 && ( + <span className="side-auto__badge">{auto.total}</span> + )} + </div> + + <div className="side-account"> + <button + type="button" + className="side-row side-row--account" + aria-haspopup="menu" + aria-expanded={accountOpen} + onClick={(event) => { + event.stopPropagation(); + setAccountOpen(!accountOpen); + }} + > + <span className="side-avatar">{account.initials}</span> + {!collapsed && ( + <> + <span className="side-row__label">{account.name}</span> + <span className="side-account__chevron">{ICONS.chevron}</span> + </> + )} + </button> + {accountOpen && ( + <div + className="context-menu side-account__menu" + role="menu" + onClick={(event) => event.stopPropagation()} + > + <div className="side-account__who"> + <span className="side-avatar">{account.initials}</span> + <strong>{account.name}</strong> + </div> + {( + [ + ['computers', 'Computers'], + ['analytics', 'Analytics'], + ['settings/devices', 'Settings'], + ] as const + ).map(([target, label]) => ( + <button + key={target} + type="button" + role="menuitem" + className={ + page === target || + (target === 'settings/devices' && + (page === 'settings/process' || + page === 'settings/design-system')) + ? 'context-menu__on' + : undefined + } + onClick={() => { + setAccountOpen(false); + onOpenAccountPage(target); + }} + > + {label} + </button> + ))} + <button + type="button" + role="menuitem" + onClick={() => { + setAccountOpen(false); + onSignOut(); + }} + > + Sign out + </button> + </div> + )} + </div> + </div> + </nav> + ); +}; diff --git a/src/app/client/SignIn.tsx b/src/app/client/SignIn.tsx new file mode 100644 index 0000000..86d7c1f --- /dev/null +++ b/src/app/client/SignIn.tsx @@ -0,0 +1,74 @@ +import { type FormEvent, useState } from 'react'; + +import { api } from './api'; + +/** + * Every visit without a session lands here. A code from Add a device signs + * this device in; on the computer running Lambda, `lambda open` skips it. + */ +export const SignIn = ({ onSignedIn }: { onSignedIn: () => void }) => { + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + const submit = (event: FormEvent): void => { + event.preventDefault(); + + if (code.trim() === '' || busy) { + return; + } + + setBusy(true); + setError(null); + api + .pair(code) + .then(onSignedIn) + .catch((caught: unknown) => { + setError(caught instanceof Error ? caught.message : String(caught)); + setBusy(false); + }); + }; + + return ( + <main className="sign-in"> + <form className="sign-in__card" onSubmit={submit}> + <p className="sign-in__brand">Lambda</p> + {/* The design says "Sign in to Leslie's Lambda", but the owner's name + can't be read before sign-in without a fifth public endpoint, and + the public list (PUBLIC_ROUTES, mothership/gate.ts) is pinned to + four. */} + <h1 className="sign-in__title">Sign in to your Lambda</h1> + <label className="sign-in__label" htmlFor="sign-in-code"> + Enter the code from “Add a device” + </label> + <input + id="sign-in-code" + className="sign-in__code" + autoFocus + autoComplete="one-time-code" + spellCheck={false} + placeholder="XXXX-XXXX" + value={code} + disabled={busy} + onChange={(event) => setCode(event.target.value)} + /> + {error !== null && ( + <p className="sign-in__error" role="alert"> + {error} + </p> + )} + <button + type="submit" + className="primary" + disabled={code.trim() === '' || busy} + > + Sign in + </button> + <p className="muted sign-in__hint"> + On the computer running Lambda? Run <code>lambda open</code> to sign + in at once. + </p> + </form> + </main> + ); +}; diff --git a/src/app/client/SpeakerIcon.tsx b/src/app/client/SpeakerIcon.tsx new file mode 100644 index 0000000..3e3f31a --- /dev/null +++ b/src/app/client/SpeakerIcon.tsx @@ -0,0 +1,18 @@ +/** The quiet "listen" glyph (was a 🔊 emoji): a speaker, 14px, in grey. */ +export const SpeakerIcon = () => ( + <svg + className="speaker-icon" + viewBox="0 0 24 24" + width="14" + height="14" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + <path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" /> + <path d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11" /> + </svg> +); diff --git a/src/app/client/StructureCard.tsx b/src/app/client/StructureCard.tsx new file mode 100644 index 0000000..1fa08a9 --- /dev/null +++ b/src/app/client/StructureCard.tsx @@ -0,0 +1,54 @@ +import type { Pod } from '../../pod-store'; +import { podName } from '../shared/podName'; +import { + STRUCTURE_BETWEEN, + type StructureProposal, + structureCheckpoints, + structureWhat, +} from '../shared/structurePitch'; + +/** + * A freeform pod's dev proposes structured planning: the Plan rail shows the + * pitch — what it would plan, why now, and (short, like an ad) how it works. + * Start planning is the footer's black button (⌘⏎); keep chatting to wait. + */ +export const StructureCard = ({ + pod, + proposal, +}: { + pod: Pod; + proposal: StructureProposal; +}) => { + const checkpoints = structureCheckpoints(proposal); + + return ( + <article className="card structure-pitch"> + <div className="card__meta"> + <span className="kind">Structured planning</span> + <span>{podName(pod)}</span> + </div> + <section className="section"> + <p className="structure-pitch__eyebrow">Ready to plan</p> + <h3 className="structure-pitch__what"> + {structureWhat(proposal, podName(pod))} + </h3> + <p className="structure-pitch__why">{proposal.why}</p> + </section> + <section className="section"> + <p className="lead"> + Lambda does the work. You step in at {checkpoints.length} checkpoints + to approve or steer: + </p> + <ol className="structure-pitch__steps"> + {checkpoints.map((step) => ( + <li key={step}>{step}</li> + ))} + </ol> + <p className="muted structure-pitch__between">{STRUCTURE_BETWEEN}</p> + <p className="muted structure-pitch__wait"> + Not ready? Keep chatting — this stays here until you start it. + </p> + </section> + </article> + ); +}; diff --git a/src/app/client/TerminalView.tsx b/src/app/client/TerminalView.tsx new file mode 100644 index 0000000..64ab82d --- /dev/null +++ b/src/app/client/TerminalView.tsx @@ -0,0 +1,143 @@ +import { Terminal } from '@xterm/xterm'; +import '@xterm/xterm/css/xterm.css'; +import { useEffect, useRef } from 'react'; + +import { api } from './api'; +import { toHexBytes } from './keys'; +import { terminalErrors } from './terminalErrors'; + +/** + * A live view of one agent's tmux pane, for dropping in when something looks + * off. It polls `tmux capture-pane -e` and redraws; typing goes back as raw + * bytes via `tmux send-keys -H`, so it works for any agent CLI. + */ +export const TerminalView = ({ + podId, + window: windowName, + onError, + readOnly = false, +}: { + podId: string; + window: string; + /** The page's error line: an offline computer is said, not swallowed. */ + onError: (caught: unknown) => void; + /** A window that isn't an agent's: watched, never typed into. */ + readOnly?: boolean; +}) => { + const host = useRef<HTMLDivElement>(null); + // Through a ref: a new handler mustn't rebuild the terminal. + const report = useRef(onError); + report.current = onError; + + useEffect(() => { + const element = host.current; + + if (element === null) { + return undefined; + } + + const terminal = new Terminal({ + fontSize: 12, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + theme: { + background: '#262624', + foreground: '#faf9f5', + cursor: '#faf9f5', + }, + cols: 120, + rows: 45, + // A mirror, not a shell: each frame is the whole pane, so nothing may + // ever scroll into history (that is what stacked repeated headers). + scrollback: 0, + disableStdin: readOnly, + }); + terminal.open(element); + const errors = terminalErrors((caught) => report.current(caught)); + // One request per keystroke, strictly in order — concurrent requests can + // land out of order and submit a half-typed line. + const queue = { tail: Promise.resolve() }; + const subscription = terminal.onData((data) => { + if (readOnly) { + return; + } + + queue.tail = queue.tail + .then(() => api.termKeys(podId, windowName, toHexBytes(data))) + .then(() => undefined) + .catch(errors.keys); + }); + + const state = { last: '', cols: 0, rows: 0, stopped: false }; + + /** The font that fits the pane's width into the panel's. */ + const fitFont = (): void => { + if (state.cols === 0) { + return; + } + + terminal.options.fontSize = Math.max( + 7, + Math.min(13, Math.floor(element.clientWidth / (state.cols * 0.62))) + ); + }; + + /** Match the pane's size, and shrink the font so its width fits the panel. */ + const fit = (cols: number, rows: number): void => { + if (cols === state.cols && rows === state.rows) { + return; + } + + state.cols = cols; + state.rows = rows; + terminal.resize(cols, rows); + fitFont(); + }; + + // The rail is resizable and can expand: refit when it changes width. + const resized = new ResizeObserver(fitFont); + resized.observe(element); + + const poll = async (): Promise<void> => { + if (state.stopped) { + return; + } + + try { + const { screen, cols, rows } = await api.term(podId, windowName); + errors.polled(); + fit(cols, rows); + + if (screen !== state.last) { + state.last = screen; + const lines = screen.replace(/\n$/, '').split('\n').slice(0, rows); + // Each line is placed on its own row with autowrap off, so a frame + // can never scroll — the old "same line repeated down the panel". + const frame = lines + .map((line, row) => `\x1b[${row + 1};1H\x1b[2K${line}\x1b[0m`) + .join(''); + terminal.write(`\x1b[?7l\x1b[H\x1b[2J${frame}`); + } + } catch (caught) { + // Window gone (fork closed, pod paused): keep the last frame. A + // computer gone offline is said (terminalErrors). + if (!state.stopped) { + errors.poll(caught); + } + } + + setTimeout(() => { + poll().catch(() => undefined); + }, 700); + }; + poll().catch(() => undefined); + + return () => { + state.stopped = true; + resized.disconnect(); + subscription.dispose(); + terminal.dispose(); + }; + }, [podId, windowName, readOnly]); + + return <div className="terminal" ref={host} />; +}; diff --git a/src/app/client/TicketIcons.tsx b/src/app/client/TicketIcons.tsx new file mode 100644 index 0000000..9e571d2 --- /dev/null +++ b/src/app/client/TicketIcons.tsx @@ -0,0 +1,337 @@ +import type { Priority, TicketState } from '../../tickets/source'; +import { PRIORITY_NAMES, STATUSES } from '../shared/ticketBoard'; + +/** + * The Tickets screen's small icons, drawn as Linear draws them: one status + * set used everywhere (list, ticket page, menus), priority bars, and the + * computer an assigned ticket goes to. + */ + +export const StatusIcon = ({ + status, + size = 15, + label = true, +}: { + status: TicketState; + size?: number; + /** Off where the status's name is written beside it. */ + label?: boolean; +}) => { + const look = STATUSES[status]; + const a11y = label + ? { role: 'img', 'aria-label': look.name } + : { 'aria-hidden': true }; + + return ( + <svg + className={`status-icon status-icon--${look.icon}`} + width={size} + height={size} + viewBox="0 0 16 16" + {...a11y} + > + {look.icon === 'inbox' && ( + <> + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeDasharray="1 2.4" + strokeLinecap="round" + /> + <path + d="M5.2 8.6h1.6l.7 1.1h1l.7-1.1h1.6" + fill="none" + stroke="currentColor" + strokeWidth="1.2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </> + )} + {look.icon === 'planning' && ( + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeDasharray="2.5 2" + /> + )} + {look.icon === 'ready' && ( + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + /> + )} + {look.icon === 'building' && ( + <> + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + /> + <path d="M8 3.2a4.8 4.8 0 010 9.6z" fill="currentColor" /> + </> + )} + {look.icon === 'blocked' && ( + <> + <circle + cx="8" + cy="8" + r="6.5" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + /> + <path d="M3.5 12.5l9-9" stroke="currentColor" strokeWidth="1.5" /> + </> + )} + {look.icon === 'shipped' && ( + <> + <circle cx="8" cy="8" r="7" fill="currentColor" /> + <path + d="M4.8 8.2l2.2 2.2 4.2-4.6" + fill="none" + stroke="var(--white)" + strokeWidth="1.7" + strokeLinecap="round" + strokeLinejoin="round" + /> + </> + )} + {look.icon === 'canceled' && ( + <> + <circle cx="8" cy="8" r="7" fill="currentColor" /> + <path + d="M5.5 5.5l5 5M10.5 5.5l-5 5" + stroke="var(--white)" + strokeWidth="1.6" + strokeLinecap="round" + /> + </> + )} + </svg> + ); +}; + +/** Bars filled for each level; Urgent is an orange square; none is "···". */ +export const PriorityIcon = ({ + priority, + label = true, +}: { + priority: Priority; + label?: boolean; +}) => { + const a11y = label + ? { role: 'img', 'aria-label': PRIORITY_NAMES[priority] } + : { 'aria-hidden': true }; + + if (priority === 'none') { + return ( + <span + className="priority-icon priority-icon--none" + {...(label + ? { role: 'img', 'aria-label': PRIORITY_NAMES.none } + : { 'aria-hidden': true })} + > + ··· + </span> + ); + } + + if (priority === 'urgent') { + return ( + <svg + className="priority-icon priority-icon--urgent" + width="14" + height="14" + viewBox="0 0 16 16" + {...a11y} + > + <rect x="1" y="1" width="14" height="14" rx="3" fill="currentColor" /> + <path + d="M8 4v5" + stroke="var(--white)" + strokeWidth="2" + strokeLinecap="round" + /> + <circle cx="8" cy="11.8" r="1.2" fill="var(--white)" /> + </svg> + ); + } + + const filled = { high: 3, medium: 2, low: 1 }[priority]; + + return ( + <svg + className="priority-icon" + width="14" + height="14" + viewBox="0 0 16 16" + {...a11y} + > + {[ + { x: 1.5, y: 9, h: 5 }, + { x: 6.5, y: 5.5, h: 8.5 }, + { x: 11.5, y: 2, h: 12 }, + ].map((bar, index) => ( + <rect + key={bar.x} + x={bar.x} + y={bar.y} + width="3" + height={bar.h} + rx="1" + className={ + index < filled ? 'priority-icon__on' : 'priority-icon__off' + } + /> + ))} + </svg> + ); +}; + +/** A laptop: a ticket assigned to a computer; dashed when unassigned. */ +export const ComputerIcon = ({ assigned }: { assigned: boolean }) => + assigned ? ( + <svg + className="computer-icon" + width="15" + height="15" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + aria-hidden="true" + > + <rect x="4" y="5" width="16" height="11" rx="1.5" /> + <path d="M2 19h20" /> + </svg> + ) : ( + <svg + className="computer-icon computer-icon--none" + width="15" + height="15" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeDasharray="3 2" + aria-hidden="true" + > + <rect x="4" y="5" width="16" height="11" rx="1.5" /> + </svg> + ); + +/** The epic's progress ring beside "1/5". */ +export const ProgressRing = ({ + done, + total, +}: { + done: number; + total: number; +}) => { + const share = total === 0 ? 0 : done / total; + const radius = 6; + const around = 2 * Math.PI * radius; + + return ( + <svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true"> + <circle + cx="8" + cy="8" + r={radius} + fill="none" + stroke="var(--grey-300)" + strokeWidth="2.5" + /> + <circle + cx="8" + cy="8" + r={radius} + fill="none" + stroke="var(--success)" + strokeWidth="2.5" + strokeDasharray={`${share * around} ${around}`} + transform="rotate(-90 8 8)" + /> + </svg> + ); +}; + +/** A small stroke icon, 14px: filter, display, plus, chevrons, link, lock. */ +export const LineIcon = ({ + name, + size = 14, +}: { + name: + | 'filter' + | 'display' + | 'plus' + | 'down' + | 'up' + | 'link' + | 'lock' + | 'close' + | 'check' + | 'search' + | 'sparkle'; + size?: number; +}) => ( + <svg + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + > + {name === 'filter' && <path d="M4 6h16M7 12h10M10 18h4" />} + {name === 'display' && ( + <> + <path d="M4 7h10M18 7h2M4 17h4M12 17h8" /> + <circle cx="16" cy="7" r="2" /> + <circle cx="10" cy="17" r="2" /> + </> + )} + {name === 'plus' && <path d="M12 5v14M5 12h14" />} + {name === 'down' && <path d="M12 5v14M6 13l6 6 6-6" />} + {name === 'up' && <path d="M12 19V5M6 11l6-6 6 6" />} + {name === 'link' && ( + <path d="M10 14a4 4 0 006 0l3-3a4 4 0 00-6-6l-1 1M14 10a4 4 0 00-6 0l-3 3a4 4 0 006 6l1-1" /> + )} + {name === 'lock' && ( + <> + <rect x="5" y="11" width="14" height="9" rx="2" /> + <path d="M8 11V8a4 4 0 018 0v3" /> + </> + )} + {name === 'close' && <path d="M6 6l12 12M18 6L6 18" />} + {name === 'check' && <path d="M5 12l5 5 9-10" />} + {name === 'search' && ( + <> + <circle cx="11" cy="11" r="7" /> + <path d="M20 20l-3.5-3.5" /> + </> + )} + {name === 'sparkle' && ( + <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /> + )} + </svg> +); diff --git a/src/app/client/TicketMenu.tsx b/src/app/client/TicketMenu.tsx new file mode 100644 index 0000000..689f1b8 --- /dev/null +++ b/src/app/client/TicketMenu.tsx @@ -0,0 +1,229 @@ +import { + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +/** + * A Linear-style dropdown: a trigger, then a floating list with a type-to- + * filter box, ↑ ↓ to move and Enter to pick. Used for every property on the + * Tickets screen (status, priority, computer, epic, labels) and for Filter + * and Display. + */ + +export interface MenuOption { + value: string; + label: string; + /** Drawn before the label: a status or priority icon, a dot. */ + icon?: ReactNode; + /** A second, quieter line ("Any computer can take it"), or a state. */ + hint?: string; + /** Shown at the end ("idle", "offline"). */ + trail?: string; + disabled?: boolean; +} + +export const TicketMenu = ({ + options, + selected, + onPick, + onClose, + placeholder, + label, + align = 'left', + onCreate, +}: { + options: MenuOption[]; + /** Ticked: one value, or several for a multi-pick (labels). */ + selected: string[]; + onPick: (value: string) => void; + onClose: () => void; + /** The filter box's placeholder ("Assign to…"). */ + placeholder: string; + label: string; + align?: 'left' | 'right'; + /** Offered as `Add "<typed>"` when nothing matches exactly (a new label). */ + onCreate?: (text: string) => void; +}) => { + const [query, setQuery] = useState(''); + const [at, setAt] = useState(0); + const box = useRef<HTMLDivElement>(null); + const input = useRef<HTMLInputElement>(null); + const needle = query.trim().toLowerCase(); + const matching = options.filter( + (option) => + needle === '' || + option.label.toLowerCase().includes(needle) || + (option.hint ?? '').toLowerCase().includes(needle) + ); + const creatable = + onCreate !== undefined && + needle !== '' && + !options.some((option) => option.label.toLowerCase() === needle); + const shown: MenuOption[] = creatable + ? [...matching, { value: `\u0000new`, label: `Add “${query.trim()}”` }] + : matching; + + useLayoutEffect(() => input.current?.focus(), []); + useEffect(() => setAt(0), [query]); + + // A click anywhere else closes it. + useEffect(() => { + const away = (event: MouseEvent): void => { + if ( + box.current !== null && + event.target instanceof Node && + !box.current.contains(event.target) + ) { + onClose(); + } + }; + window.addEventListener('mousedown', away); + + return () => window.removeEventListener('mousedown', away); + }, [onClose]); + + const pick = (option: MenuOption | undefined): void => { + if (option === undefined || option.disabled === true) { + return; + } + + if (option.value === '\u0000new') { + onCreate?.(query.trim()); + + return; + } + + onPick(option.value); + }; + + return ( + <div + ref={box} + className={`ticket-menu ticket-menu--${align}`} + role="dialog" + aria-label={label} + onClick={(event) => event.stopPropagation()} + > + <input + ref={input} + className="ticket-menu__filter" + type="text" + value={query} + placeholder={placeholder} + aria-label={placeholder} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + + if (event.key === 'Escape') { + onClose(); + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setAt((now) => Math.min(now + 1, shown.length - 1)); + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + setAt((now) => Math.max(now - 1, 0)); + } + + if (event.key === 'Enter') { + event.preventDefault(); + pick(shown[at]); + } + }} + /> + <div role="listbox" aria-label={label} className="ticket-menu__list"> + {shown.length === 0 && <p className="ticket-menu__empty">No matches</p>} + {shown.map((option, index) => ( + <button + key={option.value} + type="button" + role="option" + aria-selected={selected.includes(option.value)} + disabled={option.disabled} + className={`ticket-menu__option${ + index === at ? ' ticket-menu__option--at' : '' + }`} + onMouseEnter={() => setAt(index)} + onClick={() => pick(option)} + > + {option.icon !== undefined && ( + <span className="ticket-menu__icon">{option.icon}</span> + )} + <span className="ticket-menu__label"> + {option.label} + {option.hint !== undefined && ( + <span className="ticket-menu__hint">{option.hint}</span> + )} + </span> + {option.trail !== undefined && ( + <span className="ticket-menu__trail">{option.trail}</span> + )} + {selected.includes(option.value) && ( + <svg + className="ticket-menu__tick" + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2.2" + role="img" + aria-label="Selected" + > + <path d="M5 12l5 5 9-10" /> + </svg> + )} + </button> + ))} + </div> + </div> + ); +}; + +/** A property button that opens its menu below itself. */ +export const MenuButton = ({ + children, + menu, + className, + label, + disabled = false, + title, +}: { + children: ReactNode; + /** The menu, given a way to close it. */ + menu: (close: () => void) => ReactNode; + className: string; + label: string; + disabled?: boolean; + title?: string; +}) => { + const [open, setOpen] = useState(false); + + return ( + <span className="menu-anchor"> + <button + type="button" + className={`${className}${open ? ` ${className}--open` : ''}`} + aria-haspopup="dialog" + aria-expanded={open} + aria-label={label} + title={title} + disabled={disabled} + onClick={(event) => { + event.stopPropagation(); + setOpen(!open); + }} + > + {children} + </button> + {open && menu(() => setOpen(false))} + </span> + ); +}; diff --git a/src/app/client/TicketsPage.tsx b/src/app/client/TicketsPage.tsx new file mode 100644 index 0000000..feaee6c --- /dev/null +++ b/src/app/client/TicketsPage.tsx @@ -0,0 +1,2905 @@ +import { + type ReactNode, + Suspense, + lazy, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { + Priority, + TicketState, + TrackerProject, +} from '../../tickets/source'; +import { formatMinutes, stepDisplayName } from '../shared/buildTimeline'; +import type { + BuildingPodDetail, + NewTicket, + TicketChanges, + TicketListItem, + TicketPage, + TicketsResponse, +} from '../shared/building'; +import { agoShort } from '../shared/building'; +import { + type Lambda, + askRouteOf, + lambdaOfTicket, + lambdasOf, +} from '../shared/lambdas'; +import { + type ListSettings, + NO_SETTINGS, + decodeSettings, + encodeSettings, + favoriteRef, + listSettingsOf, + sameSettings, +} from '../shared/savedViews'; +import { + ALL_STATUSES, + type BoardRow, + type ComputerChoice, + FILTER_FIELDS, + FILTER_NAMES, + type FilterChip, + type FilterField, + GROUPINGS, + GROUPING_NAMES, + type Grouping, + PICKABLE_STATUSES, + PRIORITY_NAMES, + PRIORITY_ORDER, + STATUSES, + assigneeOf, + boardGroups, + computerChoices, + isPriorityName, + isStatusName, + labelColour, + priorityOf, + realChanges, + shortDate, + ticketActivity, + visibleRows, +} from '../shared/ticketBoard'; +import { + linkOf, + openGroupKeysByProject, + sourceLabel, + talkToSend, + ticketKey, + ticketsRefreshDue, +} from '../shared/ticketList'; +import type { AppState } from '../shared/types'; +import { BlockerCard, blockerAnswer } from './BlockerCard'; +import { Markdown } from './Message'; +import { ProjectIcon, StarButton, ViewIcon } from './ProjectIcon'; +import { + ComputerIcon, + LineIcon, + PriorityIcon, + ProgressRing, + StatusIcon, +} from './TicketIcons'; +import { MenuButton, type MenuOption, TicketMenu } from './TicketMenu'; +import { ProjectPanel, ViewDialog, ViewPanel } from './ViewPanels'; +import { api } from './api'; +import { errorText, latestLoader } from './loading'; +import type { Workspace } from './useWorkspace'; + +/** + * The Tickets screen, laid out as Linear's: a dense list grouped by status + * (or project, epic or computer), filters as chips, a ticket's own page with + * its properties on the right, and New ticket as a dialog. Every change goes + * to the project's ticket source through the computer holding it; Building + * and Shipped stay the build's to set. + */ + +export interface OpenTicket { + projectId: string; + ticketId: string; +} + +const allTickets = (data: TicketsResponse): TicketListItem[] => + data.projects.flatMap((project) => + 'tickets' in project ? project.tickets : [] + ); + +/** The editor is big: it loads with the first ticket page, not the list. */ +const DescriptionEditor = lazy(() => + import('./DescriptionEditor').then((module) => ({ + default: module.DescriptionEditor, + })) +); + +/** A typed key goes to the box it's typed in, never to a shortcut. */ +const typing = (target: EventTarget | null): boolean => + target instanceof HTMLElement && + (target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); + +// ── the view, kept per browser ────────────────────────────────────────────── + +const VIEW_KEY = 'lambda:tickets-view'; + +interface ViewSettings { + groupBy: Grouping; + chips: FilterChip[]; + /** Folded groups, by key. */ + folded: string[]; +} + +const isGrouping = (value: unknown): value is Grouping => + GROUPINGS.some((grouping) => grouping === value); + +const isField = (value: unknown): value is FilterField => + FILTER_FIELDS.some((field) => field === value); + +const loadView = (): ViewSettings => { + const fallback: ViewSettings = { groupBy: 'status', chips: [], folded: [] }; + + try { + const saved: unknown = JSON.parse( + window.localStorage.getItem(VIEW_KEY) ?? 'null' + ); + + if (typeof saved !== 'object' || saved === null) { + return fallback; + } + + const groupBy = 'groupBy' in saved ? saved.groupBy : undefined; + const chips = 'chips' in saved ? saved.chips : undefined; + const folded = 'folded' in saved ? saved.folded : undefined; + + return { + groupBy: isGrouping(groupBy) ? groupBy : 'status', + chips: Array.isArray(chips) + ? chips.flatMap((chip: unknown) => + typeof chip === 'object' && + chip !== null && + 'field' in chip && + 'value' in chip && + isField(chip.field) && + typeof chip.value === 'string' + ? [{ field: chip.field, value: chip.value }] + : [] + ) + : [], + folded: Array.isArray(folded) + ? folded.filter((key): key is string => typeof key === 'string') + : [], + }; + } catch { + return fallback; + } +}; + +const PANEL_KEY = 'lambda:tickets-panel'; + +/** A view's or project's panel: open unless they've closed it. */ +const loadPanelOpen = (): boolean => { + try { + return window.localStorage.getItem(PANEL_KEY) !== 'closed'; + } catch { + return true; + } +}; + +const storePanelOpen = (open: boolean): void => { + try { + window.localStorage.setItem(PANEL_KEY, open ? 'open' : 'closed'); + } catch { + // Private mode: it opens again next time. + } +}; + +const saveView = (view: ViewSettings): void => { + try { + window.localStorage.setItem(VIEW_KEY, JSON.stringify(view)); + } catch { + // Private mode or full: the view resets on the next load. + } +}; + +// ── shared bits ───────────────────────────────────────────────────────────── + +interface Lookups { + items: TicketListItem[]; + projectName: (projectId: string) => string; + /** The computers a ticket in this project can be assigned to. */ + computersFor: (projectId: string) => ComputerChoice[]; + /** You: a ticket can be yours to do by hand, and no computer builds it. */ + owner: { name: string; initials: string }; + /** A codebase's tracker projects (Linear's), for the Project property. */ + projectsOf: (codebaseId: string) => TrackerProject[]; +} + +/** Who a ticket is assigned to: your initials, a laptop, or a dashed one. */ +const AssigneeIcon = ({ + assignee, + owner, +}: { + assignee: string | null; + owner: Lookups['owner']; +}) => + assignee === owner.name ? ( + <span className="tk-avatar" aria-hidden="true"> + {owner.initials} + </span> + ) : ( + <ComputerIcon assigned={assignee !== null} /> + ); + +const titleOf = ( + items: TicketListItem[], + projectId: string, + id: string +): string => + items.find((item) => item.projectId === projectId && item.id === id)?.title ?? + id; + +/** The menus' options. */ +const statusOptions = (): MenuOption[] => + PICKABLE_STATUSES.map((status) => ({ + value: status, + label: STATUSES[status].name, + icon: <StatusIcon status={status} label={false} />, + })); + +const priorityOptions = (): MenuOption[] => + PRIORITY_ORDER.map((priority) => ({ + value: priority, + label: PRIORITY_NAMES[priority], + icon: <PriorityIcon priority={priority} label={false} />, + })); + +const UNASSIGNED = ''; + +/** "Building", or "Building · In review" when the tracker calls it more. */ +const statusName = ( + item: Pick<TicketListItem, 'status' | 'trackerStatus'> +): string => + (item.trackerStatus ?? null) === null + ? STATUSES[item.status].name + : `${STATUSES[item.status].name} · ${item.trackerStatus ?? ''}`; + +/** Who a message of a ticket's conversation is from: whichever agent, Lambda. */ +const TALKERS: Record<'you' | 'product-manager' | 'dev', string> = { + you: 'You', + 'product-manager': 'Lambda', + dev: 'Lambda', +}; + +/** A Lambda's phase in a word, for the ticket's Lambda property. */ +const PHASE_WORDS: Record<Lambda['phase'], string> = { + planning: 'planning', + queued: 'ready', + building: 'building', + qa: 'in review', + shipped: 'merged', + done: 'done', +}; + +/** "Add it to the ticket": a reply kept under the description's Notes. */ +const withNote = (description: string, note: string): string => { + const body = description.trimEnd(); + + return /^## Notes$/m.test(body) + ? `${body}\n\n${note.trim()}` + : `${body}${body === '' ? '' : '\n\n'}## Notes\n\n${note.trim()}`; +}; + +const assigneeOptions = ( + known: ComputerChoice[], + owner: Lookups['owner'], + current: string | null = null, + /** The ticket's tracker: a computer that hasn't connected it says so. */ + tracker: string | null = null +): MenuOption[] => { + // Assigned to a computer this Lambda doesn't know (another Lambda's, or + // one gone): still listed, so the tick shows where it is. + const computers = + current === null || + current === owner.name || + known.some((computer) => computer.name === current) + ? known + : [...known, { name: current, state: 'unknown', hasProject: false }]; + + return assigneeOptionsOf(computers, owner, tracker); +}; + +const TRACKER_NAMES: Record<string, string> = { + linear: 'Linear', + jira: 'Jira', +}; + +const assigneeOptionsOf = ( + computers: ComputerChoice[], + owner: Lookups['owner'], + tracker: string | null = null +): MenuOption[] => [ + { + value: UNASSIGNED, + label: 'Unassigned', + hint: 'Any computer can take it', + icon: <ComputerIcon assigned={false} />, + }, + { + value: owner.name, + label: owner.name, + hint: 'Yours, by hand: no computer builds it', + icon: ( + <span className="tk-avatar" aria-hidden="true"> + {owner.initials} + </span> + ), + }, + ...computers.map((computer) => ({ + value: computer.name, + label: computer.name, + // Lambda knows who it is in every tracker it has connected; one that + // hasn't connected this ticket's can only be assigned by lambda.json. + ...(tracker !== null && + TRACKER_NAMES[tracker] !== undefined && + computer.trackers !== undefined && + !computer.trackers.includes(tracker) + ? { hint: `Connect ${TRACKER_NAMES[tracker] ?? ''} on it to assign it` } + : {}), + ...(computer.state === 'unknown' ? {} : { trail: computer.state }), + icon: ( + <span + className={`state-dot state-dot--${computer.state}`} + aria-hidden="true" + /> + ), + })), +]; + +/** Epics a ticket can sit under: the project's top-level tickets. */ +const epicOptions = ( + items: TicketListItem[], + projectId: string, + except: string | null +): MenuOption[] => [ + { value: UNASSIGNED, label: 'No epic' }, + ...items + .filter( + (item) => + item.projectId === projectId && + item.parent === null && + item.id !== except && + item.source !== 'pod' + ) + .map((item) => ({ + value: item.id, + label: item.title, + hint: item.id, + icon: <StatusIcon status={item.status} label={false} />, + })), +]; + +const labelsOf = (items: TicketListItem[], projectId: string): string[] => + [ + ...new Set( + items + .filter((item) => item.projectId === projectId) + .flatMap((item) => item.labels) + ), + ].sort(); + +/** A tracker project, by name, from the codebase it's in (or any). */ +const trackerProjectNamed = ( + lookups: Lookups, + codebaseIds: string[], + name: string +): TrackerProject | undefined => + codebaseIds + .flatMap((codebaseId) => lookups.projectsOf(codebaseId)) + .find((project) => project.name === name); + +/** The Project choices: the trackers' projects, and any a ticket names. */ +const projectOptions = ( + lookups: Lookups, + codebaseIds: string[], + withNone: boolean +): MenuOption[] => { + const tracked = codebaseIds.flatMap((codebaseId) => + lookups.projectsOf(codebaseId) + ); + const names = [ + ...new Set([ + ...tracked.map((project) => project.name), + ...lookups.items + .filter((item) => codebaseIds.includes(item.projectId)) + .flatMap((item) => + (item.project ?? '') === '' ? [] : [item.project ?? ''] + ), + ]), + ]; + + return [ + ...(withNone + ? [ + { + value: UNASSIGNED, + label: 'No project', + icon: <ProjectIcon icon={null} color="var(--grey-500)" />, + }, + ] + : []), + ...names.map((name) => { + const project = tracked.find((one) => one.name === name); + + return { + value: name, + label: name, + icon: <ProjectIcon icon={project?.icon} color={project?.color} />, + }; + }), + ]; +}; + +const Chip = ({ children }: { children: React.ReactNode }) => ( + <span className="tk-chip">{children}</span> +); + +// ── Filter and Display ────────────────────────────────────────────────────── + +const valueOptions = ( + field: FilterField, + lookups: Lookups, + projects: Array<{ projectId: string; name: string }> +): MenuOption[] => { + const { items } = lookups; + + switch (field) { + case 'status': + return ALL_STATUSES.map((status) => ({ + value: status, + label: STATUSES[status].name, + icon: <StatusIcon status={status} label={false} />, + })); + case 'priority': + return priorityOptions(); + case 'assignee': + return assigneeOptions( + [ + ...new Map( + projects + .flatMap((project) => lookups.computersFor(project.projectId)) + .map((computer) => [computer.name, computer]) + ).values(), + ].sort((a, b) => a.name.localeCompare(b.name)), + lookups.owner + ); + case 'codebase': + return projects.map((project) => ({ + value: project.projectId, + label: project.name, + })); + case 'project': + return projectOptions( + lookups, + projects.map((project) => project.projectId), + true + ); + case 'epic': + return items + .filter((item) => items.some((other) => other.parent === item.id)) + .map((item) => ({ value: item.id, label: item.title, hint: item.id })); + default: + return [...new Set(items.flatMap((item) => item.labels))] + .sort() + .map((label) => ({ value: label, label })); + } +}; + +const chipText = (chip: FilterChip, lookups: Lookups): string => { + switch (chip.field) { + case 'status': + return isStatusName(chip.value) ? STATUSES[chip.value].name : chip.value; + case 'priority': + return isPriorityName(chip.value) + ? PRIORITY_NAMES[chip.value] + : chip.value; + case 'assignee': + return chip.value === UNASSIGNED ? 'Unassigned' : chip.value; + case 'codebase': + return lookups.projectName(chip.value); + case 'project': + return chip.value === '' ? 'No project' : chip.value; + case 'epic': + return ( + lookups.items.find((item) => item.id === chip.value)?.title ?? + chip.value + ); + default: + return chip.value; + } +}; + +const FilterMenu = ({ + lookups, + projects, + chips, + onAdd, + onClose, +}: { + lookups: Lookups; + projects: Array<{ projectId: string; name: string }>; + chips: FilterChip[]; + onAdd: (chip: FilterChip) => void; + onClose: () => void; +}) => { + const [field, setField] = useState<FilterField | null>(null); + + if (field === null) { + return ( + <TicketMenu + label="Filter" + placeholder="Filter…" + options={FILTER_FIELDS.map((one) => ({ + value: one, + label: FILTER_NAMES[one], + }))} + selected={[]} + onPick={(value) => setField(isField(value) ? value : null)} + onClose={onClose} + /> + ); + } + + return ( + <TicketMenu + label={FILTER_NAMES[field]} + placeholder={`${FILTER_NAMES[field]}…`} + options={valueOptions(field, lookups, projects)} + selected={chips + .filter((chip) => chip.field === field) + .map((chip) => chip.value)} + onPick={(value) => { + onAdd({ field, value }); + onClose(); + }} + onClose={onClose} + /> + ); +}; + +// ── the list ──────────────────────────────────────────────────────────────── + +const Row = ({ + row, + lookups, + at, + dropping, + draggable, + showProject, + now, + onOpen, + onFocus, + onDragStart, + onDragOver, + onDrop, +}: { + row: BoardRow; + lookups: Lookups; + at: boolean; + dropping: boolean; + draggable: boolean; + /** Its project's chip: not on that project's own page. */ + showProject: boolean; + now: number; + onOpen: () => void; + onFocus: () => void; + onDragStart: () => void; + onDragOver: () => void; + onDrop: () => void; +}) => { + const { item, depth, progress } = row; + const assignee = assigneeOf(item); + const trackerProject = + item.project === undefined || item.project === null + ? undefined + : trackerProjectNamed(lookups, [item.projectId], item.project); + const epic = + item.parent === null || depth === 1 + ? null + : titleOf(lookups.items, item.projectId, item.parent); + + return ( + <button + type="button" + className={`tk-row${at ? ' tk-row--at' : ''}${ + depth === 1 ? ' tk-row--child' : '' + }${dropping ? ' tk-row--drop' : ''}`} + data-ticket={ticketKey(item.projectId, item.id)} + draggable={draggable} + onDragStart={(event) => { + event.dataTransfer.effectAllowed = 'move'; + onDragStart(); + }} + onDragOver={(event) => { + event.preventDefault(); + onDragOver(); + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(); + }} + onFocus={onFocus} + onClick={onOpen} + > + <span className="tk-row__priority"> + <PriorityIcon priority={priorityOf(item)} /> + </span> + <span className="tk-row__id">{item.id}</span> + <span className="tk-row__status" title={statusName(item)}> + <StatusIcon status={item.status} /> + </span> + <span className="tk-row__title"> + <span className="tk-row__text">{item.title}</span> + {(item.trackerStatus ?? null) !== null && ( + <span + className="tk-chip tk-chip--state" + title={`${ + item.trackerStatus ?? '' + } in the tracker: Lambda reads it as ${STATUSES[item.status].name}`} + > + {item.trackerStatus} + </span> + )} + {item.status === 'blocked' && (item.blockReason ?? '') !== '' && ( + <span className="tk-row__reason"> · {item.blockReason}</span> + )} + {progress !== null && ( + <span className="tk-chip tk-chip--progress"> + <ProgressRing done={progress.done} total={progress.total} /> + {progress.done}/{progress.total} + </span> + )} + </span> + {epic !== null && ( + <span className="tk-chip tk-chip--epic" title={epic}> + {epic} + </span> + )} + {showProject && (item.project ?? '') !== '' && ( + <span className="tk-chip tk-chip--project" title={item.project ?? ''}> + <ProjectIcon + icon={trackerProject?.icon} + color={trackerProject?.color} + size={12} + /> + <span className="tk-chip__text">{item.project}</span> + </span> + )} + {item.labels.map((label) => ( + <span key={label} className="tk-chip tk-chip--label"> + <span + className="tk-chip__dot" + style={{ background: labelColour(label) }} + aria-hidden="true" + /> + {label} + </span> + ))} + {item.status === 'done' && (item.commit ?? null) !== null && ( + <span className="tk-row__commit">{item.commit}</span> + )} + <span + className={`tk-row__assignee${ + assignee === null ? ' tk-row__assignee--none' : '' + }`} + title={assignee ?? 'Unassigned'} + > + <AssigneeIcon assignee={assignee} owner={lookups.owner} /> + <span>{assignee ?? 'Unassigned'}</span> + </span> + <span className="tk-row__date"> + {(item.updatedAt ?? null) === null + ? '' + : shortDate(item.updatedAt ?? 0, now)} + </span> + </button> + ); +}; + +const ListView = ({ + data, + lookups, + view, + onView, + search, + onSearch, + onOpen, + onNew, + onMove, + title, + titleEnd, + headEnd, + fixedChips, + saveBar, + aside, + showProject, +}: { + data: TicketsResponse; + lookups: Lookups; + view: ViewSettings; + onView: (view: ViewSettings) => void; + search: string; + onSearch: (search: string) => void; + onOpen: (ticket: OpenTicket) => void; + onNew: (status?: TicketState) => void; + onMove: (item: TicketListItem, after: TicketListItem) => void; + /** The header's left: "Tickets", or a view's or project's breadcrumb. */ + title: ReactNode; + /** After the count: a view's or project's star. */ + titleEnd: ReactNode; + /** After Display: the panel's toggle. */ + headEnd: ReactNode; + /** A project page's own filter: applied, never shown as a chip. */ + fixedChips: FilterChip[]; + /** Save / Update view, at the end of the filters row. */ + saveBar: ReactNode; + /** A view's or project's panel on the right. */ + aside: ReactNode; + showProject: boolean; +}) => { + const [at, setAt] = useState(0); + const [dragging, setDragging] = useState<TicketListItem | null>(null); + const [over, setOver] = useState<string | null>(null); + const searchBox = useRef<HTMLInputElement>(null); + const list = useRef<HTMLDivElement>(null); + const now = Date.now(); + const projects = data.projects.map((project) => ({ + projectId: project.projectId, + name: project.name, + })); + const groups = boardGroups(lookups.items, { + groupBy: view.groupBy, + chips: [...fixedChips, ...view.chips], + search, + shippingTogether: openGroupKeysByProject(data.groupEvents), + projectName: lookups.projectName, + }); + const folded = new Set(view.folded); + const rows = visibleRows(groups, folded); + const count = groups.reduce((sum, group) => sum + group.rows.length, 0); + const offline = data.projects.flatMap((project) => + 'offline' in project ? [project] : [] + ); + // Order is queue order, so only the status view, unsearched, reorders. + const reorderable = view.groupBy === 'status' && search === ''; + + // j / k move, Enter opens, / searches, c makes a ticket. + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (typing(event.target) || event.metaKey || event.ctrlKey) { + return; + } + + if (event.key === 'j' || event.key === 'ArrowDown') { + event.preventDefault(); + setAt((now) => Math.min(now + 1, rows.length - 1)); + } + + if (event.key === 'k' || event.key === 'ArrowUp') { + event.preventDefault(); + setAt((now) => Math.max(now - 1, 0)); + } + + if (event.key === 'Enter') { + const row = rows[at]; + + if (row !== undefined) { + event.preventDefault(); + onOpen({ projectId: row.item.projectId, ticketId: row.item.id }); + } + } + + if (event.key === '/') { + event.preventDefault(); + searchBox.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, [at, rows, onOpen]); + + // The row j and k reach stays in view. + useEffect(() => { + const row = rows[at]; + + if (row === undefined) { + return; + } + + list.current + ?.querySelector( + `[data-ticket="${CSS.escape( + ticketKey(row.item.projectId, row.item.id) + )}"]` + ) + ?.scrollIntoView({ block: 'nearest' }); + }, [at, rows]); + + const rowIndex = (item: TicketListItem): number => + rows.findIndex( + (row) => row.item.projectId === item.projectId && row.item.id === item.id + ); + + return ( + <div + className={`tickets-wrap${aside === null ? '' : ' tickets-wrap--aside'}`} + > + <section className="tickets"> + <header className="tickets__head"> + <h1 className="tickets__title"> + {title} <span className="tickets__count">{count}</span> + {titleEnd} + </h1> + <MenuButton + className="tk-button" + label="Filter" + menu={(close) => ( + <FilterMenu + lookups={lookups} + projects={projects} + chips={view.chips} + onAdd={(chip) => + onView({ + ...view, + chips: view.chips.some( + (one) => + one.field === chip.field && one.value === chip.value + ) + ? view.chips + : [...view.chips, chip], + }) + } + onClose={close} + /> + )} + > + <LineIcon name="filter" /> + Filter + </MenuButton> + <MenuButton + className="tk-button" + label="Display" + menu={(close) => ( + <TicketMenu + label="Group by" + placeholder="Group by…" + align="right" + options={GROUPINGS.map((grouping) => ({ + value: grouping, + label: `Group by ${GROUPING_NAMES[grouping].toLowerCase()}`, + }))} + selected={[view.groupBy]} + onPick={(value) => { + onView({ + ...view, + groupBy: isGrouping(value) ? value : 'status', + }); + close(); + }} + onClose={close} + /> + )} + > + <LineIcon name="display" /> + Display + </MenuButton> + {headEnd} + <button + type="button" + className="tk-button tk-button--primary" + title="New ticket (C)" + onClick={() => onNew()} + > + <LineIcon name="plus" /> + New ticket + </button> + </header> + + <div className="tickets__filters"> + {view.chips.map((chip) => ( + <span + key={`${chip.field}:${chip.value}`} + className="tk-filter-chip" + > + <span className="tk-filter-chip__field"> + {FILTER_NAMES[chip.field]} + </span>{' '} + is <strong>{chipText(chip, lookups)}</strong> + <button + type="button" + aria-label={`Remove the ${FILTER_NAMES[ + chip.field + ].toLowerCase()} filter`} + onClick={() => + onView({ + ...view, + chips: view.chips.filter((one) => one !== chip), + }) + } + > + <LineIcon name="close" size={12} /> + </button> + </span> + ))} + <MenuButton + className="tk-add-filter" + label="Add filter" + menu={(close) => ( + <FilterMenu + lookups={lookups} + projects={projects} + chips={view.chips} + onAdd={(chip) => + onView({ ...view, chips: [...view.chips, chip] }) + } + onClose={close} + /> + )} + > + + Add filter + </MenuButton> + <label className="tickets__search"> + <LineIcon name="search" size={13} /> + <input + ref={searchBox} + type="search" + placeholder="Search" + aria-label="Search tickets" + value={search} + onChange={(event) => onSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + onSearch(''); + event.currentTarget.blur(); + } + }} + /> + </label> + {saveBar} + </div> + + {offline.map((project) => ( + <p key={project.projectId} className="tickets__offline muted"> + {project.name}: {project.offline} + </p> + ))} + + <div className="tickets__list" ref={list}> + {groups.length === 0 && ( + <p className="tickets__empty muted"> + {lookups.items.length === 0 + ? 'No tickets yet. Make one with New ticket.' + : 'No tickets match.'} + </p> + )} + {groups.map((group) => { + const isFolded = folded.has(group.key); + + return ( + <div key={group.key} className="tk-group"> + <div className="tk-group__head"> + <button + type="button" + className="tk-group__toggle" + aria-expanded={!isFolded} + onClick={() => + onView({ + ...view, + folded: isFolded + ? view.folded.filter((key) => key !== group.key) + : [...view.folded, group.key], + }) + } + > + <svg + className={`tk-group__chevron${ + isFolded ? ' tk-group__chevron--folded' : '' + }`} + width="10" + height="10" + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + > + <path d="M5 8h14l-7 9z" /> + </svg> + {group.status !== null && ( + <StatusIcon status={group.status} label={false} /> + )} + <span className="tk-group__name">{group.label}</span> + <span className="tk-group__count">{group.rows.length}</span> + </button> + {group.status !== null && + !STATUSES[group.status].setByBuild && ( + <button + type="button" + className="tk-group__add" + aria-label={`New ${STATUSES[ + group.status + ].name.toLowerCase()} ticket`} + onClick={() => onNew(group.status ?? undefined)} + > + <LineIcon name="plus" /> + </button> + )} + </div> + {!isFolded && + group.rows.map((row) => ( + <Row + key={ticketKey(row.item.projectId, row.item.id)} + row={row} + lookups={lookups} + at={rows[at] === row} + now={now} + draggable={reorderable && row.item.source !== 'pod'} + showProject={showProject} + dropping={ + dragging !== null && + over === ticketKey(row.item.projectId, row.item.id) + } + onOpen={() => + onOpen({ + projectId: row.item.projectId, + ticketId: row.item.id, + }) + } + onFocus={() => setAt(rowIndex(row.item))} + onDragStart={() => setDragging(row.item)} + onDragOver={() => + setOver(ticketKey(row.item.projectId, row.item.id)) + } + onDrop={() => { + if (dragging !== null && dragging !== row.item) { + onMove(dragging, row.item); + } + + setDragging(null); + setOver(null); + }} + /> + ))} + </div> + ); + })} + </div> + + <p className="tickets__keys"> + <kbd>j</kbd> <kbd>k</kbd> move · <kbd>↵</kbd> open · <kbd>/</kbd>{' '} + search · <kbd>c</kbd> new ticket + </p> + </section> + {aside} + </div> + ); +}; + +// ── a ticket's page ───────────────────────────────────────────────────────── + +const PropertyRow = ({ + heading, + children, +}: { + heading: string; + children: React.ReactNode; +}) => ( + <div className="tk-prop"> + <span className="tk-prop__heading">{heading}</span> + <div className="tk-prop__value">{children}</div> + </div> +); + +const TicketView = ({ + open, + lookups, + order, + tick, + buildingOn, + onBack, + onOpen, + onOpenPod, + onChange, + lambda, + onChat, + onPlan, + starred, + onStar, + backLabel, +}: { + open: OpenTicket; + lookups: Lookups; + /** The list's tickets in order, for previous and next. */ + order: OpenTicket[]; + tick: number; + /** The computer building a ticket now, if any. */ + buildingOn: (projectId: string, ticketId: string) => string | null; + onBack: () => void; + onOpen: (ticket: OpenTicket) => void; + onOpenPod: (podId: string) => void; + onChange: (item: TicketListItem, changes: TicketChanges) => Promise<void>; + /** The Lambda it's part of, if any. */ + lambda: Lambda | undefined; + /** "Chat with Lambda": its Lambda, or a new planning chat about it. */ + onChat: () => void; + /** "Plan with Lambda" under a reply. */ + onPlan: () => void; + /** It's in the sidebar's Favorites. */ + starred: boolean; + onStar: () => void; + /** Where Back goes: "Tickets", or the view or project it was opened from. */ + backLabel: string; +}) => { + const [page, setPage] = useState<TicketPage | null>(null); + const [error, setError] = useState<string | null>(null); + const [actionError, setActionError] = useState<string | null>(null); + const [title, setTitle] = useState(''); + const [comment, setComment] = useState(''); + const [sending, setSending] = useState(false); + const [copied, setCopied] = useState(false); + // A build blocked on the owner: its question, from its computer's pod. + const [blockDetail, setBlockDetail] = useState<BuildingPodDetail | null>( + null + ); + const [choice, setChoice] = useState<number | null>(null); + const [words, setWords] = useState(''); + const route = askRouteOf(lambda, open.ticketId); + const blockPod = route.to === 'block' ? route.podId : null; + const blockMessage = route.to === 'block' ? route.messageId : null; + + useEffect(() => { + if (blockPod === null) { + setBlockDetail(null); + + return; + } + + api + .building(blockPod) + .then(setBlockDetail) + .catch(() => setBlockDetail(null)); + }, [blockPod, tick]); + const load = useMemo( + () => latestLoader<TicketPage>({ value: setPage, error: setError }), + [] + ); + const key = `${open.projectId}\u0000${open.ticketId}`; + const loadedFor = useRef<{ key: string; at: number } | null>(null); + + useEffect(() => { + const opening = loadedFor.current?.key !== key; + const now = Date.now(); + + if (!opening && !ticketsRefreshDue(loadedFor.current?.at ?? null, now)) { + return; + } + + if (opening) { + setPage(null); + } + + void load(api.ticketPage(open.projectId, open.ticketId, opening)); + loadedFor.current = { key, at: now }; + }, [load, key, open.projectId, open.ticketId, tick]); + + const reread = useCallback(() => { + loadedFor.current = { key, at: Date.now() }; + void load(api.ticketPage(open.projectId, open.ticketId)); + }, [load, key, open.projectId, open.ticketId]); + + useEffect(() => setTitle(page?.ticket.title ?? ''), [page?.ticket.title]); + + const index = order.findIndex( + (one) => one.projectId === open.projectId && one.ticketId === open.ticketId + ); + const step = (by: number): void => { + const next = order[index + by]; + + if (next !== undefined) { + onOpen(next); + } + }; + + // Esc goes back; j and k step through the list. + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (typing(event.target) || event.metaKey || event.ctrlKey) { + return; + } + + if (event.key === 'Escape') { + onBack(); + } + + if (event.key === 'j') { + step(1); + } + + if (event.key === 'k') { + step(-1); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }); + + const item: TicketListItem | null = + lookups.items.find( + (one) => one.projectId === open.projectId && one.id === open.ticketId + ) ?? (page === null ? null : page.ticket); + const editable = + item !== null && item.source !== 'pod' && item.readOnly !== true; + const change = (changes: TicketChanges): void => { + if (item === null) { + return; + } + + const real = realChanges(item, page?.ticket.description ?? null, changes); + + if (real === null) { + return; + } + + setActionError(null); + onChange(item, real) + .then(reread) + .catch((caught: unknown) => setActionError(errorText(caught))); + }; + const computer = + item?.status === 'building' + ? buildingOn(open.projectId, open.ticketId) ?? assigneeOf(item) + : null; + // Edits and the build as lines; the conversation as comments, by time. + const activity: Array< + | { kind: 'line'; at: number; who: string; text: string } + | { kind: 'comment'; at: number; who: string; text: string; podId: string } + > = + page === null + ? [] + : [ + ...ticketActivity(page.events, stepDisplayName, formatMinutes).map( + (entry) => ({ kind: 'line' as const, ...entry }) + ), + ...(page.talk ?? []).map((message) => ({ + kind: 'comment' as const, + at: Date.parse(message.at), + who: TALKERS[message.from], + text: message.text, + podId: message.podId, + })), + ].sort((a, b) => a.at - b.at); + const now = Date.now(); + const epic = + item === null || item.parent === null + ? null + : lookups.items.find( + (one) => one.projectId === item.projectId && one.id === item.parent + ) ?? null; + const epicChildren = + epic === null + ? [] + : lookups.items.filter( + (one) => + one.projectId === epic.projectId && + one.parent === epic.id && + one.status !== 'canceled' + ); + const link = linkOf(item?.url ?? null); + const tracker = item === null ? null : sourceLabel(item.source); + const labels = item === null ? [] : labelsOf(lookups.items, item.projectId); + const itemProject = + item === null || (item.project ?? '') === '' + ? undefined + : trackerProjectNamed(lookups, [item.projectId], item.project ?? ''); + + const talk = (): void => { + const said = talkToSend(comment, sending); + + if (said === null) { + return; + } + + setSending(true); + setActionError(null); + // While it's blocked, words in the box answer the block. + (blockPod !== null && blockMessage !== null + ? api.chat(blockPod, said, blockMessage) + : api.talkTicket(open.projectId, open.ticketId, said) + ) + .then(() => { + setComment(''); + reread(); + }) + .catch((caught: unknown) => setActionError(errorText(caught))) + .finally(() => setSending(false)); + }; + + const blocker = blockDetail?.building.runs.find( + (run) => run.blocked?.messageId === blockMessage + )?.blocked; + const where = lambda?.tickets.find((one) => one.id === open.ticketId)?.where; + const blockedOn = where?.kind === 'blocked' ? where.computer : null; + const answerBlock = (): void => { + const text = blockerAnswer(blocker, choice, words); + + if (text === '' || blockPod === null || blockMessage === null) { + return; + } + + setSending(true); + setActionError(null); + api + .chat(blockPod, text, blockMessage) + .then(() => { + setWords(''); + setChoice(null); + reread(); + }) + .catch((caught: unknown) => setActionError(errorText(caught))) + .finally(() => setSending(false)); + }; + const last = activity.at(-1); + const lastReply = + last?.kind === 'comment' && last.who !== TALKERS.you ? last : null; + const lastAsked = + last?.kind === 'comment' && last.who === TALKERS.you ? last : null; + + return ( + <section className="ticket"> + <header className="ticket__head"> + <nav className="ticket__crumbs" aria-label="Breadcrumb"> + <button type="button" className="ticket__crumb" onClick={onBack}> + {backLabel} + </button> + <span aria-hidden="true">›</span> + {epic !== null && ( + <> + <button + type="button" + className="ticket__crumb" + onClick={() => + onOpen({ projectId: epic.projectId, ticketId: epic.id }) + } + > + {epic.title} + </button> + <span aria-hidden="true">›</span> + </> + )} + <span className="ticket__crumb ticket__crumb--here"> + {open.ticketId} {item?.title ?? ''} + </span> + <StarButton on={starred} onToggle={onStar} what="ticket" /> + </nav> + {item?.readOnly !== true && ( + <button type="button" className="tk-chat-button" onClick={onChat}> + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + aria-hidden="true" + > + <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /> + </svg> + Chat with Lambda + </button> + )} + {index !== -1 && ( + <span className="ticket__position"> + {index + 1} / {order.length} + </span> + )} + <button + type="button" + className="tk-icon-button" + aria-label="Next ticket (J)" + title="Next ticket (J)" + disabled={index === -1 || index >= order.length - 1} + onClick={() => step(1)} + > + <LineIcon name="down" /> + </button> + <button + type="button" + className="tk-icon-button" + aria-label="Previous ticket (K)" + title="Previous ticket (K)" + disabled={index <= 0} + onClick={() => step(-1)} + > + <LineIcon name="up" /> + </button> + <button + type="button" + className="tk-icon-button" + aria-label={copied ? 'Link copied' : 'Copy link'} + title={copied ? 'Copied' : 'Copy link'} + onClick={() => { + navigator.clipboard + .writeText(window.location.href) + .then(() => setCopied(true)) + .catch(() => undefined); + }} + > + <LineIcon name={copied ? 'check' : 'link'} /> + </button> + </header> + + {error !== null && <p className="error">{error}</p>} + {actionError !== null && ( + <button + type="button" + className="error" + onClick={() => setActionError(null)} + > + {actionError} (dismiss) + </button> + )} + + {item === null ? ( + error === null && <p className="muted ticket__loading">Loading…</p> + ) : ( + <div className="ticket__body"> + <article className="ticket__main"> + {route.to === 'block' ? ( + <div + className="ticket__notice ticket__notice--blocked" + role="status" + > + <span className="ticket__notice-dot" aria-hidden="true" /> + <span> + Blocked: {blockedOn ?? 'the build'} is waiting on you. Answer + below. + </span> + </div> + ) : lambda?.phase === 'planning' ? ( + <div + className="ticket__notice ticket__notice--planning" + role="status" + > + <StatusIcon status="planning" label={false} /> + <span className="ticket__notice-text"> + Being planned in <strong>{lambda.title}</strong>. + </span> + {lambda.planningPodId !== null && ( + <button + type="button" + className="link-button" + onClick={() => + lambda.planningPodId !== null && + onOpenPod(lambda.planningPodId) + } + > + Open the planning chat + </button> + )} + </div> + ) : ( + computer !== null && ( + <div className="ticket__notice" role="status"> + <StatusIcon status="building" label={false} /> + <span> + Being built on {computer}. Changes you make reach it at its + next check. + </span> + </div> + ) + )} + {editable ? ( + <input + className="ticket__title" + aria-label="Title" + value={title} + onChange={(event) => setTitle(event.target.value)} + onBlur={() => { + if (title.trim() !== '' && title.trim() !== item.title) { + change({ title: title.trim() }); + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + + if (event.key === 'Escape') { + setTitle(item.title); + event.currentTarget.blur(); + } + }} + /> + ) : ( + <h1 className="ticket__title">{item.title}</h1> + )} + {epic !== null && ( + <p className="ticket__parent"> + Sub-ticket of + <StatusIcon status={epic.status} /> + <button + type="button" + className="ticket__parent-link" + onClick={() => + onOpen({ projectId: epic.projectId, ticketId: epic.id }) + } + > + {epic.id} {epic.title} + </button> + <span className="tk-chip"> + {epicChildren.filter((one) => one.status === 'done').length}/ + {epicChildren.length} + </span> + </p> + )} + + {page === null ? ( + <p className="muted ticket__description">Loading…</p> + ) : ( + <Suspense + fallback={ + <div className="ticket__description"> + <Markdown text={page.ticket.description} /> + </div> + } + > + <DescriptionEditor + // Another ticket is a new editor: never the last one's, + // which is destroyed as the new ticket's is made. + key={key} + ticketKey={key} + markdown={page.ticket.description} + editable={editable} + onSave={(description) => onChange(item, { description })} + /> + </Suspense> + )} + + <section className="ticket__activity" aria-label="Activity"> + <h2>Activity</h2> + {activity.length === 0 && ( + <p className="muted ticket__quiet">Nothing yet.</p> + )} + {activity.map((entry) => + entry.kind === 'comment' ? ( + <div + key={`${entry.at}:${entry.text}`} + className={`ticket__talk${ + entry.who === TALKERS.you ? ' ticket__talk--you' : '' + }`} + > + <span className="ticket__talk-who"> + {entry.who} · {agoShort(entry.at, now)} + </span> + <Markdown text={entry.text} /> + </div> + ) : ( + <div + key={`${entry.at}:${entry.text}`} + className="ticket__event" + > + <span className="ticket__event-dot" aria-hidden="true" /> + <span> + <strong>{entry.who}</strong> {entry.text} ·{' '} + <span className="muted">{agoShort(entry.at, now)}</span> + </span> + </div> + ) + )} + {lastAsked !== null && route.to === 'planning' && ( + <p className="muted ticket__queued"> + In the planning chat's queue · read at its next turn + </p> + )} + {lastReply !== null && route.to !== 'block' && ( + <div className="ticket__reply-actions"> + {editable && page !== null && ( + <button + type="button" + className="tk-button" + onClick={() => + change({ + description: withNote( + page.ticket.description, + lastReply.text + ), + }) + } + > + Add it to the ticket + </button> + )} + <button type="button" className="tk-button" onClick={onPlan}> + Plan with Lambda + </button> + </div> + )} + {route.to === 'block' && ( + <BlockerCard + blocker={blocker} + computer={blockedOn} + fallback={<p className="muted">Loading the question…</p>} + choice={choice} + words={words} + busy={sending} + onChoice={setChoice} + onWords={setWords} + onAnswer={answerBlock} + onPlanMore={onChat} + /> + )} + {item.readOnly === true ? ( + <p className="muted ticket__quiet"> + Read-only here: this codebase mirrors its tracker, so nothing + is changed or started from this page. + </p> + ) : ( + <div className="ticket__comment"> + <textarea + rows={2} + aria-label="Ask about this ticket" + placeholder={ + route.to === 'block' + ? 'Answer in your own words, or ask something…' + : 'Ask about this ticket…' + } + value={comment} + onChange={(event) => setComment(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && event.metaKey) { + talk(); + } + }} + /> + <span className="ticket__comment-foot"> + <span> + {route.to === 'block' ? ( + 'Goes to Lambda · while it’s blocked, this answers the block' + ) : route.to === 'planning' ? ( + 'Goes to the planning chat, at its next turn' + ) : ( + <> + Goes to Lambda · for anything bigger,{' '} + <button + type="button" + className="link-button" + onClick={onChat} + > + Chat with Lambda + </button> + </> + )} + </span> + <button + type="button" + className="tk-button tk-button--primary" + disabled={sending || comment.trim() === ''} + onClick={talk} + > + Send + </button> + </span> + </div> + )} + </section> + </article> + + <aside className="ticket__props" aria-label="Properties"> + <h2 className="tk-props__title">Properties</h2> + {STATUSES[item.status].setByBuild || !editable ? ( + <div className="tk-prop-line" title="Set by the build"> + <StatusIcon status={item.status} label={false} /> + <span className="tk-prop-line__text">{statusName(item)}</span> + {STATUSES[item.status].setByBuild && ( + <span + className="tk-prop-line__lock" + role="img" + aria-label="Set by the build" + > + <LineIcon name="lock" size={12} /> + </span> + )} + </div> + ) : ( + <MenuButton + className="tk-prop-line" + label={`Status: ${statusName(item)}`} + menu={(close) => ( + <TicketMenu + label="Status" + placeholder="Change status…" + options={statusOptions()} + selected={[item.status]} + onPick={(value) => { + close(); + + if (isStatusName(value)) { + change({ status: value }); + } + }} + onClose={close} + /> + )} + > + <StatusIcon status={item.status} label={false} /> + <span className="tk-prop-line__text">{statusName(item)}</span> + </MenuButton> + )} + + <MenuButton + className="tk-prop-line" + label={`Priority: ${PRIORITY_NAMES[priorityOf(item)]}`} + disabled={!editable} + menu={(close) => ( + <TicketMenu + label="Priority" + placeholder="Set priority…" + options={priorityOptions()} + selected={[priorityOf(item)]} + onPick={(value) => { + close(); + + if (isPriorityName(value)) { + change({ priority: value }); + } + }} + onClose={close} + /> + )} + > + <PriorityIcon priority={priorityOf(item)} label={false} /> + <span className="tk-prop-line__text"> + {priorityOf(item) === 'none' + ? 'Set priority' + : PRIORITY_NAMES[priorityOf(item)]} + </span> + </MenuButton> + + <MenuButton + className="tk-prop-line" + label={`Assignee: ${assigneeOf(item) ?? 'Unassigned'}`} + disabled={!editable} + menu={(close) => ( + <TicketMenu + label="Assignee" + placeholder="Assign to…" + options={assigneeOptions( + lookups.computersFor(item.projectId), + lookups.owner, + assigneeOf(item), + item.source + )} + selected={[assigneeOf(item) ?? UNASSIGNED]} + onPick={(value) => { + close(); + change({ assignee: value === UNASSIGNED ? null : value }); + }} + onClose={close} + /> + )} + > + <AssigneeIcon assignee={assigneeOf(item)} owner={lookups.owner} /> + <span className="tk-prop-line__text"> + {assigneeOf(item) ?? 'Unassigned'} + </span> + </MenuButton> + + <PropertyRow heading="Labels"> + <div className="tk-labels"> + {item.labels.map((label) => ( + <span key={label} className="tk-chip tk-chip--label"> + <span + className="tk-chip__dot" + style={{ background: labelColour(label) }} + aria-hidden="true" + /> + {label} + {editable && ( + <button + type="button" + className="tk-chip__remove" + aria-label={`Remove the ${label} label`} + onClick={() => change({ removeLabels: [label] })} + > + <LineIcon name="close" size={10} /> + </button> + )} + </span> + ))} + {editable && ( + <MenuButton + className="tk-chip tk-chip--add" + label="Add label" + menu={(close) => ( + <TicketMenu + label="Labels" + placeholder="Add label…" + options={labels + .filter((label) => !item.labels.includes(label)) + .map((label) => ({ value: label, label }))} + selected={[]} + onPick={(value) => { + close(); + change({ labels: [value] }); + }} + onCreate={(text) => { + close(); + change({ labels: [text] }); + }} + onClose={close} + /> + )} + > + + + </MenuButton> + )} + </div> + </PropertyRow> + + <PropertyRow heading="Project"> + <MenuButton + className="tk-prop-line tk-prop-line--inline" + label={`Project: ${item.project ?? 'No project'}`} + disabled={!editable} + menu={(close) => ( + <TicketMenu + label="Project" + placeholder="Move to project…" + options={projectOptions(lookups, [item.projectId], true)} + selected={[item.project ?? UNASSIGNED]} + onPick={(value) => { + close(); + change({ project: value === UNASSIGNED ? null : value }); + }} + onClose={close} + /> + )} + > + {(item.project ?? '') !== '' && ( + <ProjectIcon + icon={itemProject?.icon} + color={itemProject?.color} + /> + )} + <span className="tk-prop-line__text"> + {(item.project ?? '') === '' ? 'No project' : item.project} + </span> + </MenuButton> + </PropertyRow> + + <PropertyRow heading="Lambda"> + {item.readOnly === true && lambda === undefined ? ( + <span className="muted">Not on this computer</span> + ) : lambda === undefined ? ( + <span className="muted"> + None yet. Chat with Lambda starts one. + </span> + ) : ( + <button + type="button" + className="tk-prop-line tk-prop-line--inline" + onClick={onChat} + > + <span className="tk-prop-line__text"> + {lambda.title}{' '} + <span + className={ + route.to === 'block' ? 'ticket__blocked-word' : 'muted' + } + > + ·{' '} + {route.to === 'block' + ? 'blocked' + : PHASE_WORDS[lambda.phase]} + </span> + </span> + </button> + )} + </PropertyRow> + + <PropertyRow heading="Codebase"> + {lookups.projectName(item.projectId)} + </PropertyRow> + + <PropertyRow heading="Epic"> + <MenuButton + className="tk-prop-line tk-prop-line--inline" + label={`Epic: ${epic?.title ?? 'No epic'}`} + disabled={!editable} + menu={(close) => ( + <TicketMenu + label="Epic" + placeholder="Move to epic…" + options={epicOptions( + lookups.items, + item.projectId, + item.id + )} + selected={[item.parent ?? UNASSIGNED]} + onPick={(value) => { + close(); + change({ parent: value === UNASSIGNED ? null : value }); + }} + onClose={close} + /> + )} + > + <span className="tk-prop-line__text"> + {epic === null ? 'No epic' : epic.title} + </span> + </MenuButton> + </PropertyRow> + + <PropertyRow heading="Source"> + <div className="tk-source"> + {link !== null && tracker !== null ? ( + <a href={link} target="_blank" rel="noreferrer"> + Open in {tracker} ↗ + </a> + ) : item.source === 'markdown' ? ( + <code>tickets/{item.id}.md</code> + ) : ( + <span>Planned in a pod, not written yet</span> + )} + <span className="muted"> + {item.status === 'done' + ? (item.commit ?? null) === null + ? 'Commit: not found' + : `Commit: ${item.commit ?? ''}` + : 'Commit: not shipped yet'} + </span> + </div> + </PropertyRow> + </aside> + </div> + )} + </section> + ); +}; + +// ── New ticket ────────────────────────────────────────────────────────────── + +const NewTicketDialog = ({ + projects, + lookups, + startProject, + startTrackerProject, + startStatus, + onClose, + onCreated, + onPlan, +}: { + projects: Array<{ projectId: string; name: string }>; + lookups: Lookups; + startProject: string; + /** On a project's page: that project. */ + startTrackerProject: string | null; + startStatus: TicketState; + onClose: () => void; + onCreated: (item: TicketListItem) => void; + onPlan: (projectId: string, text: string) => void; +}) => { + const [projectId, setProjectId] = useState(startProject); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [status, setStatus] = useState<TicketState>(startStatus); + const [priority, setPriority] = useState<Priority>('none'); + const [assignee, setAssignee] = useState<string | null>(null); + const [labels, setLabels] = useState<string[]>([]); + const [parent, setParent] = useState<string | null>(null); + const [trackerProject, setTrackerProject] = useState<string | null>( + startTrackerProject + ); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState<string | null>(null); + const project = projects.find((one) => one.projectId === projectId); + const parentItem = + parent === null + ? null + : lookups.items.find( + (one) => one.projectId === projectId && one.id === parent + ) ?? null; + + const create = (): void => { + if (title.trim() === '' || busy) { + return; + } + + const ticket: NewTicket = { + title: title.trim(), + ...(description.trim() === '' ? {} : { description }), + status, + priority, + assignee, + ...(labels.length === 0 ? {} : { labels }), + ...(parent === null ? {} : { parent }), + ...(trackerProject === null ? {} : { project: trackerProject }), + }; + setBusy(true); + setProblem(null); + api + .newTicket(projectId, ticket) + .then(onCreated) + .catch((caught: unknown) => setProblem(errorText(caught))) + .finally(() => setBusy(false)); + }; + + return ( + <div + className="tk-dialog-backdrop" + role="presentation" + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + <div + className="tk-dialog" + role="dialog" + aria-modal="true" + aria-label="New ticket" + onKeyDown={(event) => { + if (event.key === 'Escape') { + onClose(); + } + + if (event.key === 'Enter' && event.metaKey) { + create(); + } + }} + > + <header className="tk-dialog__head"> + <h2>New ticket</h2> + <MenuButton + className="tk-dialog__project" + label={`Codebase: ${project?.name ?? ''}`} + menu={(close) => ( + <TicketMenu + label="Codebase" + placeholder="Codebase…" + align="right" + options={projects.map((one) => ({ + value: one.projectId, + label: one.name, + }))} + selected={[projectId]} + onPick={(value) => { + setProjectId(value); + setParent(null); + setAssignee(null); + setTrackerProject(null); + close(); + }} + onClose={close} + /> + )} + > + {project?.name ?? 'Codebase'} ▾ + </MenuButton> + <button + type="button" + className="tk-icon-button tk-icon-button--bare" + aria-label="Close" + onClick={onClose} + > + <LineIcon name="close" /> + </button> + </header> + <input + className="tk-dialog__title" + aria-label="Ticket title" + placeholder="Ticket title" + autoFocus + value={title} + onChange={(event) => setTitle(event.target.value)} + /> + <textarea + className="tk-dialog__description" + aria-label="Description" + placeholder="Add a description…" + rows={5} + value={description} + onChange={(event) => setDescription(event.target.value)} + /> + <div className="tk-dialog__props"> + <MenuButton + className="tk-pill" + label={`Status: ${STATUSES[status].name}`} + menu={(close) => ( + <TicketMenu + label="Status" + placeholder="Status…" + options={statusOptions()} + selected={[status]} + onPick={(value) => { + if (isStatusName(value)) { + setStatus(value); + } + close(); + }} + onClose={close} + /> + )} + > + <StatusIcon status={status} label={false} /> + {STATUSES[status].name} + </MenuButton> + <MenuButton + className="tk-pill" + label={`Priority: ${PRIORITY_NAMES[priority]}`} + menu={(close) => ( + <TicketMenu + label="Priority" + placeholder="Priority…" + options={priorityOptions()} + selected={[priority]} + onPick={(value) => { + if (isPriorityName(value)) { + setPriority(value); + } + close(); + }} + onClose={close} + /> + )} + > + <PriorityIcon priority={priority} label={false} /> + {priority === 'none' ? 'Priority' : PRIORITY_NAMES[priority]} + </MenuButton> + <MenuButton + className="tk-pill" + label={`Assignee: ${assignee ?? 'Unassigned'}`} + menu={(close) => ( + <TicketMenu + label="Assignee" + placeholder="Assign to…" + options={assigneeOptions( + lookups.computersFor(projectId), + lookups.owner, + null, + lookups.items.find((one) => one.projectId === projectId) + ?.source ?? null + )} + selected={[assignee ?? UNASSIGNED]} + onPick={(value) => { + setAssignee(value === UNASSIGNED ? null : value); + close(); + }} + onClose={close} + /> + )} + > + <AssigneeIcon assignee={assignee} owner={lookups.owner} /> + {assignee ?? 'Unassigned'} + </MenuButton> + <MenuButton + className="tk-pill" + label="Labels" + menu={(close) => ( + <TicketMenu + label="Labels" + placeholder="Add label…" + options={labelsOf(lookups.items, projectId).map((label) => ({ + value: label, + label, + }))} + selected={labels} + onPick={(value) => + setLabels( + labels.includes(value) + ? labels.filter((one) => one !== value) + : [...labels, value] + ) + } + onCreate={(text) => { + setLabels([...labels, text]); + close(); + }} + onClose={close} + /> + )} + > + {labels.length === 0 ? 'Labels' : labels.join(', ')} + </MenuButton> + <MenuButton + className="tk-pill" + label={`Epic: ${parentItem?.title ?? 'none'}`} + menu={(close) => ( + <TicketMenu + label="Epic" + placeholder="Epic…" + options={epicOptions(lookups.items, projectId, null)} + selected={[parent ?? UNASSIGNED]} + onPick={(value) => { + setParent(value === UNASSIGNED ? null : value); + close(); + }} + onClose={close} + /> + )} + > + {parentItem === null ? 'Epic' : parentItem.title} + </MenuButton> + {projectOptions(lookups, [projectId], false).length > 0 && ( + <MenuButton + className="tk-pill" + label={`Project: ${trackerProject ?? 'none'}`} + menu={(close) => ( + <TicketMenu + label="Project" + placeholder="Project…" + options={projectOptions(lookups, [projectId], true)} + selected={[trackerProject ?? UNASSIGNED]} + onPick={(value) => { + setTrackerProject(value === UNASSIGNED ? null : value); + close(); + }} + onClose={close} + /> + )} + > + <ProjectIcon + icon={ + trackerProject === null + ? null + : trackerProjectNamed(lookups, [projectId], trackerProject) + ?.icon + } + color={ + trackerProject === null + ? 'var(--grey-500)' + : trackerProjectNamed(lookups, [projectId], trackerProject) + ?.color + } + /> + {trackerProject ?? 'Project'} + </MenuButton> + )} + </div> + {problem !== null && <p className="tk-dialog__problem">{problem}</p>} + <footer className="tk-dialog__foot"> + <button + type="button" + className="tk-button tk-button--quiet" + onClick={() => + onPlan( + projectId, + [title.trim(), description.trim()] + .filter((part) => part !== '') + .join('\n\n') + ) + } + > + <LineIcon name="sparkle" /> + Plan with Lambda + </button> + <button + type="button" + className="tk-button tk-button--primary" + disabled={title.trim() === '' || busy} + title="Create ticket (⌘↵)" + onClick={create} + > + Create ticket + </button> + </footer> + </div> + </div> + ); +}; + +// ── the screen ────────────────────────────────────────────────────────────── + +/** The whole list, a tracker project's tickets, or a saved view's. */ +export type TicketsScope = + | { kind: 'all' } + | { kind: 'project'; codebaseId: string; name: string } + | { kind: 'view'; viewId: string }; + +const scopeKey = (scope: TicketsScope): string => + scope.kind === 'all' + ? 'all' + : scope.kind === 'project' + ? `project:${scope.codebaseId}:${scope.name}` + : `view:${scope.viewId}`; + +export const TicketsPage = ({ + state, + tick, + open, + onOpen, + onOpenPod, + onOpenLambda, + onPlan, + onChatAbout, + scope, + filter, + onFilter, + workspace, + onOpenProjects, + onOpenViews, + onOpenView, +}: { + state: AppState; + tick: number; + scope: TicketsScope; + /** The address's `?filter=`: a scoped list's unsaved filters. */ + filter: string | undefined; + onFilter: (filter: string | undefined) => void; + workspace: Workspace; + onOpenProjects: () => void; + onOpenViews: () => void; + onOpenView: (viewId: string) => void; + /** The ticket page up, if any. */ + open: OpenTicket | null; + onOpen: (ticket: OpenTicket | null) => void; + onOpenPod: (podId: string) => void; + /** A Lambda: its planning chat while planning, else its page. */ + onOpenLambda: (lambda: Lambda) => void; + /** A new chat about a ticket: the New page, the ticket attached. */ + onChatAbout: ( + projectId: string, + ticket: { id: string; title: string } + ) => void; + /** "Plan with Lambda": a new planning chat, its first message typed in. */ + onPlan: (projectId: string, text: string) => void; +}) => { + const [data, setData] = useState<TicketsResponse | null>(null); + const [error, setError] = useState<string | null>(null); + const [reload, setReload] = useState(0); + const [stored, setStored] = useState(loadView); + const [search, setSearch] = useState(''); + const [creating, setCreating] = useState<{ status: TicketState } | null>( + null + ); + const [made, setMade] = useState<TicketListItem | null>(null); + const [problem, setProblem] = useState<string | null>(null); + const load = useMemo( + () => latestLoader<TicketsResponse>({ value: setData, error: setError }), + [] + ); + // A view's or project's page: its settings from the address, folded + // groups kept while it's up. + const [scopedFolded, setScopedFolded] = useState<string[]>([]); + const [saving, setSaving] = useState(false); + const [panelOpen, setPanelOpen] = useState(loadPanelOpen); + const key = scopeKey(scope); + + useEffect(() => { + setScopedFolded([]); + }, [key]); + + const scopedView = + scope.kind === 'view' + ? workspace.views?.find((one) => one.id === scope.viewId) ?? null + : null; + const base: ListSettings = + scope.kind === 'all' + ? { groupBy: stored.groupBy, chips: stored.chips } + : scopedView === null + ? NO_SETTINGS + : listSettingsOf(scopedView.settings); + const settings: ListSettings = + scope.kind === 'all' ? base : decodeSettings(filter ?? null) ?? base; + const view: ViewSettings = { + ...settings, + folded: scope.kind === 'all' ? stored.folded : scopedFolded, + }; + const setView = (next: ViewSettings): void => { + if (scope.kind === 'all') { + setStored(next); + saveView(next); + + return; + } + + setScopedFolded(next.folded); + const changed = { groupBy: next.groupBy, chips: next.chips }; + onFilter(sameSettings(changed, base) ? undefined : encodeSettings(changed)); + }; + const fixedChips: FilterChip[] = + scope.kind === 'project' + ? [ + { field: 'codebase', value: scope.codebaseId }, + { field: 'project', value: scope.name }, + ] + : []; + + // Every project's sources are asked again on each read: a tick (any app + // event, or the 20s poll) only re-reads once TICKETS_REFRESH_MS has passed. + // A reload (after a change) always does. + const loadedAt = useRef<number | null>(null); + + useEffect(() => { + const now = Date.now(); + + if (!ticketsRefreshDue(loadedAt.current, now)) { + return; + } + + loadedAt.current = now; + void load(api.tickets()); + }, [load, tick]); + + useEffect(() => { + if (reload === 0) { + return; + } + + loadedAt.current = Date.now(); + void load(api.tickets()); + }, [load, reload]); + + // c makes a ticket, wherever on the Tickets screen. + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if ( + event.key === 'c' && + !typing(event.target) && + !event.metaKey && + !event.ctrlKey && + creating === null + ) { + event.preventDefault(); + setCreating({ status: 'inbox' }); + } + }; + window.addEventListener('keydown', onKey); + + return () => window.removeEventListener('keydown', onKey); + }, [creating]); + + const items = data === null ? [] : allTickets(data); + const projects = + data?.projects.map((project) => ({ + projectId: project.projectId, + name: project.name, + })) ?? + (state.lambda?.projects ?? []).map((project) => ({ + projectId: project.id, + name: project.name, + })); + const projectName = (projectId: string): string => + projects.find((project) => project.projectId === projectId)?.name ?? + projectId; + // What each computer's building pod is doing now, by computer name. + const building = state.pods.flatMap((summary) => { + const computer = state.lambda?.podComputers?.[summary.pod.id]; + + return summary.pod.kind === 'building' && + summary.pod.talk === undefined && + summary.status !== 'done' && + computer !== undefined && + summary.building !== undefined + ? [{ computer, state: summary.building.state, summary }] + : []; + }); + const lambdas = useMemo(() => lambdasOf(state), [state]); + const openLambda = + open === null + ? undefined + : lambdaOfTicket(lambdas, open.projectId, open.ticketId); + /** A new chat about the open ticket, the ticket attached. */ + const planTicket = (): void => { + if (open === null) { + return; + } + + onChatAbout(open.projectId, { + id: open.ticketId, + title: + items.find( + (one) => one.projectId === open.projectId && one.id === open.ticketId + )?.title ?? '', + }); + }; + const lookups: Lookups = { + items, + projectName, + computersFor: (projectId) => + computerChoices(state.lambda?.computers ?? [], building, projectId), + owner: state.lambda?.owner ?? { name: 'You', initials: 'Y' }, + projectsOf: workspace.projectsOf, + }; + const order: OpenTicket[] = + data === null + ? [] + : visibleRows( + boardGroups(items, { + groupBy: view.groupBy, + chips: [...fixedChips, ...view.chips], + search, + shippingTogether: openGroupKeysByProject(data.groupEvents), + projectName, + }), + new Set(view.folded) + ).map((row) => ({ + projectId: row.item.projectId, + ticketId: row.item.id, + })); + + /** Shows the change at once, then reads the list again. */ + const change = async ( + item: TicketListItem, + changes: TicketChanges + ): Promise<void> => { + const after = await api.updateTicket(item.projectId, item.id, changes); + setData((now) => + now === null + ? now + : { + ...now, + projects: now.projects.map((project) => + 'tickets' in project && project.projectId === item.projectId + ? { + ...project, + tickets: project.tickets.map((one) => + one.id === item.id ? { ...one, ...after } : one + ), + } + : project + ), + } + ); + setReload((value) => value + 1); + }; + + const startProject = + [...fixedChips, ...view.chips].find((chip) => chip.field === 'codebase') + ?.value ?? + state.lambda?.lastProjectId ?? + projects[0]?.projectId ?? + ''; + + /** The tickets the list shows, for the panel's breakdown. */ + const shown = boardGroups(items, { + groupBy: 'status', + chips: [...fixedChips, ...view.chips], + search: '', + shippingTogether: new Set(), + projectName, + }).flatMap((group) => group.rows.map((row) => row.item)); + const anyProject = (name: string): TrackerProject | undefined => + trackerProjectNamed( + lookups, + projects.map((project) => project.projectId), + name + ); + const dirty = + scope.kind === 'view' + ? scopedView !== null && !sameSettings(settings, base) + : settings.chips.length > 0 || + (scope.kind === 'project' && settings.groupBy !== 'status'); + const togglePanel = (): void => { + storePanelOpen(!panelOpen); + setPanelOpen(!panelOpen); + }; + const panelButton = ( + <button + type="button" + className={`tk-icon-button${panelOpen ? ' tk-icon-button--on' : ''}`} + aria-label={panelOpen ? 'Hide the details' : 'Show the details'} + aria-pressed={panelOpen} + title="Details" + onClick={togglePanel} + > + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + aria-hidden="true" + > + <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" /> + <path d="M14.5 4.5v15" /> + </svg> + </button> + ); + const title: ReactNode = + scope.kind === 'all' ? ( + 'Tickets' + ) : ( + <span className="tickets__crumbs"> + <button + type="button" + className="ticket__crumb" + onClick={scope.kind === 'view' ? onOpenViews : onOpenProjects} + > + {scope.kind === 'view' ? 'Views' : 'Projects'} + </button> + <span aria-hidden="true">›</span> + <span className="tickets__crumb-here"> + {scope.kind === 'view' ? ( + <ViewIcon icon={scopedView?.icon} /> + ) : ( + <ProjectIcon + icon={anyProject(scope.name)?.icon} + color={anyProject(scope.name)?.color} + /> + )} + {scope.kind === 'view' + ? scopedView?.name ?? + (workspace.views === null ? 'Loading…' : 'No such view') + : scope.name} + </span> + </span> + ); + // A view's or project's star, beside its name. + const starred = + scope.kind === 'view' + ? { kind: 'view' as const, ref: scope.viewId } + : scope.kind === 'project' + ? { + kind: 'project' as const, + ref: favoriteRef(scope.codebaseId, scope.name), + } + : null; + const titleEnd: ReactNode = + starred === null ? null : ( + <StarButton + what={starred.kind} + on={workspace.isFavorite(starred.kind, starred.ref)} + onToggle={() => + workspace.setFavorite( + starred.kind, + starred.ref, + !workspace.isFavorite(starred.kind, starred.ref) + ) + } + /> + ); + const saveBar: ReactNode = dirty ? ( + <span className="tickets__save"> + {scope.kind !== 'all' && ( + <button + type="button" + className="tk-button tk-button--quiet" + onClick={() => onFilter(undefined)} + > + {scope.kind === 'view' ? 'Reset' : 'Clear'} + </button> + )} + <button + type="button" + className={`tk-button${ + scope.kind === 'view' ? '' : ' tk-button--primary' + }`} + onClick={() => setSaving(true)} + > + Save as new view + </button> + {scope.kind === 'view' && scopedView !== null && ( + <button + type="button" + className="tk-button tk-button--primary" + disabled={ + scopedView.mine === false && scopedView.visibility === 'personal' + } + onClick={() => { + api + .updateView(scopedView.id, { settings }) + .then(() => workspace.reloadViews()) + .then(() => onFilter(undefined)) + .catch((caught: unknown) => setProblem(errorText(caught))); + }} + > + Save to view + </button> + )} + </span> + ) : null; + const aside: ReactNode = !panelOpen ? null : scope.kind === 'view' ? ( + scopedView === null ? null : ( + <ViewPanel + view={scopedView} + items={shown} + ownerName={lookups.owner.name} + projectIcon={anyProject} + onChange={(changes) => { + api + .updateView(scopedView.id, changes) + .then(() => workspace.reloadViews()) + .catch((caught: unknown) => setProblem(errorText(caught))); + }} + onDelete={() => { + api + .deleteView(scopedView.id) + .then(() => workspace.reloadViews()) + .then(onOpenViews) + .catch((caught: unknown) => setProblem(errorText(caught))); + }} + /> + ) + ) : scope.kind === 'project' ? ( + <ProjectPanel + name={scope.name} + project={trackerProjectNamed(lookups, [scope.codebaseId], scope.name)} + codebase={projectName(scope.codebaseId)} + items={shown} + /> + ) : null; + + return ( + <div className="tickets-screen"> + {error !== null && <p className="error">{error}</p>} + {problem !== null && ( + <button + type="button" + className="error" + onClick={() => setProblem(null)} + > + {problem} (dismiss) + </button> + )} + {made !== null && ( + <div className="tk-toast" role="status"> + <StatusIcon status={made.status} label={false} /> + {made.id} created + <button + type="button" + className="link-button" + onClick={() => { + onOpen({ projectId: made.projectId, ticketId: made.id }); + setMade(null); + }} + > + View + </button> + <button + type="button" + className="tk-icon-button tk-icon-button--bare" + aria-label="Dismiss" + onClick={() => setMade(null)} + > + <LineIcon name="close" size={12} /> + </button> + </div> + )} + {open !== null ? ( + <TicketView + open={open} + lookups={lookups} + order={order} + tick={tick} + buildingOn={(projectId, ticketId) => + building.find( + ({ summary }) => + summary.building?.current?.id === ticketId && + (state.lambda?.podProjects[summary.pod.id] ?? projectId) === + projectId + )?.computer ?? null + } + onBack={() => onOpen(null)} + onOpen={onOpen} + onOpenPod={onOpenPod} + onChange={change} + backLabel={ + scope.kind === 'project' + ? scope.name + : scope.kind === 'view' + ? scopedView?.name ?? 'Tickets' + : 'Tickets' + } + starred={workspace.isFavorite( + 'ticket', + favoriteRef(open.projectId, open.ticketId) + )} + onStar={() => + workspace.setFavorite( + 'ticket', + favoriteRef(open.projectId, open.ticketId), + !workspace.isFavorite( + 'ticket', + favoriteRef(open.projectId, open.ticketId) + ), + items.find( + (one) => + one.projectId === open.projectId && one.id === open.ticketId + )?.title + ) + } + lambda={openLambda} + onChat={() => { + if (openLambda !== undefined) { + onOpenLambda(openLambda); + + return; + } + + planTicket(); + }} + onPlan={planTicket} + /> + ) : data === null ? ( + error === null && <p className="muted tickets__loading">Loading…</p> + ) : ( + <ListView + data={data} + lookups={lookups} + view={view} + onView={setView} + search={search} + onSearch={setSearch} + onOpen={onOpen} + onNew={(status) => setCreating({ status: status ?? 'inbox' })} + title={title} + titleEnd={titleEnd} + headEnd={scope.kind === 'all' ? null : panelButton} + fixedChips={fixedChips} + saveBar={saveBar} + aside={aside} + showProject={scope.kind !== 'project'} + onMove={(item, after) => { + if ( + item.projectId !== after.projectId || + item.parent !== after.parent + ) { + setProblem( + `${item.id} can only move among the tickets beside it in the queue.` + ); + + return; + } + + api + .moveTicket(item.projectId, item.id, after.id) + .then(() => setReload((value) => value + 1)) + .catch((caught: unknown) => setProblem(errorText(caught))); + }} + /> + )} + {creating !== null && ( + <NewTicketDialog + projects={projects} + lookups={lookups} + startProject={startProject} + startTrackerProject={scope.kind === 'project' ? scope.name : null} + startStatus={creating.status} + onClose={() => setCreating(null)} + onCreated={(item) => { + setCreating(null); + setMade(item); + setReload((value) => value + 1); + }} + onPlan={(projectId, text) => { + setCreating(null); + onPlan(projectId, text); + }} + /> + )} + {saving && ( + <ViewDialog + onClose={() => setSaving(false)} + onSave={async (fields) => { + const made = await api.newView({ + ...fields, + settings: { + groupBy: settings.groupBy, + chips: [...fixedChips, ...settings.chips], + }, + }); + await workspace.reloadViews(); + setSaving(false); + onOpenView(made.id); + }} + /> + )} + </div> + ); +}; diff --git a/src/app/client/TimePage.tsx b/src/app/client/TimePage.tsx new file mode 100644 index 0000000..84bfb61 --- /dev/null +++ b/src/app/client/TimePage.tsx @@ -0,0 +1,222 @@ +import type { Question } from '../../pod-store'; +import { itemTitle } from '../shared/items'; +import { podName } from '../shared/podName'; +import type { PodSummary, TimeSummary } from '../shared/types'; +import { formatDuration } from './useActiveTime'; + +/** + * Where the planning time went: today / this week / all time, a bar per day + * for the last two weeks, and every pod broken down by item. Opened from the + * stopwatch; time spent here doesn't count. + */ + +const DAYS_SHOWN = 14; + +const dayKey = (date: Date): string => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + '0' + )}-${String(date.getDate()).padStart(2, '0')}`; + +/** The last `count` days ending on `today` ("YYYY-MM-DD"), oldest first. */ +const lastDays = (today: string, count: number): string[] => { + const [year, month, day] = today.split('-').map(Number); + const end = new Date(year ?? 1970, (month ?? 1) - 1, day ?? 1); + + return Array.from({ length: count }, (_unused, index) => { + const date = new Date(end); + date.setDate(end.getDate() - (count - 1 - index)); + + return dayKey(date); + }); +}; + +const weekday = (key: string): string => { + const [year, month, day] = key.split('-').map(Number); + + return new Date(year ?? 1970, (month ?? 1) - 1, day ?? 1).toLocaleDateString( + undefined, + { weekday: 'short', day: 'numeric' } + ); +}; + +/** Short form for the chart labels: "1h 05m" / "12m" / "40s". */ +const compact = (ms: number): string => { + const minutes = Math.round(ms / 60_000); + + if (ms < 60_000) { + return ms === 0 ? '' : `${Math.round(ms / 1000)}s`; + } + + return minutes >= 60 + ? `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m` + : `${minutes}m`; +}; + +export const TimePage = ({ + time, + todayMs, + pods, + questions, + onOpenItem, + onClose, +}: { + time: TimeSummary | undefined; + /** Today including time not yet reported. */ + todayMs: number; + pods: PodSummary[]; + questions: Record<string, Question[]>; + onOpenItem: (podId: string, qid: string) => void; + onClose: () => void; +}) => { + if (time === undefined) { + return <p className="muted">No time recorded yet.</p>; + } + + const days = lastDays(time.day, DAYS_SHOWN); + const daily = days.map((key) => ({ + key, + ms: key === time.day ? todayMs : time.days[key] ?? 0, + })); + const week = daily.slice(-7).reduce((sum, day) => sum + day.ms, 0); + const allTime = + Object.entries(time.days).reduce( + (sum, [key, ms]) => sum + (key === time.day ? 0 : ms), + 0 + ) + todayMs; + const peak = Math.max(1, ...daily.map((day) => day.ms)); + const podRows = Object.entries(time.pods) + .map(([podId, entry]) => { + const summary = pods.find((candidate) => candidate.pod.id === podId); + const podQuestions = questions[podId] ?? []; + const items = Object.entries(entry.items) + .map(([qid, ms]) => { + const question = podQuestions.find( + (candidate) => candidate.id === qid + ); + + return { + qid, + ms, + title: question === undefined ? qid : itemTitle(question), + exists: question !== undefined, + }; + }) + .sort((a, b) => b.ms - a.ms); + + return { + podId, + name: + summary === undefined ? `${podId} (deleted)` : podName(summary.pod), + exists: summary !== undefined, + totalMs: entry.totalMs, + items, + }; + }) + .sort((a, b) => b.totalMs - a.totalMs); + const itemCount = podRows.reduce((sum, pod) => sum + pod.items.length, 0); + const podTotal = podRows.reduce((sum, pod) => sum + pod.totalMs, 0); + + return ( + <section className="time-page"> + <button type="button" className="link-button back" onClick={onClose}> + ← Back + </button> + <h2>Planning time</h2> + <p className="muted"> + Active time only — scrolling, typing, clicking, listening, or waiting on + a live reply. Time on this page doesn't count. + </p> + + <div className="time-stats"> + <div> + <span className="time-stats__label">Today</span> + <strong>{formatDuration(todayMs)}</strong> + </div> + <div> + <span className="time-stats__label">Last 7 days</span> + <strong>{formatDuration(week)}</strong> + </div> + <div> + <span className="time-stats__label">All time</span> + <strong>{formatDuration(allTime)}</strong> + </div> + <div> + <span className="time-stats__label">Per item, on average</span> + <strong> + {itemCount === 0 ? '—' : formatDuration(podTotal / itemCount)} + </strong> + </div> + </div> + + <h3 className="time-page__heading">Last {DAYS_SHOWN} days</h3> + <div className="time-chart" role="img" aria-label="Active time per day"> + {daily.map((day) => ( + <div + key={day.key} + className="time-chart__day" + title={`${weekday(day.key)} · ${formatDuration(day.ms)}`} + > + <span className="time-chart__value">{compact(day.ms)}</span> + <span + className={`time-chart__bar${ + day.key === time.day ? ' time-chart__bar--today' : '' + }`} + style={{ height: `${(day.ms / peak) * 100}%` }} + /> + <span className="time-chart__label">{weekday(day.key)}</span> + </div> + ))} + </div> + + <h3 className="time-page__heading">By Lambda</h3> + {podRows.length === 0 && ( + <p className="muted"> + Nothing yet — time on a Lambda's items shows up here. + </p> + )} + {podRows.map((pod) => ( + <details + key={pod.podId} + className="time-pod" + open={podRows.length <= 3} + > + <summary> + <span>{pod.name}</span> + <strong>{formatDuration(pod.totalMs)}</strong> + </summary> + <ul> + {pod.items.map((item) => ( + <li key={item.qid}> + {item.exists && pod.exists ? ( + <button + type="button" + className="link-button" + onClick={() => onOpenItem(pod.podId, item.qid)} + > + {item.title} + </button> + ) : ( + <span>{item.title}</span> + )} + <span className="time-pod__ms">{formatDuration(item.ms)}</span> + </li> + ))} + {pod.totalMs > + pod.items.reduce((sum, item) => sum + item.ms, 0) && ( + <li className="muted"> + <span>On its own page</span> + <span className="time-pod__ms"> + {formatDuration( + pod.totalMs - + pod.items.reduce((sum, item) => sum + item.ms, 0) + )} + </span> + </li> + )} + </ul> + </details> + ))} + </section> + ); +}; diff --git a/src/app/client/Tip.tsx b/src/app/client/Tip.tsx new file mode 100644 index 0000000..71504c0 --- /dev/null +++ b/src/app/client/Tip.tsx @@ -0,0 +1,96 @@ +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +const DELAY_MS = 120; +const WIDTH = 320; +const GUTTER = 12; + +interface Position { + left: number; + top: number; + above: boolean; +} + +/** + * A quick, styled tooltip (the native `title` one waits ~1s and can't be + * styled). Rendered in a portal with fixed positioning so the sidebar's + * overflow never clips it; flips above the anchor near the bottom edge. + */ +export const Tip = ({ + text, + children, + className, +}: { + /** Plain text, or formatted content (the workflow panel's step details). */ + text: ReactNode; + children: ReactNode; + className?: string; +}) => { + const anchor = useRef<HTMLSpanElement>(null); + const timer = useRef<number | undefined>(undefined); + const [position, setPosition] = useState<Position | null>(null); + + const clear = (): void => window.clearTimeout(timer.current); + + const show = (): void => { + clear(); + timer.current = window.setTimeout(() => { + const rect = anchor.current?.getBoundingClientRect(); + + if (rect === undefined) { + return; + } + + // Rich tips run taller; flip above sooner for them. + const above = + rect.bottom + (typeof text === 'string' ? 140 : 300) > + window.innerHeight; + const left = Math.min( + Math.max(GUTTER, rect.left), + window.innerWidth - WIDTH - GUTTER + ); + + setPosition({ + left, + top: above ? rect.top - 8 : rect.bottom + 8, + above, + }); + }, DELAY_MS); + }; + + const hide = (): void => { + clear(); + setPosition(null); + }; + + useEffect(() => clear, []); + + if (text === '') { + return <span className={className}>{children}</span>; + } + + return ( + <span + ref={anchor} + className={className} + tabIndex={0} + onMouseEnter={show} + onMouseLeave={hide} + onFocus={show} + onBlur={hide} + > + {children} + {position !== null && + createPortal( + <div + role="tooltip" + className={`tip${position.above ? ' tip--above' : ''}`} + style={{ left: position.left, top: position.top, width: WIDTH }} + > + {text} + </div>, + document.body + )} + </span> + ); +}; diff --git a/src/app/client/ViewPanels.tsx b/src/app/client/ViewPanels.tsx new file mode 100644 index 0000000..88c956f --- /dev/null +++ b/src/app/client/ViewPanels.tsx @@ -0,0 +1,531 @@ +import { useEffect, useState } from 'react'; + +import type { TrackerProject } from '../../tickets/source'; +import type { TicketListItem } from '../shared/building'; +import { + type SavedView, + type Visibility, + viewCounts, +} from '../shared/savedViews'; +import { labelColour } from '../shared/ticketBoard'; +import { ProjectIcon } from './ProjectIcon'; +import { LineIcon } from './TicketIcons'; +import { MenuButton, TicketMenu } from './TicketMenu'; +import { errorText } from './loading'; + +/** + * The right-hand panels of a view's and a project's pages, laid out as + * Linear's: what it is, its properties, and how its tickets break down. And + * the dialog that saves a list's filters as a view. + */ + +const VISIBILITY_NAMES: Record<Visibility, string> = { + personal: 'Personal', + workspace: 'Workspace', +}; + +const VISIBILITY_HINTS: Record<Visibility, string> = { + personal: 'Only you see it', + workspace: 'Everyone in the workspace sees it', +}; + +const VisibilityIcon = ({ visibility }: { visibility: Visibility }) => + visibility === 'personal' ? ( + <LineIcon name="lock" size={13} /> + ) : ( + <svg + width="13" + height="13" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + aria-hidden="true" + > + <circle cx="9" cy="9" r="3.2" /> + <path d="M3.5 19c.6-3 2.8-4.8 5.5-4.8s4.9 1.8 5.5 4.8" /> + <circle cx="16.5" cy="8" r="2.6" /> + <path d="M15.5 13.4c2.6-.2 4.6 1.4 5 4.6" /> + </svg> + ); + +const COUNT_TABS = [ + { by: 'assignee', name: 'Assignees' }, + { by: 'label', name: 'Labels' }, + { by: 'project', name: 'Projects' }, +] as const; + +type CountBy = (typeof COUNT_TABS)[number]['by']; + +/** Assignees / Labels / Projects: how many of the tickets each has. */ +const Breakdown = ({ + items, + tabs, + projectIcon, +}: { + items: TicketListItem[]; + tabs: readonly CountBy[]; + projectIcon: (name: string) => TrackerProject | undefined; +}) => { + const [by, setBy] = useState<CountBy>(tabs[0] ?? 'assignee'); + const counts = viewCounts(items, by); + const top = Math.max(1, ...counts.map((one) => one.count)); + + return ( + <div className="view-panel__breakdown"> + <div className="view-panel__tabs" role="tablist"> + {COUNT_TABS.filter((tab) => tabs.includes(tab.by)).map((tab) => ( + <button + key={tab.by} + type="button" + role="tab" + aria-selected={by === tab.by} + className={`view-panel__tab${ + by === tab.by ? ' view-panel__tab--on' : '' + }`} + onClick={() => setBy(tab.by)} + > + {tab.name} + </button> + ))} + </div> + {counts.length === 0 ? ( + <p className="muted view-panel__none">No tickets.</p> + ) : ( + <ul className="view-panel__counts"> + {counts.map((one) => ( + <li key={one.key} className="view-panel__count"> + <span className="view-panel__count-icon" aria-hidden="true"> + {by === 'label' ? ( + <span + className="tk-chip__dot" + style={{ background: labelColour(one.key) }} + /> + ) : by === 'project' ? ( + <ProjectIcon + icon={projectIcon(one.key)?.icon} + color={ + one.key === '' + ? 'var(--grey-500)' + : projectIcon(one.key)?.color + } + size={13} + /> + ) : ( + <span + className={`view-panel__avatar${ + one.key === '' ? ' view-panel__avatar--none' : '' + }`} + > + {one.key === '' ? '' : one.key.slice(0, 1).toUpperCase()} + </span> + )} + </span> + <span className="view-panel__count-name">{one.label}</span> + <span className="view-panel__bar" aria-hidden="true"> + <span style={{ width: `${(one.count / top) * 100}%` }} /> + </span> + <span className="view-panel__count-number">{one.count}</span> + </li> + ))} + </ul> + )} + </div> + ); +}; + +/** Text that saves when it loses focus (or on Enter, for a one-liner). */ +const Editable = ({ + value, + placeholder, + multiline, + className, + label, + disabled, + onSave, +}: { + value: string; + placeholder: string; + multiline: boolean; + className: string; + label: string; + disabled: boolean; + onSave: (value: string) => void; +}) => { + const [text, setText] = useState(value); + + useEffect(() => setText(value), [value]); + + const save = (): void => { + if (text.trim() !== value.trim() && (multiline || text.trim() !== '')) { + onSave(multiline ? text : text.trim()); + } + }; + + return multiline ? ( + <textarea + className={className} + aria-label={label} + placeholder={placeholder} + value={text} + rows={2} + disabled={disabled} + onChange={(event) => setText(event.target.value)} + onBlur={save} + /> + ) : ( + <input + className={className} + aria-label={label} + placeholder={placeholder} + value={text} + disabled={disabled} + onChange={(event) => setText(event.target.value)} + onBlur={save} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + + if (event.key === 'Escape') { + setText(value); + event.currentTarget.blur(); + } + }} + /> + ); +}; + +const VisibilityMenu = ({ + visibility, + disabled, + onPick, + className, +}: { + visibility: Visibility; + disabled: boolean; + onPick: (visibility: Visibility) => void; + className: string; +}) => ( + <MenuButton + className={className} + label={`Visibility: ${VISIBILITY_NAMES[visibility]}`} + disabled={disabled} + menu={(close) => ( + <TicketMenu + label="Visibility" + placeholder="Visibility…" + options={(['workspace', 'personal'] as const).map((one) => ({ + value: one, + label: VISIBILITY_NAMES[one], + hint: VISIBILITY_HINTS[one], + icon: <VisibilityIcon visibility={one} />, + }))} + selected={[visibility]} + onPick={(value) => { + close(); + onPick(value === 'personal' ? 'personal' : 'workspace'); + }} + onClose={close} + /> + )} + > + <VisibilityIcon visibility={visibility} /> + <span className="tk-prop-line__text">{VISIBILITY_NAMES[visibility]}</span> + </MenuButton> +); + +export const ViewPanel = ({ + view, + items, + ownerName, + projectIcon, + onChange, + onDelete, +}: { + view: SavedView; + items: TicketListItem[]; + /** The signed-in owner's name, for a view that's theirs. */ + ownerName: string; + projectIcon: (name: string) => TrackerProject | undefined; + onChange: ( + changes: Partial<{ + name: string; + description: string; + visibility: Visibility; + }> + ) => void; + onDelete: () => void; +}) => { + const [deleting, setDeleting] = useState(false); + + return ( + <aside className="view-panel" aria-label="View"> + <Editable + className="view-panel__name" + label="View name" + placeholder="View name" + value={view.name} + multiline={false} + disabled={false} + onSave={(name) => onChange({ name })} + /> + <Editable + className="view-panel__description" + label="Description" + placeholder="Add a description…" + value={view.description} + multiline + disabled={false} + onSave={(description) => onChange({ description })} + /> + <dl className="view-panel__props"> + <dt>Visibility</dt> + <dd> + <VisibilityMenu + className="tk-prop-line tk-prop-line--inline" + visibility={view.visibility} + disabled={view.mine === false} + onPick={(visibility) => onChange({ visibility })} + /> + </dd> + <dt>Owner</dt> + <dd className="view-panel__owner"> + <span className="view-panel__avatar"> + {(view.mine === false ? view.owner : ownerName) + .slice(0, 1) + .toUpperCase()} + </span> + {view.mine === false ? view.owner : ownerName} + </dd> + {view.linearId !== null && ( + <> + <dt>From</dt> + <dd className="muted">Imported from Linear</dd> + </> + )} + </dl> + <Breakdown + items={items} + tabs={['assignee', 'label', 'project']} + projectIcon={projectIcon} + /> + <div className="view-panel__foot"> + {deleting ? ( + <> + <span className="muted">Delete this view?</span> + <button + type="button" + className="tk-button tk-button--quiet" + onClick={() => setDeleting(false)} + > + Keep it + </button> + <button + type="button" + className="tk-button tk-button--danger" + onClick={onDelete} + > + Delete + </button> + </> + ) : ( + <button + type="button" + className="link-button view-panel__delete" + onClick={() => setDeleting(true)} + > + Delete view + </button> + )} + </div> + </aside> + ); +}; + +const shortDay = (iso: string): string => { + const day = new Date(`${iso.slice(0, 10)}T12:00:00`); + + return Number.isNaN(day.getTime()) + ? iso + : day.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +export const ProjectPanel = ({ + name, + project, + codebase, + items, +}: { + name: string; + /** The tracker's record of it; undefined while loading or for a name only tickets carry. */ + project: TrackerProject | undefined; + codebase: string; + items: TicketListItem[]; +}) => ( + <aside className="view-panel" aria-label="Project"> + <div className="view-panel__title"> + <ProjectIcon icon={project?.icon} color={project?.color} size={18} /> + <h2>{name}</h2> + </div> + {(project?.description ?? '') !== '' && ( + <p className="view-panel__text">{project?.description}</p> + )} + <dl className="view-panel__props"> + <dt>Status</dt> + <dd>{project?.status?.name ?? '—'}</dd> + <dt>Progress</dt> + <dd className="view-panel__progress"> + <span className="view-panel__bar" aria-hidden="true"> + <span + style={{ width: `${Math.round((project?.progress ?? 0) * 100)}%` }} + /> + </span> + {Math.round((project?.progress ?? 0) * 100)}% + </dd> + <dt>Lead</dt> + <dd>{project?.lead ?? <span className="muted">No lead</span>}</dd> + <dt>Target date</dt> + <dd> + {(project?.targetDate ?? null) === null ? ( + <span className="muted">No target date</span> + ) : ( + shortDay(project?.targetDate ?? '') + )} + </dd> + <dt>Codebase</dt> + <dd>{codebase}</dd> + </dl> + {(project?.url ?? null) !== null && ( + <a + className="view-panel__link" + href={project?.url ?? undefined} + target="_blank" + rel="noreferrer" + > + Open in Linear ↗ + </a> + )} + <Breakdown + items={items} + tabs={['assignee', 'label']} + projectIcon={() => undefined} + /> + </aside> +); + +/** "Save as new view": its name, description and who sees it. */ +export const ViewDialog = ({ + onClose, + onSave, +}: { + onClose: () => void; + onSave: (fields: { + name: string; + description: string; + visibility: Visibility; + }) => Promise<void>; +}) => { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [visibility, setVisibility] = useState<Visibility>('workspace'); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState<string | null>(null); + + const save = (): void => { + if (name.trim() === '' || busy) { + return; + } + + setBusy(true); + setProblem(null); + onSave({ name: name.trim(), description, visibility }) + .catch((caught: unknown) => setProblem(errorText(caught))) + .finally(() => setBusy(false)); + }; + + return ( + <div + className="tk-dialog-backdrop" + role="presentation" + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + <div + className="tk-dialog tk-dialog--small" + role="dialog" + aria-modal="true" + aria-label="New view" + onKeyDown={(event) => { + if (event.key === 'Escape') { + onClose(); + } + + if ( + event.key === 'Enter' && + (event.metaKey || event.target instanceof HTMLInputElement) + ) { + save(); + } + }} + > + <header className="tk-dialog__head"> + <h2>New view</h2> + <button + type="button" + className="tk-icon-button tk-icon-button--bare" + aria-label="Close" + onClick={onClose} + > + <LineIcon name="close" /> + </button> + </header> + <input + className="tk-dialog__title" + aria-label="View name" + placeholder="View name" + autoFocus + value={name} + onChange={(event) => setName(event.target.value)} + /> + <textarea + className="tk-dialog__description" + aria-label="Description" + placeholder="Description (optional)" + rows={2} + value={description} + onChange={(event) => setDescription(event.target.value)} + /> + <div className="tk-dialog__props"> + <VisibilityMenu + className="tk-pill" + visibility={visibility} + disabled={false} + onPick={setVisibility} + /> + </div> + {problem !== null && <p className="tk-dialog__problem">{problem}</p>} + <footer className="tk-dialog__foot"> + <button + type="button" + className="tk-button tk-button--quiet" + onClick={onClose} + > + Cancel + </button> + <button + type="button" + className="tk-button tk-button--primary" + disabled={name.trim() === '' || busy} + onClick={save} + > + Save view + </button> + </footer> + </div> + </div> + ); +}; diff --git a/src/app/client/Viewer.tsx b/src/app/client/Viewer.tsx new file mode 100644 index 0000000..6deeb9b --- /dev/null +++ b/src/app/client/Viewer.tsx @@ -0,0 +1,166 @@ +import { + type ReactNode, + Suspense, + createContext, + lazy, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +import type { PrototypeGroup } from '../../pod-store'; +import type { Device, Made, PrototypeRef } from '../shared/prototypes'; +import { api } from './api'; + +/** + * The prototype viewer's context and provider: the small part the signed-in + * shell holds. The focus view and lightbox it opens (Prototypes.tsx) load + * with the pod screen, or when first opened, not with the shell. + */ + +const FocusView = lazy(() => + import('./Prototypes').then((module) => ({ default: module.FocusView })) +); +const Lightbox = lazy(() => + import('./Prototypes').then((module) => ({ default: module.Lightbox })) +); + +export type ViewMode = 'single' | 'side'; + +export interface ViewTarget extends PrototypeRef { + letter?: string; + situation?: string | null; + device?: Device; + mode?: ViewMode; +} + +type Open = + | { kind: 'prototype'; target: ViewTarget } + | { kind: 'picture'; path: string }; + +interface Viewer { + podId: string; + made: Made | null; + refresh: () => void; + openPrototype: (target: ViewTarget) => void; + openPicture: (path: string) => void; +} + +const ViewerContext = createContext<Viewer | null>(null); + +/** The viewer for the Lambda on screen; null off a Lambda. */ +export const useViewer = (): Viewer | null => useContext(ViewerContext); + +export const groupOf = ( + made: Made | null, + slug: string +): PrototypeGroup | undefined => + made?.prototypes.find((group) => group.slug === slug); + +export const ViewerProvider = ({ + podId, + refreshKey, + onAddToChat, + children, +}: { + /** The Lambda on screen; off a Lambda there's nothing to view. */ + podId: string | null; + /** Changes when the pod may have made something (its thread grew). */ + refreshKey: number; + /** A pick from the focus view ("Going with C"), added to the chat box. */ + onAddToChat: (text: string) => void; + children: ReactNode; +}) => { + const [made, setMade] = useState<Made | null>(null); + const [open, setOpen] = useState<Open | null>(null); + const [tick, setTick] = useState(0); + const refresh = useCallback(() => setTick((value) => value + 1), []); + + useEffect(() => { + setOpen(null); + setMade(null); + }, [podId]); + + useEffect(() => { + if (podId === null) { + return undefined; + } + + const live = { current: true }; + api + .made(podId) + .then((next) => live.current && setMade(next)) + .catch(() => live.current && setMade(null)); + + return () => { + live.current = false; + }; + }, [podId, refreshKey, tick]); + + // The inbox's own shortcuts (Space, ←/→, [ ]) step aside while it's open. + useEffect(() => { + document.body.classList.toggle('viewer-open', open !== null); + + return () => document.body.classList.remove('viewer-open'); + }, [open]); + + const value = useMemo<Viewer | null>( + () => + podId === null + ? null + : { + podId, + made, + refresh, + openPrototype: (target) => { + refresh(); + setOpen({ kind: 'prototype', target }); + }, + openPicture: (path) => setOpen({ kind: 'picture', path }), + }, + [podId, made, refresh] + ); + + const group = + open?.kind === 'prototype' ? groupOf(made, open.target.slug) : undefined; + + return ( + <ViewerContext.Provider value={value}> + {children} + {podId !== null && + open?.kind === 'prototype' && + group !== undefined && + createPortal( + <Suspense fallback={null}> + <FocusView + key={`${open.target.slug}`} + podId={podId} + bases={made?.bases} + group={group} + initial={open.target} + onClose={() => setOpen(null)} + onAddToChat={onAddToChat} + /> + </Suspense>, + document.body + )} + {podId !== null && + open?.kind === 'picture' && + createPortal( + <Suspense fallback={null}> + <Lightbox + podId={podId} + made={made} + start={open.path} + onClose={() => setOpen(null)} + onTry={(target) => setOpen({ kind: 'prototype', target })} + /> + </Suspense>, + document.body + )} + </ViewerContext.Provider> + ); +}; diff --git a/src/app/client/ViewsPage.tsx b/src/app/client/ViewsPage.tsx new file mode 100644 index 0000000..63a53c4 --- /dev/null +++ b/src/app/client/ViewsPage.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react'; + +import { NO_SETTINGS } from '../shared/savedViews'; +import { StarButton, ViewIcon } from './ProjectIcon'; +import { LineIcon } from './TicketIcons'; +import { ViewDialog } from './ViewPanels'; +import { api } from './api'; +import { errorText } from './loading'; +import type { Workspace } from './useWorkspace'; + +/** + * Views, as Linear lists them: every saved view you can see (the + * workspace's, and your personal ones), New view, and Import from Linear — + * which brings a tracker's views and your favorites over, saying what didn't + * translate. + */ + +interface Imported { + added: number; + updated: number; + favorites: number; + notes: Array<{ view: string; dropped: string[] }>; +} + +const plural = (count: number, word: string): string => + `${count} ${word}${count === 1 ? '' : 's'}`; + +export const ViewsPage = ({ + workspace, + ownerName, + onOpenView, +}: { + workspace: Workspace; + ownerName: string; + onOpenView: (viewId: string) => void; +}) => { + const [creating, setCreating] = useState(false); + const [importing, setImporting] = useState(false); + const [imported, setImported] = useState<Imported | null>(null); + const [problem, setProblem] = useState<string | null>(null); + const { views } = workspace; + + const importNow = (): void => { + setImporting(true); + setProblem(null); + api + .importViews() + .then(async (result) => { + setImported(result); + await workspace.reloadViews(); + }) + .catch((caught: unknown) => setProblem(errorText(caught))) + .finally(() => setImporting(false)); + }; + + return ( + <section className="tickets views-page"> + <header className="tickets__head"> + <h1 className="tickets__title"> + Views <span className="tickets__count">{views?.length ?? ''}</span> + </h1> + <button + type="button" + className="tk-button" + disabled={importing} + onClick={importNow} + > + {importing ? 'Importing…' : 'Import from Linear'} + </button> + <button + type="button" + className="tk-button tk-button--primary" + onClick={() => setCreating(true)} + > + <LineIcon name="plus" /> + New view + </button> + </header> + + {problem !== null && <p className="error">{problem}</p>} + {imported !== null && ( + <div className="views-page__imported" role="status"> + <p> + {imported.added + imported.updated === 0 + ? 'No views to import.' + : `Imported ${plural(imported.added + imported.updated, 'view')}${ + imported.updated > 0 + ? ` (${imported.added} new, ${imported.updated} updated)` + : '' + }`} + {imported.favorites > 0 && + ` and ${plural(imported.favorites, 'favorite')}`} + . + </p> + {imported.notes.length > 0 && ( + <> + <p className="muted"> + Some filters don't have a match in Lambda, so they were + left out: + </p> + <ul> + {imported.notes.map((note) => ( + <li key={note.view}> + <strong>{note.view}</strong>: {note.dropped.join(', ')} + </li> + ))} + </ul> + </> + )} + <button + type="button" + className="tk-icon-button tk-icon-button--bare views-page__dismiss" + aria-label="Dismiss" + onClick={() => setImported(null)} + > + <LineIcon name="close" size={12} /> + </button> + </div> + )} + + {views === null ? ( + <p className="muted tickets__loading">Loading…</p> + ) : views.length === 0 ? ( + <p className="tickets__empty muted"> + No views yet. Filter a list and choose Save as new view, or import + your views from Linear. + </p> + ) : ( + <div className="views-list"> + {views.map((view) => { + const starred = workspace.isFavorite('view', view.id); + + return ( + <div key={view.id} className="views-list__row"> + <button + type="button" + className="views-list__open" + onClick={() => onOpenView(view.id)} + > + <ViewIcon icon={view.icon} /> + <span className="views-list__name">{view.name}</span> + {view.description !== '' && ( + <span className="views-list__description muted"> + {view.description} + </span> + )} + </button> + <span + className="views-list__visibility muted" + title={ + view.visibility === 'personal' + ? 'Personal: only you see it' + : 'Workspace: everyone sees it' + } + > + {view.visibility === 'personal' ? 'Personal' : 'Workspace'} + </span> + <span + className="view-panel__avatar" + title={view.mine === false ? view.owner : ownerName} + > + {(view.mine === false ? view.owner : ownerName) + .slice(0, 1) + .toUpperCase()} + </span> + <StarButton + what="view" + on={starred} + onToggle={() => + workspace.setFavorite('view', view.id, !starred) + } + /> + </div> + ); + })} + </div> + )} + + {creating && ( + <ViewDialog + onClose={() => setCreating(false)} + onSave={async (fields) => { + const made = await api.newView({ + ...fields, + settings: NO_SETTINGS, + }); + await workspace.reloadViews(); + setCreating(false); + onOpenView(made.id); + }} + /> + )} + </section> + ); +}; diff --git a/src/app/client/WindowPicker.tsx b/src/app/client/WindowPicker.tsx new file mode 100644 index 0000000..95d25b8 --- /dev/null +++ b/src/app/client/WindowPicker.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from 'react'; + +import type { Panes } from '../shared/types'; +import { chosenOption, pickerGroups } from '../shared/windowPicker'; +import { TerminalView } from './TerminalView'; +import { api } from './api'; + +/** How often the window list and the working marks are read again. */ +const PANES_EVERY_MS = 3000; + +/** Per viewer, per project: a convenience, so storage may fail quietly. */ +const remembered = (key: string): string | null => { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +}; + +const remember = (key: string, value: string): void => { + try { + window.localStorage.setItem(key, value); + } catch { + // Private window or blocked storage: it just isn't remembered. + } +}; + +/** + * The Agents panel: one dropdown of the pod's agents (working ● or idle ○), + * the project's own tmux windows, and, when asked, Lambda's. Only an agent + * takes typing; the rest are watched. The last pick is kept per project. + */ +export const WindowPicker = ({ + podId, + project, + agents, + labelOf, + fallback, + onError, + onChosen, +}: { + podId: string; + /** Whose pick is remembered: the project's id or name. */ + project: string; + agents: string[]; + labelOf: (window: string) => string; + /** The agent shown when nothing (still there) was picked. */ + fallback: string; + onError: (caught: unknown) => void; + /** The window now on screen, for a parent that adds to it (the dev's box). */ + onChosen?: (window: string) => void; +}) => { + const pickKey = `lambda:agents-window:${project}`; + const lambdaKey = `lambda:agents-show-lambda:${project}`; + const [panes, setPanes] = useState<Panes | null>(null); + const [picked, setPicked] = useState(() => remembered(pickKey)); + const [showLambda, setShowLambda] = useState( + () => remembered(lambdaKey) === 'yes' + ); + + useEffect(() => { + const live = { current: true }; + + const read = (): void => { + api + .panes(podId) + .then((next) => { + if (live.current) { + setPanes(next); + } + }) + // The list is extra: the agents still show without it. + .catch(() => undefined); + }; + + read(); + const timer = setInterval(read, PANES_EVERY_MS); + + return () => { + live.current = false; + clearInterval(timer); + }; + }, [podId]); + + const groups = pickerGroups({ agents, labelOf, panes, showLambda }); + const active = chosenOption(groups, picked, fallback); + + useEffect(() => { + if (active !== undefined) { + onChosen?.(active.value); + } + }, [active?.value, onChosen]); + + if (active === undefined) { + return <p className="rail__empty">Its windows are closed.</p>; + } + + return ( + <> + <div className="window-picker"> + <select + aria-label="Window" + value={active.value} + onChange={(event) => { + setPicked(event.target.value); + remember(pickKey, event.target.value); + }} + > + {groups.map((group) => ( + <optgroup key={group.label} label={group.label}> + {group.options.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </optgroup> + ))} + </select> + <label className="window-picker__lambda"> + <input + type="checkbox" + checked={showLambda} + onChange={(event) => { + setShowLambda(event.target.checked); + remember(lambdaKey, event.target.checked ? 'yes' : 'no'); + }} + /> + Show Lambda’s windows + </label> + {active.readOnly && ( + <span className="window-picker__note">Read-only</span> + )} + </div> + <TerminalView + key={`${podId}:${active.value}`} + podId={podId} + window={active.value} + readOnly={active.readOnly} + onError={onError} + /> + </> + ); +}; diff --git a/src/app/client/WorkflowPanel.tsx b/src/app/client/WorkflowPanel.tsx new file mode 100644 index 0000000..95400dc --- /dev/null +++ b/src/app/client/WorkflowPanel.tsx @@ -0,0 +1,550 @@ +import { useState } from 'react'; + +import type { + RenderMode, + StepHistory, + StepStatus, + WorkflowStep, +} from '../../pod-store'; +import type { + AgentState, + WorkflowStageView, + WorkflowView, +} from '../shared/types'; +import { Tip } from './Tip'; + +/** + * The sidebar's workflow panel: every stage of the current pod's pipeline, + * where it is, and — on hover — what each step does, who runs it, what it did + * in this pod and what it does on average. For debugging the pipeline: which + * steps are slow, which never find anything, where a pod is stuck. + */ + +const duration = (ms: number | undefined): string => { + if (ms === undefined || !Number.isFinite(ms) || ms < 0) { + return ''; + } + + const minutes = Math.round(ms / 60_000); + + if (minutes < 1) { + return `${Math.max(1, Math.round(ms / 1000))}s`; + } + + if (minutes < 60) { + return `${minutes}m`; + } + + return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`; +}; + +const between = (from?: string, to?: string, now: number = Date.now()) => + from === undefined + ? undefined + : (to === undefined ? now : Date.parse(to)) - Date.parse(from); + +const clock = (at: string): string => + new Date(at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + +const ICON = { + done: '✓', + skipped: '–', + now: '▶', + 'waiting-you': '◆', + todo: '○', +} as const; + +const whoMark = (who: string): string => (who === 'you' ? 'you' : '⚙'); + +const historyLines = ( + history: StepHistory | undefined, + repeated: boolean +): string[] => { + if (history === undefined || history.runs === 0) { + return ['Past planning Lambdas: no history yet.']; + } + + const found = + history.avgFound === undefined + ? '' + : ` · ${history.avgFound.toFixed(1)} found per ${ + repeated ? 'pass' : 'run' + }`; + const nothing = + history.reportedFound === 0 + ? [] + : [ + `Found nothing in ${history.foundNothing} of ${ + history.reportedFound + } ${repeated ? 'passes' : 'runs'}.`, + ]; + + return [ + `Past pods (${history.pods}): avg ${ + duration(history.avgMs) || '—' + }${found}.`, + ...nothing, + ]; +}; + +const thisPodLine = (status: StepStatus): string | undefined => { + if (!status.tracked) { + return status.state === 'done' + ? 'This Lambda: done (not tracked — before step reporting).' + : undefined; + } + + const parts = [ + status.passes.length > 1 ? `${status.passes.length} passes` : undefined, + status.progress, + status.found === undefined ? undefined : `${status.found} found`, + duration(between(status.startedAt, status.endedAt)), + ].filter((part): part is string => part !== undefined && part !== ''); + + return parts.length === 0 ? undefined : `This Lambda: ${parts.join(' · ')}.`; +}; + +const StepTip = ({ + step, + status, + history, +}: { + step: WorkflowStep; + status: StepStatus; + history?: StepHistory; +}) => ( + <div className="workflow__tip"> + <strong>{step.label}</strong> + <p>{step.tip}</p> + <p> + Who: {step.who === 'you' ? 'you' : `${step.who} (automated)`} + {step.maxPasses === undefined ? '' : ` · up to ${step.maxPasses} passes`} + </p> + <p>Done when: {step.doneWhen}</p> + {thisPodLine(status) !== undefined && <p>{thisPodLine(status)}</p>} + {status.note !== undefined && <p>Note: {status.note}</p>} + {historyLines(history, step.maxPasses !== undefined).map((line) => ( + <p key={line} className="workflow__tip-muted"> + {line} + </p> + ))} + </div> +); + +const StageTip = ({ view }: { view: WorkflowStageView }) => ( + <div className="workflow__tip"> + <strong>{view.stage.label}</strong> + <p>{view.stage.tip}</p> + {view.yourMs > 0 && <p>Your time here: {duration(view.yourMs)}.</p>} + {view.avgYourMs !== undefined && ( + <p className="workflow__tip-muted"> + Your average across pods: {duration(view.avgYourMs)}. + </p> + )} + </div> +); + +const stepLabel = (step: WorkflowStep, status: StepStatus): string => { + const pass = status.passes.at(-1); + const passText = + step.maxPasses === undefined || pass === undefined + ? '' + : ` · pass ${pass.pass}/${step.maxPasses}`; + const progress = + status.state === 'now' && status.progress !== undefined + ? ` · ${status.progress}` + : ''; + + return `${step.label}${passText}${progress}`; +}; + +const agentLine = (name: string, agent: AgentState, now: number): string => { + const since = duration(between(agent.since, undefined, now)); + + return `${agent.working ? '●' : '○'} ${name} ${ + agent.working ? 'working' : 'idle' + }${since === '' ? '' : ` · ${since}`}`; +}; + +const CELL = { done: '✓', now: '▶', todo: '·' } as const; + +/** Batch pods: one row per ticket, one column per stage. */ +const TicketGrid = ({ view }: { view: WorkflowView }) => { + const columns = view.stages.filter( + (stageView) => stageView.stage.steps.length > 0 + ); + + return ( + <table className="workflow__grid"> + <thead> + <tr> + <th /> + {columns.map(({ stage }) => ( + <th key={stage.id} title={stage.label}> + {stage.label.replace(/^\d+ /, '').slice(0, 3)} + </th> + ))} + </tr> + </thead> + <tbody> + {(view.tickets ?? []).map((row) => ( + <tr key={row.id}> + <th>{row.id}</th> + {columns.map(({ stage }) => { + const cell = row.cells[stage.id] ?? 'todo'; + + return ( + <td + key={stage.id} + className={`workflow__cell workflow__cell--${cell}`} + title={`${row.id} · ${stage.label}: ${cell}`} + > + {CELL[cell]} + </td> + ); + })} + </tr> + ))} + </tbody> + </table> + ); +}; + +/** + * Render Full | ASCII: whether the pod's UX review gets screenshots, designs + * and prototypes, or ASCII only (faster). Takes effect at the next UX review. + */ +/** Shown under the switch when the pod's computer can't run Claude Design. */ +export const FULL_UNAVAILABLE = + "Full needs Claude Design, which isn't available with this Claude account."; + +/** + * `designAvailable` is what the pod's runner reports: `false` greys out + * Full (absent: not checked yet, so Full stays on offer). + */ +export const RenderSwitch = ({ + mode, + designAvailable, + onChange, +}: { + mode: RenderMode; + designAvailable?: boolean; + onChange: (mode: RenderMode) => void; +}) => { + const fullOff = designAvailable === false; + + return ( + <> + <p className="workflow__render"> + <Tip + text={ + <div className="workflow__tip"> + <strong>Render</strong> + <p> + Full: the UX review shows screenshots, designs and prototypes. + ASCII: no screenshots or Claude Design, ASCII only, so the pod + plans faster. A change takes effect at the next UX review. + </p> + </div> + } + > + Render + </Tip>{' '} + <span role="group" aria-label="Render the UX review as"> + {(['full', 'ascii'] as const).map((option) => ( + <button + key={option} + type="button" + className={`toggle${mode === option ? ' toggle--on' : ''}`} + aria-pressed={mode === option} + data-testid={`render-mode-${option}`} + disabled={option === 'full' && fullOff} + onClick={() => mode !== option && onChange(option)} + > + {option === 'full' ? 'Full' : 'ASCII'} + </button> + ))} + </span> + </p> + {fullOff && ( + <p className="muted workflow__render-note">{FULL_UNAVAILABLE}</p> + )} + </> + ); +}; + +export const WorkflowPanel = ({ + podLabel, + view, + onSkip, + onStructure, + onRenderMode, +}: { + podLabel: string | null; + view: WorkflowView | null; + /** Skip a stage that hasn't finished (the owner's call). */ + onSkip: (stageId: string, label: string, writesLinear: boolean) => void; + /** Freeform → the structured flow, keeping the conversation. */ + onStructure: () => void; + /** The Render Full | ASCII switch. */ + onRenderMode: (mode: RenderMode) => void; +}) => { + // Stages opened by hand (the current one is always open). + const [opened, setOpened] = useState<Set<string>>(new Set()); + + if (view === null || podLabel === null) { + return ( + <section className="workflow"> + <div className="sidebar__label">Workflow</div> + <p className="muted workflow__empty"> + Open a pod or an item to see its workflow. + </p> + </section> + ); + } + + const now = Date.now(); + const lastWatchdog = view.watchdog.at(-1); + const recentWatchdog = + lastWatchdog !== undefined && + now - Date.parse(lastWatchdog.at) < 60 * 60_000 + ? lastWatchdog + : undefined; + const toggle = (id: string): void => + setOpened((current) => { + const next = new Set(current); + + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + + return next; + }); + + if (view.freeform) { + return ( + <section className="workflow"> + <div className="sidebar__label">Workflow · {podLabel}</div> + <RenderSwitch + mode={view.renderMode} + designAvailable={view.designAvailable} + onChange={onRenderMode} + /> + <p className="workflow__agents"> + <span>{agentLine('dev', view.agents.dev, now)}</span> + </p> + <p className="muted workflow__empty"> + Freeform: a conversation with the dev — no stages or checkpoints. + Switch when you want the planning pipeline; it starts from what you + have said so far. + </p> + <button type="button" className="ghost" onClick={onStructure}> + Switch to structured + </button> + </section> + ); + } + + return ( + <section className="workflow"> + <div className="sidebar__label">Workflow · {podLabel}</div> + <RenderSwitch + mode={view.renderMode} + designAvailable={view.designAvailable} + onChange={onRenderMode} + /> + {view.tickets !== undefined && <TicketGrid view={view} />} + <p className="workflow__agents"> + <span>{agentLine('dev', view.agents.dev, now)}</span> + <span>{agentLine('manager', view.agents.manager, now)}</span> + </p> + {view.requests + .filter((request) => request.status !== 'done') + .map((request) => ( + <p key={request.id} className="workflow__request"> + <Tip + text={ + <div className="workflow__tip"> + <strong>Work request {request.id}</strong> + <p>“{request.quote}”</p> + <p> + {request.status === 'queued' + ? 'Queued — the manager starts it right away, interrupting the dev.' + : 'The dev is on it.'}{' '} + Meanwhile this Lambda's items are out of your inbox; they + come back re-checked, and anything it made stale is redone. + </p> + </div> + } + > + ⏸ Request · {request.summary} + {' · '} + {request.status === 'queued' + ? 'queued' + : `working ${duration( + between(request.startedAt, undefined, now) + )}`} + </Tip> + </p> + ))} + {view.requests.some((request) => request.status === 'done') && ( + <p className="workflow__requests-done muted"> + <Tip + text={ + <div className="workflow__tip"> + <strong>Work requests done</strong> + {view.requests + .filter((request) => request.status === 'done') + .map((request) => ( + <p key={request.id}> + {request.id} · {request.summary} — {request.result ?? ''}{' '} + ({duration(between(request.startedAt, request.doneAt))}) + </p> + ))} + </div> + } + > + ✓{' '} + { + view.requests.filter((request) => request.status === 'done') + .length + }{' '} + work request(s) done + </Tip> + </p> + )} + {recentWatchdog !== undefined && ( + <p className="workflow__watchdog"> + <Tip + text={ + <div className="workflow__tip"> + <strong>Watchdog</strong> + <p> + When nothing is running and nothing waits on you for 90s, the + app nudges the manager (twice at most, 2 minutes apart). If + that doesn't get it moving — or an agent is on a permission + prompt or hung — it goes to your inbox as Stalled. + </p> + {view.watchdog.map((entry) => ( + <p key={entry.at} className="workflow__tip-muted"> + {clock(entry.at)} · {entry.note} + </p> + ))} + </div> + } + > + ⚙ {clock(recentWatchdog.at)} · {recentWatchdog.note} + </Tip> + </p> + )} + <ol className="workflow__stages"> + {view.stages.map((stageView) => { + const { stage, status } = stageView; + const isCurrent = + status.state === 'now' || status.state === 'waiting-you'; + const open = isCurrent || opened.has(stage.id); + const time = + stage.kind === 'checkpoint' + ? duration(stageView.yourMs || undefined) + : duration( + between( + status.startedAt, + status.state === 'done' ? status.endedAt : undefined, + now + ) + ); + + return ( + <li + key={stage.id} + className={`workflow__stage workflow__stage--${status.state}`} + > + <button + type="button" + className="workflow__row" + onClick={() => toggle(stage.id)} + disabled={stage.steps.length === 0} + > + <span className="workflow__icon">{ICON[status.state]}</span> + <Tip + className="workflow__label" + text={<StageTip view={stageView} />} + > + {stage.label} + </Tip> + <span className="workflow__who">{whoMark(stage.who)}</span> + <span className="workflow__time">{time}</span> + </button> + {(status.state === 'todo' || + status.state === 'now' || + status.state === 'waiting-you') && ( + <button + type="button" + className="workflow__skip link-button" + title={`Skip ${stage.label} — this Lambda passes it by`} + onClick={() => + onSkip( + stage.id, + stage.label, + view.confirmSkip.includes(stage.id) + ) + } + > + Skip + </button> + )} + {open && stage.steps.length > 0 && ( + <ul className="workflow__steps"> + {stage.steps.map((step) => { + const stepStatus = status.steps.find( + (candidate) => candidate.id === step.id + ); + + if (stepStatus === undefined) { + return null; + } + + return ( + <li + key={step.id} + className={`workflow__step workflow__step--${ + stepStatus.state + }${ + stepStatus.tracked ? '' : ' workflow__step--untracked' + }`} + > + <span className="workflow__icon"> + {ICON[stepStatus.state]} + </span> + <Tip + className="workflow__label" + text={ + <StepTip + step={step} + status={stepStatus} + history={view.history[step.id]} + /> + } + > + {stepLabel(step, stepStatus)} + </Tip> + <span className="workflow__who"> + {whoMark(step.who)} + </span> + </li> + ); + })} + </ul> + )} + </li> + ); + })} + </ol> + <p className="workflow__legend muted"> + ⚙ automated · you · ▶ now · ◆ waiting on you · ✓ done + </p> + </section> + ); +}; diff --git a/src/app/client/api.ts b/src/app/client/api.ts new file mode 100644 index 0000000..799c199 --- /dev/null +++ b/src/app/client/api.ts @@ -0,0 +1,771 @@ +import type { Question, RenderMode } from '../../pod-store'; +import type { FolderListing } from '../../setup/folders'; +import type { SetupState } from '../../setup/routes'; +import type { TicketsBodyType } from '../../setup/schemas'; +import type { + SharedTrackerUser, + TrackerIdentity, +} from '../../tickets/assignees'; +import type { + BuildingPodDetail, + NewTicket, + TicketChanges, + TicketListItem, + TicketPage, + TicketsResponse, + TrackerProjectsResponse, +} from '../shared/building'; +import type { + DesignSystemPage, + MadeDesignSystem, +} from '../shared/designSystem'; +import type { Made } from '../shared/prototypes'; +import type { + Favorite, + FavoriteKind, + ListSettings, + SavedView, + Visibility, +} from '../shared/savedViews'; +import type { AppState, ClipStatus, Panes, PodDetail } from '../shared/types'; +import type { NewPodRequest } from './NewPodForm'; + +/** Fired when a request comes back 401: the page shows the sign-in screen. */ +export const SIGNED_OUT_EVENT = 'lambda:signed-out'; + +/** + * The body as JSON — or a plain error saying the server didn't answer. An + * empty or non-JSON body never comes from our server (it always answers with + * JSON); it means the request died on the way, e.g. the Tailscale proxy + * giving up on a busy server, which `response.json()` reports as the + * baffling "Unexpected end of JSON input". + */ +const readJson = async (response: Response, path: string): Promise<unknown> => { + if (response.status === 401) { + window.dispatchEvent(new Event(SIGNED_OUT_EVENT)); + } + + const text = await response.text(); + + try { + return JSON.parse(text); + } catch { + throw new Error( + `The inbox server didn't answer ${path} (HTTP ${response.status}) — try again.` + ); + } +}; + +/** + * The server said no (`{ ok: false, error }`): its words, with the HTTP + * status — 409 is "Leslie's Mac mini is offline.", 400 the runner's own + * answer (a tmux window that's gone). + */ +export class ServerError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +const refusal = (response: Response, data: unknown): ServerError | null => + typeof data === 'object' && + data !== null && + 'ok' in data && + data.ok === false && + 'error' in data + ? new ServerError(response.status, String(data.error)) + : null; + +/** POST JSON; answers the `result` as it is, or throws the server's error text. */ +const postFor = async (path: string, body: unknown): Promise<unknown> => { + const response = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await readJson(response, path); + + const refused = refusal(response, data); + + if (refused !== null) { + throw refused; + } + + return typeof data === 'object' && data !== null && 'result' in data + ? data.result + : undefined; +}; + +/** `postFor`, with the same top-level shape check `getJson` makes. */ +const postAs = async <T>( + path: string, + body: unknown, + guard: (value: unknown) => value is T +): Promise<T> => { + const result = await postFor(path, body); + + if (!guard(result)) { + throw new Error(`Unexpected response from ${path}`); + } + + return result; +}; + +const post = async (path: string, body: unknown): Promise<string> => { + const result = await postFor(path, body); + + return result === undefined ? '' : String(result); +}; + +/** Upload one image; answers its absolute path on this machine (for the agent). */ +const uploadImage = async (file: File): Promise<string> => { + const response = await fetch('/api/upload', { + method: 'POST', + headers: { + 'content-type': file.type === '' ? 'application/octet-stream' : file.type, + // The server reads the type from the name when the browser gives none + // (a HEIC from Finder). + 'x-file-name': encodeURIComponent(file.name), + }, + body: file, + }); + const data = await readJson(response, '/api/upload'); + + if ( + typeof data !== 'object' || + data === null || + !('result' in data) || + typeof data.result !== 'string' + ) { + throw new Error( + typeof data === 'object' && data !== null && 'error' in data + ? String(data.error) + : 'The image did not upload.' + ); + } + + return data.result; +}; + +/** A section's clip, rendered now if it isn't cached (POST /api/tts/clip). */ +const ttsClip = async ( + podId: string, + text: string, + /** `now`: the player is waiting to play it; `soon`: look-ahead. */ + need: 'now' | 'soon' +): Promise<ClipStatus> => { + const response = await fetch('/api/tts/clip', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ podId, text, need }), + }); + const data = await readJson(response, '/api/tts/clip'); + const result = + typeof data === 'object' && data !== null && 'result' in data + ? data.result + : undefined; + + if ( + typeof result === 'object' && + result !== null && + 'status' in result && + result.status === 'ready' && + 'url' in result && + typeof result.url === 'string' && + 'duration' in result && + typeof result.duration === 'number' + ) { + return { status: 'ready', url: result.url, duration: result.duration }; + } + + return { status: 'unavailable', reason: 'not rendered' }; +}; + +const getJson = async <T>( + path: string, + guard: (value: unknown) => value is T +): Promise<T> => { + const response = await fetch(path); + const data = await readJson(response, path); + + if (!guard(data)) { + throw ( + refusal(response, data) ?? new Error(`Unexpected response from ${path}`) + ); + } + + return data; +}; + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null; +} + +// The server is ours and local; a shape check on the top-level keys is enough. +const isAppState = (value: unknown): value is AppState => + isObject(value) && Array.isArray(value.pods) && Array.isArray(value.inbox); + +const isVersions = (value: unknown): value is { versions: Question[] } => + isObject(value) && Array.isArray(value.versions); + +const isPodDetail = (value: unknown): value is PodDetail => + isObject(value) && isObject(value.pod) && Array.isArray(value.thread); + +const isBuildingDetail = (value: unknown): value is BuildingPodDetail => + isObject(value) && + isObject(value.pod) && + Array.isArray(value.thread) && + isObject(value.building) && + Array.isArray(value.steps); + +const isTickets = (value: unknown): value is TicketsResponse => + isObject(value) && Array.isArray(value.projects); + +const isTicketItem = (value: unknown): value is TicketListItem => + isObject(value) && + typeof value.id === 'string' && + typeof value.title === 'string' && + typeof value.status === 'string'; + +const isTicketPage = (value: unknown): value is TicketPage => + isObject(value) && isObject(value.ticket) && isObject(value.summary); + +/** `GET /api/tickets` also lists who holds which project. */ +export interface Holding { + runnerId: string; + computer: string; + projectId: string; + connected: boolean; +} + +const isMade = (value: unknown): value is Made => + isObject(value) && + Array.isArray(value.prototypes) && + Array.isArray(value.pictures); + +const isPanes = (value: unknown): value is Panes => + isObject(value) && + Array.isArray(value.others) && + Array.isArray(value.working); + +const isScreen = ( + value: unknown +): value is { screen: string; cols: number; rows: number } => + isObject(value) && + typeof value.screen === 'string' && + typeof value.cols === 'number' && + typeof value.rows === 'number'; + +const isSetupState = (value: unknown): value is SetupState => + isObject(value) && isObject(value.steps) && 'claudeExpiresOn' in value; + +const isFolderListing = (value: unknown): value is FolderListing => + isObject(value) && + typeof value.path === 'string' && + Array.isArray(value.folders); + +const isProjectResult = (value: unknown): value is { folder: string } => + isObject(value) && typeof value.folder === 'string'; + +/** + * Asked after a key connects whose login isn't the owner's yet: is it? + * (`yourEmail`: the email they sign in with, offered for a No.) + */ +export interface AskWho { + login: { name: string; email: string | null }; + yourEmail: string | null; +} + +export interface TicketsResult { + connectedAs: string | null; + askWho: AskWho | null; +} + +const isTicketsResult = (value: unknown): value is TicketsResult => + isObject(value) && 'connectedAs' in value && 'askWho' in value; + +const isYouResult = (value: unknown): value is { counts: string } => + isObject(value) && typeof value.counts === 'string'; + +/** First run (`/setup`): every step's work runs on the mothership. */ +export const setupApi = { + state: (options: { retryBrowser?: boolean } = {}) => + getJson( + options.retryBrowser === true + ? '/api/setup/state?retry=browser' + : '/api/setup/state', + isSetupState + ), + connectClaude: () => postFor('/api/setup/claude', {}), + sendCode: (code: string) => postFor('/api/setup/claude/code', { code }), + folders: async (folder: string): Promise<FolderListing> => { + const path = `/api/setup/folders?path=${encodeURIComponent(folder)}`; + const response = await fetch(path); + const data = await readJson(response, path); + const refused = refusal(response, data); + + if (refused !== null) { + throw refused; + } + + const result = isObject(data) ? data.result : undefined; + + if (!isFolderListing(result)) { + throw new Error(`Unexpected response from ${path}`); + } + + return result; + }, + project: (body: { folder: string } | { name: string }) => + postAs('/api/setup/project', body, isProjectResult), + tickets: (body: TicketsBodyType) => + postAs('/api/setup/tickets', body, isTicketsResult), + /** "That login is me": an email or a name, counted as the owner's. */ + you: (login: string) => + postAs('/api/setup/tickets/you', { login }, isYouResult), +}; + +const isSavedView = (value: unknown): value is SavedView => + isObject(value) && + typeof value.id === 'string' && + typeof value.name === 'string'; + +const isFavorites = (value: unknown): value is { favorites: Favorite[] } => + isObject(value) && Array.isArray(value.favorites); + +export const api = { + uploadImage, + /** An image dragged from a web page: the server fetches the link. */ + uploadImageUrl: (url: string) => post('/api/upload-url', { url }), + state: () => getJson('/api/state', isAppState), + pod: (podId: string) => + getJson(`/api/pod/${encodeURIComponent(podId)}`, isPodDetail), + /** What the pod has made: its prototypes and pictures (Made here). */ + made: (podId: string) => + getJson(`/api/made/${encodeURIComponent(podId)}`, isMade), + /** A checkpoint card's kept earlier versions, oldest first. */ + versions: (podId: string, qid: string) => + getJson( + `/api/versions?podId=${encodeURIComponent( + podId + )}&qid=${encodeURIComponent(qid)}`, + isVersions + ).then((data) => data.versions), + /** The Agents panel's other windows, and which agents are mid-turn. */ + panes: (podId: string) => + getJson(`/api/panes/${encodeURIComponent(podId)}`, isPanes), + term: (podId: string, window: string) => + getJson( + `/api/term?podId=${encodeURIComponent(podId)}&window=${encodeURIComponent( + window + )}`, + isScreen + ), + answer: (podId: string, qid: string, text: string) => + post('/api/answer', { podId, qid, text }), + acceptRec: (podId: string, qid: string) => + post('/api/answer', { podId, qid, acceptRec: true }), + checkpointComplete: (podId: string, cp: number, keepOne = false) => + post('/api/checkpoint-complete', { podId, cp, keepOne }), + /** To the dev directly; `about`: the item that's up (context, not an answer). */ + chat: (podId: string, text: string, about?: string | null) => + post('/api/chat', { + podId, + text, + ...(about === null || about === undefined ? {} : { about }), + }), + carryOn: (podId: string) => post('/api/carry-on', { podId }), + rename: (podId: string, title: string) => + post('/api/rename', { podId, title }), + deletePod: (podId: string) => post('/api/delete', { podId }), + nudge: (podId: string) => post('/api/nudge', { podId }), + newPod: (request: NewPodRequest) => post('/api/pods/new', { ...request }), + /** A freeform pod → the structured flow, keeping the conversation. */ + structure: (podId: string) => post('/api/structure', { podId }), + /** Skip a stage of the pod's flow (the workflow panel). */ + skip: (podId: string, stage: string) => post('/api/skip', { podId, stage }), + /** The workflow panel's Render Full | ASCII switch. */ + renderMode: (podId: string, mode: RenderMode) => + post('/api/render-mode', { podId, mode }), + priority: (order: string[]) => post('/api/priority', { order }), + /** `hold`: sent with ⌘⏎ — the pod steps aside until the chat replies. */ + chatSend: (podId: string, about: string | null, text: string, hold = false) => + post('/api/chat/send', { + podId, + text, + ...(about === null ? {} : { about }), + ...(hold ? { hold: true } : {}), + }), + chatWarm: (podId: string) => post('/api/chat/warm', { podId }), + /** Ticks on a final checkpoint's suggested judgements (saved as they make them). */ + keepJudgements: (podId: string, qid: string, kept: number[]) => + post('/api/judgements/keep', { podId, qid, kept }), + requestAdd: ( + podId: string, + summary: string, + quote: string, + qid: string | null + ) => + post('/api/request/add', { + podId, + summary, + quote, + ...(qid === null ? {} : { qid }), + }), + forkWarm: (podId: string, qid: string) => + post('/api/fork/warm', { podId, qid }), + forkSend: (podId: string, qid: string, text: string) => + post('/api/fork/send', { podId, qid, text }), + forkClose: (podId: string, qid: string, text: string) => + post('/api/fork/close', { podId, qid, text }), + forkPark: (podId: string, qid: string) => + post('/api/fork/park', { podId, qid }), + forkDiscard: (podId: string, qid: string) => + post('/api/fork/discard', { podId, qid }), + termKeys: (podId: string, window: string, hex: string[]) => + post('/api/term/keys', { podId, window, hex }), + ttsClip, + /** What's on screen — re-prioritizes the audio queue at once. */ + focus: (podId: string | null, key: string | null): Promise<void> => + fetch('/api/focus', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ podId, key }), + }).then(() => undefined), + + // ── you, your devices and computers, your projects ────────────────────── + /** Signed in? `null` when not. */ + session: async (): Promise<Session | null> => { + const response = await fetch('/api/session'); + + if (response.status === 401) { + return null; + } + + const data = await readJson(response, '/api/session'); + + if (!isSession(data)) { + throw new Error('Unexpected response from /api/session'); + } + + return data; + }, + /** Sign this device in with a code from Add a device. */ + pair: (code: string) => postFor('/api/auth/pair', { code }), + signOut: () => postFor('/api/auth/signout', {}), + devices: () => getJson('/api/devices', isList<'devices', Device>('devices')), + deviceCode: () => postAs('/api/devices/code', {}, isCode), + signOutDevice: (deviceId: string) => + post('/api/devices/signout', { deviceId }), + computers: () => + getJson( + '/api/computers', + ( + value: unknown + ): value is { computers: Computer[]; shared?: SharedTrackerUser[] } => + isObject(value) && Array.isArray(value.computers) + ), + computerCode: () => postAs('/api/computers/code', {}, isComputerCode), + tailscale: () => getJson('/api/computers/tailscale', isTailscaleStep), + listenOnTailscale: () => + postAs('/api/computers/tailscale/listen', {}, isJoinServer), + addProject: (folder: string) => + postAs('/api/projects/add', { folder }, isAddedProject), + newProject: (name: string, idea: string) => + postAs('/api/projects/new', { name, idea }, isNewProject), + process: (projectId: string) => + getJson( + `/api/process?projectId=${encodeURIComponent(projectId)}`, + isProcess + ), + switchStep: ( + projectId: string, + process: 'planning' | 'building', + step: string, + on: boolean + ) => + postAs('/api/process/switch', { projectId, process, step, on }, isProcess), + designSystem: (projectId: string) => + getJson( + `/api/projects/${encodeURIComponent(projectId)}/design-system`, + isDesignSystemPage + ), + /** Make it: the setup ticket, or the open one (`existing`). */ + makeDesignSystem: (projectId: string) => + postAs( + `/api/projects/${encodeURIComponent(projectId)}/design-system/make`, + {}, + isMadeDesignSystem + ), + // ── building pods and the Tickets screen ──────────────────────────────── + building: (podId: string) => + getJson(`/api/building/${encodeURIComponent(podId)}`, isBuildingDetail), + /** Start building a runner's project; refused when it's already building. */ + buildingStart: (projectId: string, runnerId: string) => + post('/api/building/start', { projectId, runnerId }), + pause: (podId: string) => post('/api/pause', { podId }), + resume: (podId: string) => post('/api/resume', { podId }), + /** To a building pod's Product manager. */ + pmChat: (podId: string, text: string) => + post('/api/pm-chat', { podId, text }), + tickets: () => + getJson( + '/api/tickets', + (value: unknown): value is TicketsResponse & { holdings?: Holding[] } => + isTickets(value) + ), + /** `opened`: the page's first load, not a refresh (a failed summary retries). */ + ticketPage: (projectId: string, ticketId: string, opened = false) => + getJson( + `/api/tickets/${encodeURIComponent(projectId)}/${encodeURIComponent( + ticketId + )}${opened ? '?opened=1' : ''}`, + isTicketPage + ), + /** Each codebase's tracker projects (Linear's, or a markdown codebase's). */ + trackerProjects: () => + getJson( + '/api/tracker-projects', + (value: unknown): value is { result: TrackerProjectsResponse } => + isObject(value) && + isObject(value.result) && + Array.isArray(value.result.codebases) + ).then((data) => data.result), + views: () => + getJson( + '/api/views', + (value: unknown): value is { result: { views: SavedView[] } } => + isObject(value) && + isObject(value.result) && + Array.isArray(value.result.views) + ).then((data) => data.result.views), + newView: (view: { + name: string; + icon?: string | null; + description?: string; + visibility?: Visibility; + settings: ListSettings; + }) => postAs('/api/views/new', view, isSavedView), + updateView: ( + id: string, + changes: Partial<{ + name: string; + icon: string | null; + description: string; + visibility: Visibility; + settings: ListSettings; + }> + ) => postAs('/api/views/update', { id, changes }, isSavedView), + deleteView: (id: string) => postFor('/api/views/delete', { id }), + /** Import from Linear: what came in, and what didn't translate. */ + importViews: () => + postAs( + '/api/views/import', + {}, + ( + value: unknown + ): value is { + added: number; + updated: number; + favorites: number; + notes: Array<{ view: string; dropped: string[] }>; + } => isObject(value) && typeof value.added === 'number' + ), + favorites: () => + getJson( + '/api/favorites', + (value: unknown): value is { result: { favorites: Favorite[] } } => + isObject(value) && + isObject(value.result) && + Array.isArray(value.result.favorites) + ).then((data) => data.result.favorites), + favorite: (kind: FavoriteKind, ref: string, on: boolean, label?: string) => + postAs( + on ? '/api/favorites/add' : '/api/favorites/remove', + on && label !== undefined ? { kind, ref, label } : { kind, ref }, + isFavorites + ).then((data) => data.favorites), + orderFavorites: (ids: string[]) => + postAs('/api/favorites/order', { ids }, isFavorites).then( + (data) => data.favorites + ), + /** A change from the Tickets screen: the ticket after it. */ + updateTicket: (projectId: string, ticketId: string, changes: TicketChanges) => + postAs( + '/api/tickets/update', + { projectId, ticketId, changes }, + isTicketItem + ), + /** A new ticket from the New ticket dialog. */ + newTicket: (projectId: string, ticket: NewTicket) => + postAs('/api/tickets/new', { projectId, ticket }, isTicketItem), + /** "Plan more": reopen the planning pod with this ask; tickets back to Planning. */ + planMore: ( + projectId: string, + podId: string, + ticketIds: string[], + text: string + ) => postFor('/api/lambdas/plan-more', { projectId, podId, ticketIds, text }), + /** Moves a ticket right after `after` among its siblings (null: first). */ + moveTicket: (projectId: string, ticketId: string, after: string | null) => + postFor('/api/tickets/move', { projectId, ticketId, after }), + /** "Talk about this ticket": who it went to. */ + talkTicket: (projectId: string, ticketId: string, text: string) => + postFor('/api/tickets/talk', { projectId, ticketId, text }), + analytics: (projectId: string) => + getJson( + `/api/analytics?projectId=${encodeURIComponent(projectId)}`, + (value: unknown): value is { analytics: Analytics } => + isObject(value) && isObject(value.analytics) + ).then((data) => data.analytics), +}; + +export interface Session { + owner: { name: string; initials: string }; + deviceId: string; +} + +export interface Device { + id: string; + label: string; + lastSeenAt: number; + current: boolean; +} + +export interface Code { + code: string; + expiresAt: number; +} + +/** Add a computer's step 1, as the mothership found Tailscale. */ +export interface TailscaleStep { + step: string; + state: 'up' | 'signed-out' | 'missing'; + /** With `up`: this computer's tailnet address and short name. */ + address?: string; + label?: string; +} + +export interface Computer { + id: string; + name: string; + project: string | null; + connected: boolean; + state: string; + /** Who it is in each tracker it has a key for. Left out by an older Lambda. */ + trackers?: TrackerIdentity[]; +} + +export interface ProcessStep { + id: string; + label: string; + setting: 'on' | 'off' | 'locked'; + added: boolean; +} + +export interface ProcessStage { + id: string; + label: string; + steps: ProcessStep[]; +} + +export interface ProcessSettings { + planning: ProcessStage[]; + building: ProcessStage[]; +} + +export interface Average { + name: string; + averageMs: number; + count: number; +} + +export interface Analytics { + warnings: string[]; + steps: Average[]; + stages: Average[]; + ships: { + name: string; + totalMs: number; + runsUntilGreen: number; + fixingMs: number | null; + }[]; + empty: boolean; + /** The line shown instead, when there's nothing to show (the server's). */ + notice: string | null; +} + +function isList<K extends string, T>(key: K) { + return (value: unknown): value is Record<K, T[]> => + isObject(value) && Array.isArray(value[key]); +} + +function isSession(value: unknown): value is Session { + return isObject(value) && isObject(value.owner); +} + +function isCode(value: unknown): value is Code { + return isObject(value) && typeof value.code === 'string'; +} + +function isComputerCode(value: unknown): value is Code & { command: string } { + return ( + isObject(value) && + typeof value.code === 'string' && + typeof value.command === 'string' + ); +} + +function isTailscaleStep(value: unknown): value is TailscaleStep { + return ( + isObject(value) && + typeof value.step === 'string' && + (value.state === 'up' || + value.state === 'signed-out' || + value.state === 'missing') + ); +} + +function isJoinServer(value: unknown): value is { server: string } { + return isObject(value) && typeof value.server === 'string'; +} + +function isAddedProject(value: unknown): value is { projectId: string } { + return isObject(value) && typeof value.projectId === 'string'; +} + +function isNewProject( + value: unknown +): value is { projectId: string; podId: string | null } { + return ( + isObject(value) && + typeof value.projectId === 'string' && + (value.podId === null || typeof value.podId === 'string') + ); +} + +function isProcess(value: unknown): value is ProcessSettings { + return ( + isObject(value) && + Array.isArray(value.planning) && + Array.isArray(value.building) + ); +} + +function isDesignSystemPage(value: unknown): value is DesignSystemPage { + return ( + isObject(value) && + typeof value.project === 'string' && + typeof value.dir === 'string' && + (value.system === null || isObject(value.system)) + ); +} + +function isMadeDesignSystem(value: unknown): value is MadeDesignSystem { + return ( + isObject(value) && + typeof value.id === 'string' && + typeof value.title === 'string' && + typeof value.existing === 'boolean' + ); +} diff --git a/src/app/client/chatPlaylist.ts b/src/app/client/chatPlaylist.ts new file mode 100644 index 0000000..b5f3023 --- /dev/null +++ b/src/app/client/chatPlaylist.ts @@ -0,0 +1,64 @@ +import type { ThreadMessage } from '../../pod-store'; +import { chunkSpeech, speakable } from '../shared/speech'; + +/** + * The Chat panel as something to listen to. Nothing plays on its own: a + * message's 🔊 plays it and carries on through the newer ones until the + * newest — including any that arrive while it plays. Clips render on demand + * (POST /api/tts/clip), one message ahead. + * + * Each message is cut into chunks at sentence ends (chunkSpeech): a short + * first one, so the first words come quickly, then longer ones. + */ + +export interface ChatTrack { + id: string; + chunks: string[]; +} + +/** What's said for one message: the owner's own are introduced as theirs. */ +export const messageSpeech = (message: ThreadMessage): string => { + const spoken = speakable(message.text); + + if (spoken === '' || message.from !== 'human') { + return spoken; + } + + return `You said: ${spoken}`; +}; + +/** The chat history as a playlist: system notes and silent messages skipped. */ +export const chatTracks = (history: ThreadMessage[]): ChatTrack[] => + history + .filter((message) => message.from !== 'system') + .map((message) => ({ + id: message.id, + chunks: chunkSpeech(messageSpeech(message)), + })) + .filter((track) => track.chunks.length > 0); + +/** + * Where to go after chunk `chunk` of message `id`: the next chunk, else the + * next message's first, else null (the newest is done). A message that has + * vanished from the list ends the playlist. + */ +export const nextChunk = ( + tracks: ChatTrack[], + id: string, + chunk: number +): { id: string; chunk: number } | null => { + const index = tracks.findIndex((track) => track.id === id); + const track = tracks[index]; + + if (track === undefined) { + return null; + } + + if (chunk + 1 < track.chunks.length) { + return { id, chunk: chunk + 1 }; + } + + const following = tracks[index + 1]; + + return following === undefined ? null : { id: following.id, chunk: 0 }; +}; diff --git a/src/app/client/chime.ts b/src/app/client/chime.ts new file mode 100644 index 0000000..541070e --- /dev/null +++ b/src/app/client/chime.ts @@ -0,0 +1,105 @@ +/** + * "Something's ready": a bright two-note rising chime, synthesized with Web + * Audio (no asset to ship). Played when the inbox goes from empty to not. + * Browsers only allow audio after a click or keypress on the page; before + * that it is silently skipped. + */ + +const context: { current: AudioContext | null } = { current: null }; + +const audioContext = (): AudioContext | null => { + if (typeof window === 'undefined' || !('AudioContext' in window)) { + return null; + } + + if (context.current === null) { + context.current = new AudioContext(); + } + + return context.current; +}; + +/** One bell-like note: a sine plus a soft octave, quick attack, ringing decay. */ +const note = ( + ctx: AudioContext, + frequency: number, + at: number, + length: number +): void => { + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.0001, at); + gain.gain.exponentialRampToValueAtTime(0.35, at + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, at + length); + gain.connect(ctx.destination); + + [ + { type: 'sine' as const, ratio: 1, level: 1 }, + { type: 'triangle' as const, ratio: 2, level: 0.25 }, + ].forEach(({ type, ratio, level }) => { + const voice = ctx.createGain(); + voice.gain.value = level; + voice.connect(gain); + const oscillator = ctx.createOscillator(); + oscillator.type = type; + oscillator.frequency.value = frequency * ratio; + oscillator.connect(voice); + oscillator.start(at); + oscillator.stop(at + length + 0.05); + }); +}; + +export const playReadyChime = (): void => { + const ctx = audioContext(); + + if (ctx === null) { + return; + } + + const start = (): void => { + const now = ctx.currentTime + 0.02; + // E5 → B5 → E6: rising and unmistakable, under a second. + note(ctx, 659.25, now, 0.45); + note(ctx, 987.77, now + 0.13, 0.5); + note(ctx, 1318.51, now + 0.26, 0.7); + }; + + if (ctx.state === 'suspended') { + ctx + .resume() + .then(start) + .catch(() => undefined); + + return; + } + + start(); +}; + +/** + * "The chat answered": two soft falling notes (B5 → G5), quieter and shorter + * than the ready chime, so the two are never confused. Every chat reply. + */ +export const playChatChime = (): void => { + const ctx = audioContext(); + + if (ctx === null) { + return; + } + + const start = (): void => { + const now = ctx.currentTime + 0.02; + note(ctx, 987.77, now, 0.3); + note(ctx, 783.99, now + 0.11, 0.45); + }; + + if (ctx.state === 'suspended') { + ctx + .resume() + .then(start) + .catch(() => undefined); + + return; + } + + start(); +}; diff --git a/src/app/client/clipWait.ts b/src/app/client/clipWait.ts new file mode 100644 index 0000000..7bb5bcb --- /dev/null +++ b/src/app/client/clipWait.ts @@ -0,0 +1,31 @@ +/** + * Waiting for a section's clip. No timeout and no other voice (see + * usePlayer.ts): wait as long as the render takes, and when a clip comes back + * unavailable — a failed render, a Kokoro worker that restarted — ask again + * `retries` more times, `gapMs` apart. Null means it really can't be had. + */ +export const waitForClip = async <T>( + first: Promise<T | null>, + args: { + again: () => Promise<T | null>; + retries: number; + gapMs: number; + sleep: (ms: number) => Promise<unknown>; + /** The listener moved on (stop, seek, another item): stop asking. */ + cancelled: () => boolean; + } +): Promise<T | null> => { + const ready = await first; + + if (ready !== null || args.retries === 0 || args.cancelled()) { + return ready; + } + + await args.sleep(args.gapMs); + + if (args.cancelled()) { + return null; + } + + return waitForClip(args.again(), { ...args, retries: args.retries - 1 }); +}; diff --git a/src/app/client/index.html b/src/app/client/index.html new file mode 100644 index 0000000..61addcc --- /dev/null +++ b/src/app/client/index.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Lambda + + + +
+ + + diff --git a/src/app/client/keys.ts b/src/app/client/keys.ts new file mode 100644 index 0000000..9f216c3 --- /dev/null +++ b/src/app/client/keys.ts @@ -0,0 +1,28 @@ +/** + * xterm hands us typed data as a string (including escape sequences for arrows + * etc.). tmux `send-keys -H` takes raw bytes as hex, which carries every key + * through unchanged — no key-name mapping table to keep in sync. + */ +export const toHexBytes = (data: string): string[] => + Array.from(new TextEncoder().encode(data)).map((byte) => + byte.toString(16).padStart(2, '0') + ); + +/** Inputs that take typing — not switches, sliders or buttons. */ +const TEXT_INPUTS = new Set([ + 'text', + 'search', + 'email', + 'url', + 'tel', + 'password', + 'number', +]); + +/** Is the user typing in a text field (so playback shortcuts must not fire)? */ +export const isTypingTarget = (target: EventTarget | null): boolean => + target instanceof HTMLElement && + (target.tagName === 'TEXTAREA' || + (target instanceof HTMLInputElement && TEXT_INPUTS.has(target.type)) || + target.isContentEditable || + target.closest('.xterm') !== null); diff --git a/src/app/client/loading.ts b/src/app/client/loading.ts new file mode 100644 index 0000000..cd5837b --- /dev/null +++ b/src/app/client/loading.ts @@ -0,0 +1,36 @@ +/** An error's own message: "Leslie's Mac mini is offline.", not "Error: …". */ +export const errorText = (caught: unknown): string => + caught instanceof Error ? caught.message : String(caught); + +export interface LoadTarget { + value: (value: T) => void; + /** null once a request succeeds: an old error doesn't sit under good data. */ + error: (message: string | null) => void; +} + +/** + * Loads a page's data, keeping only the newest request's answer: switch + * projects quickly and the first project's slower answer can't land under + * the second one's picker. + */ +export const latestLoader = (target: LoadTarget) => { + const count = { latest: 0 }; + + return async (request: Promise): Promise => { + count.latest += 1; + const mine = count.latest; + + try { + const value = await request; + + if (mine === count.latest) { + target.error(null); + target.value(value); + } + } catch (caught) { + if (mine === count.latest) { + target.error(errorText(caught)); + } + } + }; +}; diff --git a/src/app/client/main.tsx b/src/app/client/main.tsx new file mode 100644 index 0000000..1c31ae3 --- /dev/null +++ b/src/app/client/main.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { Root } from './Root'; +import './styles.css'; +import { startUiLog } from './uiLog'; + +const root = document.getElementById('root'); + +if (root === null) { + throw new Error('#root missing from index.html'); +} + +startUiLog(); + +createRoot(root).render( + + + +); diff --git a/src/app/client/page.ts b/src/app/client/page.ts new file mode 100644 index 0000000..c2c1fc3 --- /dev/null +++ b/src/app/client/page.ts @@ -0,0 +1,49 @@ +import { ACCOUNT_PAGES, type AccountPage, type Route } from '../shared/route'; + +/** + * The one page up instead of the inbox or a Lambda: a new pod, Time, Tickets + * or an account-menu page. One value, so two can never be up at once. + */ +export type Page = + | 'new' + | 'time' + | 'tickets' + | 'projects' + | 'views' + | AccountPage; + +/** The page a URL names, or null for the inbox and a Lambda. */ +export const pageOfRoute = (route: Route): Page | null => { + if (route.page === 'account') { + return route.which; + } + + // A project's or a view's page is the Tickets list, scoped to it. + if (route.page === 'project' || route.page === 'view') { + return 'tickets'; + } + + return route.page === 'new' || + route.page === 'time' || + route.page === 'tickets' || + route.page === 'projects' || + route.page === 'views' + ? route.page + : null; +}; + +/** The account-menu page that's up, if it's one of those. */ +export const accountPageOf = (page: Page | null): AccountPage | null => + ACCOUNT_PAGES.find((known) => known === page) ?? null; + +/** Settings' tabs, one per settings page. */ +export type SettingsTab = 'devices' | 'process' | 'design-system'; + +/** The Settings tab an account page shows: Devices for anything not a tab. */ +export const settingsTabOf = (page: AccountPage | null): SettingsTab => { + if (page === 'settings/process') { + return 'process'; + } + + return page === 'settings/design-system' ? 'design-system' : 'devices'; +}; diff --git a/src/app/client/planPlace.ts b/src/app/client/planPlace.ts new file mode 100644 index 0000000..21dce20 --- /dev/null +++ b/src/app/client/planPlace.ts @@ -0,0 +1,150 @@ +/** + * Where the owner was in each item's plan — the audio's position (as a section + * and the seconds into it, so it survives clips' real lengths replacing the + * estimates) and how far the Plan rail was scrolled — so a reload, or coming + * back to the item later, picks up where they left off. Kept in this browser + * only, for the most recent MAX_PLACES items. + */ + +const STORAGE_KEY = 'pods.places'; +const MAX_PLACES = 60; + +export interface PlanPlace { + audio?: { section: string; offset: number }; + scrollTop?: number; + /** The card at the top of the rail, and how far into it — see anchorAt. */ + anchor?: ScrollAnchor; + /** Last written (ms), to keep the newest. */ + at: number; +} + +type Places = Record; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const readAll = (): Places => { + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(STORAGE_KEY) ?? '{}' + ); + + if (!isRecord(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).flatMap(([key, value]): [string, PlanPlace][] => { + if (!isRecord(value) || typeof value.at !== 'number') { + return []; + } + + const { audio, scrollTop, anchor } = value; + + return [ + [ + key, + { + at: value.at, + scrollTop: typeof scrollTop === 'number' ? scrollTop : undefined, + anchor: + isRecord(anchor) && + typeof anchor.key === 'string' && + typeof anchor.offset === 'number' + ? { key: anchor.key, offset: anchor.offset } + : undefined, + audio: + isRecord(audio) && + typeof audio.section === 'string' && + typeof audio.offset === 'number' + ? { section: audio.section, offset: audio.offset } + : undefined, + }, + ], + ]; + }) + ); + } catch { + return {}; + } +}; + +export const loadPlace = (itemKey: string): PlanPlace | undefined => + readAll()[itemKey]; + +export const savePlace = ( + itemKey: string, + change: Partial> +): void => { + const places = readAll(); + const next: Places = { + ...places, + [itemKey]: { ...places[itemKey], ...change, at: Date.now() }, + }; + const kept = Object.fromEntries( + Object.entries(next) + .sort(([, a], [, b]) => b.at - a.at) + .slice(0, MAX_PLACES) + ); + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(kept)); + } catch { + // Private mode or full: the place just isn't remembered. + } +}; + +/** + * A place in the Plan rail as "this card, this far into it", so it holds when + * cards above it grow, shrink, appear or go (a new version of the plan). + * Cards are the `[data-card]` screens, tickets and decisions; between them, + * the `[data-section]` blocks. Pixels from the top are only the fallback. + */ +export interface ScrollAnchor { + key: string; + offset: number; +} + +const ANCHORS = '[data-card], [data-section]'; + +const keyOfAnchor = (element: HTMLElement): string | undefined => + element.dataset.card !== undefined + ? `card:${element.dataset.card}` + : element.dataset.section !== undefined + ? `section:${element.dataset.section}` + : undefined; + +/** The innermost card or section across the rail's top edge. */ +export const anchorAt = (box: HTMLElement): ScrollAnchor | undefined => { + const { top } = box.getBoundingClientRect(); + const [inner] = [...box.querySelectorAll(ANCHORS)] + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect }) => rect.top <= top + 1 && rect.bottom > top + 1) + .sort((a, b) => a.rect.height - b.rect.height); + const key = inner === undefined ? undefined : keyOfAnchor(inner.element); + + return inner === undefined || key === undefined + ? undefined + : { key, offset: top - inner.rect.top }; +}; + +/** Scroll so the anchor sits where it was. False: it isn't on the page. */ +export const scrollToAnchor = ( + box: HTMLElement, + anchor: ScrollAnchor +): boolean => { + const element = [...box.querySelectorAll(ANCHORS)].find( + (candidate) => keyOfAnchor(candidate) === anchor.key + ); + + if (element === undefined) { + return false; + } + + box.scrollTop += + element.getBoundingClientRect().top + + anchor.offset - + box.getBoundingClientRect().top; + + return true; +}; diff --git a/src/app/client/processControls.ts b/src/app/client/processControls.ts new file mode 100644 index 0000000..e01c4db --- /dev/null +++ b/src/app/client/processControls.ts @@ -0,0 +1,54 @@ +import { type LoadTarget, latestLoader } from './loading'; + +/** + * Settings → Process's reads and switches, through one loader: a read that + * started before a switch and answers after it is dropped, so the switch + * stays where it was flipped; and a switch that lands clears an old error. + * A switch goes to the computer and back (up to 60 s): `busy` is true for + * that long, so the page can hold the switches still. + */ +export const processControls = ( + io: { + read: (projectId: string) => Promise; + flip: ( + projectId: string, + process: 'planning' | 'building', + step: string, + on: boolean + ) => Promise; + }, + target: LoadTarget & { busy?: (busy: boolean) => void } +) => { + const load = latestLoader(target); + const flips = { inFlight: 0 }; + + return { + read: (projectId: string) => load(io.read(projectId)), + flip: async ( + projectId: string, + process: 'planning' | 'building', + step: string, + on: boolean + ) => { + flips.inFlight += 1; + target.busy?.(true); + + try { + await load(io.flip(projectId, process, step, on)); + } finally { + flips.inFlight -= 1; + target.busy?.(flips.inFlight > 0); + } + }, + }; +}; + +/** + * Whether a heartbeat reads the settings again. Only after an error (the + * computer was offline: it comes back on its own), and never over a flip on + * its way, whose answer the newer read would drop. + */ +export const rereadsOnTick = (now: { + error: string | null; + busy: boolean; +}): boolean => now.error !== null && !now.busy; diff --git a/src/app/client/projects.ts b/src/app/client/projects.ts new file mode 100644 index 0000000..b6b2f48 --- /dev/null +++ b/src/app/client/projects.ts @@ -0,0 +1,120 @@ +import type { AccountPage } from '../shared/route'; +import type { LambdaInfo, ProjectInfo, WaitingPod } from '../shared/types'; + +/** + * Projects in the app. With one project it looks as it always has: no + * headings, no tags. With two or more, the sidebar groups Lambdas under + * project headings and items carry a small project tag. + */ + +export const ACCOUNT_PAGE_TITLES: Record = { + computers: 'Computers', + analytics: 'Analytics', + 'settings/devices': 'Settings', + 'settings/process': 'Settings', + 'settings/design-system': 'Settings', +}; + +export const showsProjects = (lambda: LambdaInfo | undefined): boolean => + (lambda?.projects.length ?? 0) >= 2; + +export const projectOfPod = ( + podId: string, + lambda: LambdaInfo | undefined +): ProjectInfo | undefined => { + const projectId = lambda?.podProjects[podId]; + + return lambda?.projects.find((project) => project.id === projectId); +}; + +/** "Dog park map · Pawnee Parks site" with two or more projects; else as it is. */ +export const withProjectTag = ( + label: string, + podId: string, + lambda: LambdaInfo | undefined +): string => { + const project = showsProjects(lambda) + ? projectOfPod(podId, lambda) + : undefined; + + return project === undefined ? label : `${label} · ${project.name}`; +}; + +export interface PodGroup { + /** null: no heading (one project), or the trailing group (`other`). */ + project: ProjectInfo | null; + /** The pods no project claims, after the project groups. */ + other: boolean; + pods: T[]; + waiting: WaitingPod[]; +} + +/** + * The sidebar's groups: one, unheaded, until there are two projects. Then + * one per project with pods, plus a trailing group for pods no project + * claims, so none drops out of sight. With nothing to show there is still + * one empty group, which says so. + */ +export const groupByProject = ( + pods: T[], + lambda: LambdaInfo | undefined +): PodGroup[] => { + const waiting = lambda?.waiting ?? []; + + if (!showsProjects(lambda) || lambda === undefined) { + return [{ project: null, other: false, pods, waiting }]; + } + + const ids = new Set(lambda.projects.map((project) => project.id)); + const claimed = (projectId: string | undefined): boolean => + projectId !== undefined && ids.has(projectId); + const groups: PodGroup[] = [ + ...lambda.projects.map((project) => ({ + project, + other: false, + pods: pods.filter( + (summary) => lambda.podProjects[summary.pod.id] === project.id + ), + waiting: waiting.filter((pod) => pod.projectId === project.id), + })), + { + project: null, + other: true, + pods: pods.filter( + (summary) => !claimed(lambda.podProjects[summary.pod.id]) + ), + waiting: waiting.filter((pod) => !claimed(pod.projectId)), + }, + ].filter((group) => group.pods.length > 0 || group.waiting.length > 0); + + return groups.length > 0 + ? groups + : [{ project: null, other: false, pods: [], waiting: [] }]; +}; + +/** Where a new idea goes: the last project used, else the first. */ +export const defaultProjectId = ( + lambda: LambdaInfo | undefined +): string | null => lambda?.lastProjectId ?? lambda?.projects[0]?.id ?? null; + +/** + * The project a page shows: the one picked, while it still exists, else the + * default. Worked out on every render, so a page opened before any project + * existed picks one up as soon as it's added. + */ +export const chosenProjectId = ( + chosen: string | null, + lambda: LambdaInfo | undefined +): string | null => + chosen !== null && + (lambda?.projects ?? []).some((project) => project.id === chosen) + ? chosen + : defaultProjectId(lambda); + +/** The ticket shortcut under the box, in the project's own prefix; none without one. */ +export const ticketShortcut = ( + prefix: string | null | undefined +): { label: string; fill: string } | null => + prefix === null || prefix === undefined || prefix === '' + ? null + : { label: `Plan a ticket or epic — ${prefix}-…`, fill: `Plan ${prefix}-` }; diff --git a/src/app/client/sessionStatus.ts b/src/app/client/sessionStatus.ts new file mode 100644 index 0000000..a36b481 --- /dev/null +++ b/src/app/client/sessionStatus.ts @@ -0,0 +1,24 @@ +import type { Session } from './api'; + +export type Status = + | { kind: 'checking' } + | { kind: 'out' } + | { kind: 'unreachable' } + | { kind: 'in'; session: Session }; + +/** + * What the session check means for the first screen. A 401 (no session) is + * the sign-in screen; a failed check (the mothership down, a 500) is not, as + * a code can't help with that. + */ +export const sessionStatus = ( + outcome: { session: Session | null } | { error: unknown } +): Status => { + if ('error' in outcome) { + return { kind: 'unreachable' }; + } + + return outcome.session === null + ? { kind: 'out' } + : { kind: 'in', session: outcome.session }; +}; diff --git a/src/app/client/setupSteps.ts b/src/app/client/setupSteps.ts new file mode 100644 index 0000000..97cbd12 --- /dev/null +++ b/src/app/client/setupSteps.ts @@ -0,0 +1,31 @@ +import type { SetupState } from '../../setup/routes'; + +/** First run's three steps, in order. Small, so the first chunk can ask. */ + +export type Step = 'claude' | 'project' | 'tickets'; + +export const STEPS: { step: Step; label: string }[] = [ + { step: 'claude', label: 'Connect Claude' }, + { step: 'project', label: 'Codebase' }, + { step: 'tickets', label: 'Tickets' }, +]; + +export const firstUnfinished = (state: SetupState): Step | null => + STEPS.map(({ step }) => step).find((step) => !state.steps[step].done) ?? null; + +/** + * Where a finished step leads: the next step still to do, so redoing one + * step (Reconnect Claude, Run setup again…) never makes the person redo the + * others. `null` once nothing is left. + */ +export const stepAfter = (after: Step, state: SetupState): Step | null => { + const index = STEPS.findIndex(({ step }) => step === after); + const ahead = STEPS.slice(index + 1).find( + ({ step }) => !state.steps[step].done + ); + + return ahead?.step ?? firstUnfinished(state); +}; + +export const setupDone = (state: SetupState): boolean => + firstUnfinished(state) === null; diff --git a/src/app/client/speech.ts b/src/app/client/speech.ts new file mode 100644 index 0000000..7939817 --- /dev/null +++ b/src/app/client/speech.ts @@ -0,0 +1,119 @@ +/** + * Browser-side speech helpers. What gets spoken (and the script/section + * shaping) lives in ../shared/speech so the server pre-renders the identical + * text; this file only holds the rate and Autoplay settings. There is no + * browser-voice fallback (see usePlayer.ts). + */ +import type { Screen } from '../../pod-store'; + +export * from '../shared/speech'; + +export const MIN_RATE = 1; +export const MAX_RATE = 3.5; + +export const clampRate = (rate: number): number => + Math.min(MAX_RATE, Math.max(MIN_RATE, Math.round(rate * 4) / 4)); + +const RATE_KEY = 'pods.speechRate'; + +export const loadRate = (): number => { + try { + const stored = Number(window.localStorage.getItem(RATE_KEY)); + + return Number.isFinite(stored) && stored > 0 ? clampRate(stored) : 2.5; + } catch { + return 2.5; + } +}; + +export const saveRate = (rate: number): void => { + try { + window.localStorage.setItem(RATE_KEY, String(rate)); + } catch { + // Private window / blocked storage: the rate just won't persist. + } +}; + +const AUTOPLAY_KEY = 'pods.autoplay'; + +/** + * The Autoplay switch: when on, an item starts playing as it arrives and live + * conversation replies are read aloud as they land. Off by default — nothing + * plays until the owner presses play. + */ +/** + * Autoplay isn't offered for now (2026-10-17: the switch is gone from + * the player, the feature kept in case it comes back). While it isn't, it's off + * — whatever an earlier switch saved. + */ +export const AUTOPLAY_OFFERED = false; + +export const loadAutoplay = (): boolean => { + if (!AUTOPLAY_OFFERED) { + return false; + } + + try { + return window.localStorage.getItem(AUTOPLAY_KEY) === 'on'; + } catch { + return false; + } +}; + +export const saveAutoplay = (on: boolean): void => { + try { + window.localStorage.setItem(AUTOPLAY_KEY, on ? 'on' : 'off'); + } catch { + // Not persisted; the switch still works for this page. + } +}; + +const UX_VIEW_KEY = 'pods.uxView'; + +/** The UX review's Screens switch: the pictures (default), or the ASCII. */ +export type UxView = 'pictures' | 'ascii'; + +export const loadUxView = (): UxView => { + try { + return window.localStorage.getItem(UX_VIEW_KEY) === 'ascii' + ? 'ascii' + : 'pictures'; + } catch { + return 'pictures'; + } +}; + +export const saveUxView = (view: UxView): void => { + try { + window.localStorage.setItem(UX_VIEW_KEY, view); + } catch { + // Not persisted; the switch still works for this page. + } +}; + +/** What one screen shows: its pictures only when chosen and it has some (older reviews have none). */ +export const screenView = ( + saved: UxView, + screen: Pick +): UxView => + saved === 'pictures' && (screen.pictures ?? []).length > 0 + ? 'pictures' + : 'ascii'; + +/** What a picture's empty side says in place of its image. */ +export const missingPictureText = ( + side: 'before' | 'after', + screen: Pick +): string => { + if (side === 'after') { + return 'Removed.'; + } + + if (screen.change === 'new') { + return 'New screen — nothing here today.'; + } + + return screen.noPhotoHook === true + ? 'No "before" picture: this codebase hasn\'t set up app photos.' + : 'No screenshot of today.'; +}; diff --git a/src/app/client/styles.css b/src/app/client/styles.css new file mode 100644 index 0000000..eba4d58 --- /dev/null +++ b/src/app/client/styles.css @@ -0,0 +1,9954 @@ +/* Claude Code's look, measured from claude.ai/code (2026-10-16): a neutral + warm off-white page, white panels with a hairline ring and a faint drop + shadow, and near-black text — black is the only accent. */ +:root { + --black: #0b0b0b; + --white: #fcfcfb; + --surface: #ffffff; + --grey-50: #f6f6f4; + --grey-100: #f0efec; + --grey-300: #dcdbd7; + --grey-500: #898781; + --grey-800: #52514e; + --border: rgb(11 11 11 / 10%); + --ring: 0 0 0 1px rgb(11 11 11 / 10%); + --lift: 0 4px 20px rgb(0 0 0 / 3.5%); + --accent: var(--black); + --link: #184f95; + --danger: #8e2626; + /* The Tickets screen's status colours: Building, Shipped, working. */ + --amber: #c27b12; + --amber-tint: #fbf4e6; + --amber-ink: #6b4a0e; + --shipped: #2f5fa8; + --success: #2f7d4a; + --urgent: #c2410c; + --label: #3b6fb6; + --terminal: #262624; + --sidebar-bg: #fbfbf9; + --sidebar-hover: #f5f5f2; + --sidebar-active: #f0efec; + --radius: 8px; + --serif: anthropic-serif, Georgia, 'Times New Roman', serif; + font-family: anthropic-sans, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + font-size: 14px; + color: var(--black); + background: var(--white); +} + +* { + box-sizing: border-box; +} + +/* The app is a fixed-height shell: only its panes scroll, never the page. + (xterm's hidden helpers can otherwise make the document taller than the + window, so the whole page — sidebar included — scrolled.) */ +html, +body, +#root { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + background: var(--white); +} + +button { + font: inherit; + cursor: pointer; +} + +code, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* The loading screen: the λ from the favicon, centred, breathing in and out. */ +.loading { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + background: var(--white); + transition: opacity 400ms ease; +} + +/* The app is up underneath: fade away (App: LOADING_FADE_MS). */ +.loading--out { + opacity: 0; + pointer-events: none; +} + +.loading__lambda { + fill: none; + stroke: var(--black); + stroke-width: 2.6; + stroke-linecap: round; + stroke-linejoin: round; + animation: lambda-breathe 1.6s ease-in-out infinite; +} + +@keyframes lambda-breathe { + 0%, + 100% { + opacity: 0; + } + + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .loading__lambda { + animation: none; + opacity: 0.6; + } +} + +/* ── layout ─────────────────────────────────────────────────────────────── */ +.layout { + display: grid; + grid-template-columns: 288px minmax(0, 1fr); + height: 100vh; + overflow: hidden; +} + +/* ── sidebar ────────────────────────────────────────────────────────────── */ +.sidebar { + /* Light gray, like Claude's sidebar; the main pane stays white. */ + background: var(--sidebar-bg); + color: var(--black); + border-right: 1px solid var(--grey-100); + padding: 16px 10px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* A sent message that never reached the pod — offered back. */ +.lost-message { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + max-width: 860px; + margin: 0 auto 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--grey-50); + font-size: 14px; +} + +.lost-message span { + flex: 1; + min-width: 240px; +} + +/* ── the queue bar: position · Skip · Up next ───────────────────────────── */ +.queue-bar { + max-width: 860px; + margin: 0 auto 12px; +} + +.queue-bar__row { + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + font-size: 14px; +} + +.queue-bar__row .ghost { + padding: 4px 12px; +} + +/* Something new arrived while you were on this item — quietly. */ +.up-next { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 8px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--white); + font-size: 14px; +} + +.up-next__dot { + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--black); + animation: dot-pulse 1s ease-in-out infinite alternate; +} + +.up-next__text { + flex: 1; + min-width: 0; + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.up-next .ghost { + flex: none; + padding: 4px 12px; +} + +/* Sidebar: this pod has something new since your current item came up. */ +.nav-item__new { + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + background: var(--black); + animation: dot-pulse 1s ease-in-out infinite alternate; +} + +/* ── the Time page ─────────────────────────────────────────────────────── */ +.time-page { + max-width: 860px; + margin: 0 auto; +} + +.time-page h2 { + margin: 4px 0 6px; + font-size: 26px; +} + +.time-page__heading { + margin: 28px 0 12px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.time-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-top: 20px; +} + +.time-stats > div { + padding: 14px 16px; + border-radius: var(--radius); + background: var(--grey-50); +} + +.time-stats__label { + display: block; + margin-bottom: 4px; + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.time-stats strong { + font-size: 24px; + font-variant-numeric: tabular-nums; +} + +.time-chart { + display: grid; + grid-template-columns: repeat(14, 1fr); + gap: 6px; + height: 180px; + align-items: end; +} + +.time-chart__day { + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + height: 100%; + min-width: 0; +} + +.time-chart__bar { + width: 100%; + min-height: 2px; + border-radius: 4px 4px 0 0; + background: var(--grey-300); +} + +.time-chart__bar--today { + background: var(--black); +} + +.time-chart__value, +.time-chart__label { + overflow: hidden; + max-width: 100%; + color: var(--grey-500); + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.time-chart__value { + margin-bottom: 4px; +} + +.time-chart__label { + margin-top: 6px; +} + +.time-pod { + margin-bottom: 10px; + border: 1px solid var(--grey-100); + border-radius: var(--radius); +} + +.time-pod summary { + display: flex; + justify-content: space-between; + padding: 12px 16px; + font-weight: 600; + cursor: pointer; +} + +.time-pod ul { + margin: 0; + padding: 0 16px 12px; + list-style: none; +} + +.time-pod li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-top: 1px solid var(--grey-100); +} + +.time-pod__ms { + flex: none; + font-variant-numeric: tabular-nums; +} + +/* Active planning time, pinned to the bottom of the sidebar. */ +.stopwatch { + position: sticky; + bottom: -16px; + margin: auto -10px -16px; + padding: 14px 20px 16px; + border: 0; + border-top: 1px solid var(--grey-100); + background: var(--sidebar-bg); + color: var(--grey-500); + text-align: left; + cursor: pointer; +} + +.stopwatch:hover { + background: var(--sidebar-hover); +} + +.stopwatch__row { + display: flex; + gap: 8px; + align-items: center; +} + +.stopwatch__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--grey-300); +} + +.stopwatch--ticking .stopwatch__dot { + background: var(--black); + animation: dot-pulse 1s ease-in-out infinite alternate; +} + +.stopwatch__label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.stopwatch__time { + display: block; + margin-top: 4px; + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stopwatch--ticking .stopwatch__time { + color: var(--black); +} + +.stopwatch__scope { + display: block; + margin-top: 2px; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.nav-item--new { + margin-top: 8px; + border: 1px solid var(--grey-300); + background: var(--white); + font-weight: 600; + justify-content: center; +} + +/* ── new pod form ──────────────────────────────────────────────────────── */ +.new-pod { + max-width: 860px; + margin: 32px auto 0; +} + +.new-pod h2 { + margin: 0 0 6px; + font-size: 24px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 20px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.field input, +.field textarea { + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + font: inherit; + font-size: 16px; + letter-spacing: normal; + text-transform: none; + font-weight: 400; + outline: none; + resize: vertical; +} + +.field__error { + color: var(--black); + font-size: 12px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; + text-decoration: underline; +} + +.new-pod .composer__actions { + margin-top: 16px; +} + +.primary:disabled { + background: var(--grey-300); + cursor: default; +} + +.sidebar__label { + margin: 20px 8px 6px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--grey-500); +} + +.sidebar__empty { + padding: 8px; + font-size: 13px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--black); + text-align: left; +} + +.nav-item:hover { + background: var(--sidebar-hover); +} + +.nav-item--active { + background: var(--sidebar-active); +} + +.nav-item--active:hover { + background: var(--sidebar-active); +} + +.nav-item--dragging { + opacity: 0.4; +} + +.nav-item--inbox { + font-size: 18px; + font-weight: 700; + justify-content: space-between; +} + +.nav-item__body { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.nav-item__title { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-item__sub { + font-size: 12px; + color: var(--grey-500); +} + +/* Autoplay: an iOS-style switch, black when on. */ +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + user-select: none; +} + +.switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.switch__track { + position: relative; + width: 40px; + height: 24px; + border-radius: 999px; + background: var(--grey-300); + transition: background 120ms ease; +} + +.switch__thumb { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--white); + box-shadow: 0 1px 3px rgb(0 0 0 / 25%); + transition: transform 120ms ease; +} + +.switch input:checked + .switch__track { + background: var(--black); +} + +.switch input:checked + .switch__track .switch__thumb { + transform: translateX(16px); +} + +.switch input:focus-visible + .switch__track { + outline: 2px solid var(--black); + outline-offset: 2px; +} + +/* A section whose clip is rendering right now. */ +.player__dot--loading { + animation: dot-pulse 0.8s ease-in-out infinite alternate; +} + +@keyframes dot-pulse { + from { + opacity: 1; + } + + to { + opacity: 0.25; + } +} + +/* A phase or checkpoint name: hover for what that phase is for. */ +.phase-tip { + cursor: help; + text-decoration: underline dotted; + text-underline-offset: 2px; + outline: none; +} + +/* : fixed-position, so overflow on the sidebar never clips it. */ +.tip { + position: fixed; + z-index: 1000; + padding: 10px 12px; + border-radius: 8px; + background: var(--black); + color: #fff; + font-size: 13px; + font-weight: 400; + line-height: 1.45; + text-align: left; + white-space: normal; + box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + pointer-events: none; + animation: tip-in 90ms ease-out; +} + +.tip--above { + transform: translateY(-100%); +} + +@keyframes tip-in { + from { + opacity: 0; + } +} + +/* Monochrome status: weight and inversion, not colour. */ +.status--needs-you { + color: var(--black); + font-weight: 700; +} + +.status--stalled { + color: var(--black); + text-decoration: underline; +} + +.kind { + padding: 2px 8px; + border-radius: 999px; + background: var(--black); + color: var(--white); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* Right-click menu + inline rename (like Claude's sidebar). */ +.context-menu { + position: fixed; + z-index: 10; + min-width: 160px; + padding: 6px; + border: 1px solid var(--grey-300); + border-radius: var(--radius); + background: var(--white); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} + +.context-menu button { + display: block; + width: 100%; + padding: 8px 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--black); + text-align: left; +} + +.context-menu button:hover { + background: var(--grey-100); +} + +.context-menu .context-menu__danger { + color: var(--danger); +} + +.rename { + width: 100%; + padding: 2px 6px; + border: 2px solid currentColor; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + outline: none; +} + +.nav-item { + cursor: pointer; + user-select: none; +} + +.grip { + color: var(--grey-500); + cursor: grab; +} + +.count { + min-width: 22px; + padding: 2px 7px; + border-radius: 999px; + background: var(--black); + color: var(--white); + font-size: 12px; + font-weight: 700; + text-align: center; +} + +/* ── main ───────────────────────────────────────────────────────────────── */ +/* Toolbar on top, history scrolling in the middle, composer pinned at the + bottom — like Claude's web UI. */ +.main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100vh; + overflow: hidden; + padding: 0 40px; +} + +.main > .toolbar { + flex: none; +} + +.main__scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 24px; +} + +.toolbar { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 20px; + padding: 18px 0 26px; + background: var(--white); + border-bottom: 1px solid var(--grey-100); +} + +/* Long (renamed) titles truncate so the player keeps its room. */ +.toolbar__title { + flex: 0 1 auto; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar__right { + flex: none; +} + +/* ── player: ⏮ ▶ ⏭ 0:42 ━━●━━│━━│━━ 2:10 ─────────────────────────────── */ +.player { + display: flex; + flex: 1; + align-items: center; + gap: 12px; + min-width: 260px; +} + +.player--disabled { + opacity: 0.35; +} + +.player__buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + width: 34px; + height: 34px; + border: 1px solid var(--grey-300); + border-radius: 50%; + background: var(--white); + color: var(--black); + font-size: 13px; +} + +.icon-button--primary { + width: 40px; + height: 40px; + border: 0; + background: var(--black); + color: var(--white); + font-size: 14px; +} + +.icon-button:disabled { + cursor: default; +} + +.player__time { + min-width: 36px; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.player__track-wrap { + position: relative; + flex: 1; + min-width: 120px; +} + +.player__track { + position: relative; + height: 6px; + border-radius: 3px; + background: var(--grey-100); + cursor: pointer; + touch-action: none; +} + +.player__fill { + position: absolute; + inset: 0 auto 0 0; + border-radius: 3px; + background: var(--black); +} + +.player__head { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--black); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.player__dot { + position: absolute; + top: 50%; + z-index: 1; + width: 10px; + height: 10px; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--white); + transform: translate(-50%, -50%); +} + +.player__dot--active { + background: var(--black); +} + +/* A ticket: a small tick, so 20 of them don't swamp the bar. */ +.player__dot--minor { + width: 6px; + height: 6px; + border-width: 1.5px; +} + +.player__tooltip { + position: absolute; + bottom: 14px; + padding: 3px 8px; + border-radius: 6px; + background: var(--black); + color: var(--white); + font-size: 12px; + white-space: nowrap; + transform: translateX(-50%); + pointer-events: none; +} + +.player__labels { + position: absolute; + top: 10px; + left: 0; + right: 0; + height: 16px; +} + +.player__label { + position: absolute; + padding: 0 2px; + overflow: hidden; + border: 0; + background: transparent; + color: var(--grey-500); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player__label--active { + color: var(--black); + font-weight: 700; +} + +.toggle { + font-size: 13px; + padding: 8px 12px; +} + +.toggle--on { + border-color: var(--black); +} + +.audio-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 0 0; +} + +.chip { + padding: 4px 10px; + border: 1px solid var(--grey-300); + border-radius: 999px; + color: var(--grey-800); + font-size: 12px; +} + +.chip--action { + border-color: var(--black); + background: var(--black); + color: var(--white); + font-weight: 600; +} + +.toolbar h1 { + margin: 0; + font-size: 28px; + font-weight: 700; +} + +.toolbar__right { + display: flex; + align-items: center; + gap: 8px; +} + +.rate { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; +} + +.rate input { + accent-color: var(--black); +} + +.primary, +.ghost { + border-radius: var(--radius); + padding: 9px 16px; + font-weight: 600; +} + +.primary { + border: 0; + background: var(--black); + color: var(--white); +} + +.ghost { + border: 1px solid var(--grey-300); + background: var(--white); + color: var(--black); +} + +.ghost:hover { + border-color: var(--black); +} + +.ghost:disabled { + color: var(--grey-500); + cursor: default; +} + +.icon { + border: 0; + background: transparent; + font-size: 15px; +} + +.error { + display: block; + width: 100%; + margin-top: 16px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--grey-50); + text-align: left; + font-weight: 600; +} + +.empty { + padding: 96px 0; + text-align: center; +} + +.lead { + font-size: 19px; + line-height: 1.5; + font-weight: 500; +} + +.muted { + color: var(--grey-500); +} + +/* ── question card ──────────────────────────────────────────────────────── */ +.card { + max-width: 860px; + margin: 24px auto 0; +} + +.card__meta { + display: flex; + gap: 6px; + font-size: 13px; + color: var(--grey-500); + font-weight: 600; +} + +.card__position { + margin-left: auto; + color: var(--black); +} + +.section { + margin-top: 18px; + padding: 14px 18px; + border-left: 3px solid transparent; +} + +.section--playing { + border-left-color: var(--black); + background: var(--grey-50); +} + +.section header { + display: flex; + align-items: center; + gap: 8px; +} + +.section h3 { + margin: 0; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.section p { + margin: 8px 0 0; + line-height: 1.55; +} + +.options { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: grid; + gap: 10px; +} + +.option { + padding: 12px 14px; + border: 1px solid var(--grey-300); + border-radius: var(--radius); +} + +.option--rec { + border: 1px solid var(--border); +} + +.option p { + margin-top: 4px; +} + +.ascii { + margin: 10px 0 0; + padding: 14px; + overflow-x: auto; + background: var(--black); + color: var(--white); + border-radius: var(--radius); + font-size: 13px; + line-height: 1.35; +} + +.decisions { + list-style: none; + margin: 10px 0 16px; + padding: 0; +} + +.decisions li { + display: flex; + gap: 12px; + align-items: flex-start; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--grey-100); +} + +/* Checkpoint decisions: headline (click for details) · why · 🔊 */ +.decision { + align-items: flex-start; + border-left: 3px solid transparent; + padding-left: 10px !important; +} + +.decision--playing { + border-left-color: var(--black); + background: var(--grey-50); +} + +.decision__number { + min-width: 18px; + font-weight: 700; +} + +.decision__body { + flex: 1; + min-width: 0; +} + +.decision__headline { + display: flex; + gap: 8px; + align-items: baseline; + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + font-weight: 700; + text-align: left; + flex-wrap: wrap; + width: 100%; + cursor: pointer; +} + +.decision__headline:disabled { + cursor: default; +} + +/* ── item context: how we got here · the chat · what it led to ─────────── */ +.link-button { + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.back { + margin-bottom: 12px; +} + +.status-badge { + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.card__context:empty { + display: none; +} + +.origin, +.chat, +.led-to { + margin-bottom: 18px; + padding: 14px 18px; + border-radius: var(--radius); + background: var(--grey-50); +} + +.origin h3, +.chat__heading, +.led-to h3 { + margin: 0 0 8px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.origin p { + margin: 0 0 8px; +} + +.origin__quote { + padding-left: 10px; + border-left: 3px solid var(--grey-300); + /* Keep their line breaks — a numbered list must stay a list. */ + white-space: pre-line; +} + +.origin__full summary { + color: var(--grey-500); + font-size: 13px; + cursor: pointer; +} + +.origin__full p { + margin: 6px 0 0; + padding-left: 10px; + border-left: 3px solid var(--grey-100); + color: var(--grey-800); + white-space: pre-line; +} + +.chat .link-button { + display: block; + margin-bottom: 10px; + font-size: 13px; +} + +.chat__who { + margin-right: 6px; + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +/* A message with several parts, answered part by part. */ +.chat__parts { + margin: 0; + padding: 0; + list-style: none; + counter-reset: part; +} + +.chat__parts li { + position: relative; + margin: 0 0 10px; + padding: 10px 12px 10px 36px; + border-radius: var(--radius); + background: var(--white); + counter-increment: part; +} + +.chat__parts li::before { + position: absolute; + top: 12px; + left: 12px; + font-weight: 700; + content: counter(part); +} + +.chat__part--playing { + box-shadow: inset 3px 0 0 var(--black); +} + +.chat__part-status { + margin: 4px 0 0; + color: var(--grey-500); + font-size: 13px; + font-weight: 600; +} + +.chat__event { + margin: 8px 0; + font-size: 13px; + font-style: italic; +} + +.chat__pending { + margin: 8px 0 0; +} + +/* "Claude is thinking": three dots that rise in turn. */ +.typing { + display: inline-flex; + gap: 5px; + align-items: center; + height: 18px; + margin-top: 4px; +} + +.typing span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--grey-500); + animation: typing-bounce 1.1s ease-in-out infinite; +} + +.typing span:nth-child(2) { + animation-delay: 0.15s; +} + +.typing span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes typing-bounce { + 0%, + 60%, + 100% { + opacity: 0.35; + transform: translateY(0); + } + + 30% { + opacity: 1; + transform: translateY(-4px); + } +} + +.chat--live { + margin-top: 24px; + border: 1px solid var(--border); + background: var(--white); +} + +.led-to ul { + margin: 0; + padding: 0; + list-style: none; +} + +.led-to li { + padding: 4px 0; +} + +/* A checkpoint's "The plan": the dev's line breaks are its structure. */ +.plan { + white-space: pre-line; + line-height: 1.6; +} + +/* ── UX review (CP4): flows of screens, each before | after ───────────── */ +.flow { + margin: 26px 0 6px; + padding: 0 4px; +} + +.flow:first-of-type { + margin-top: 8px; +} + +.flow__title { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + font-size: 18px; +} + +.flow__label { + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.flow p { + margin: 4px 0 0; + color: var(--grey-800); +} + +/* Screens in a flow sit indented under it, joined by a rule. */ +.flow ~ .screen { + margin-left: 14px; + border-left-width: 3px; +} +.screen { + margin: 14px 0; + padding: 14px 16px; + border: 1px solid var(--grey-300); + border-radius: var(--radius); +} + +.screen--playing { + border-color: var(--black); + box-shadow: inset 3px 0 0 var(--black); +} + +.screen__head { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + +/* Tickets, version mark and play stay on one line: when the row is too + narrow they wrap together under the title. */ +.screen__meta { + display: flex; + flex: 1 0 auto; + gap: 10px; + align-items: center; + white-space: nowrap; +} + +.screen__head .icon { + margin-left: auto; +} + +.screen__compare { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} + +.screen__compare figure { + margin: 0; + min-width: 0; +} + +.screen__compare figcaption, +.screen__label { + margin: 0 0 6px; + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.screen__compare pre { + margin: 0; + overflow-x: auto; +} + +/* The workflow panel's Render Full | ASCII switch. */ +.workflow__render { + display: flex; + gap: 8px; + align-items: center; + margin: 0 10px 6px; + color: var(--grey-500); + font-size: 12px; +} + +/* Compact, like the panel's other rows. */ +.workflow__render .toggle { + padding: 2px 8px; + font-size: 12px; +} + +.workflow__render [role='group'] { + display: inline-flex; + gap: 4px; +} + +/* Full greyed out: this pod's computer can't run Claude Design. */ +.workflow__render .toggle:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.workflow__render-note { + margin: -2px 10px 6px; + font-size: 12px; +} + +/* The Screens header's Pictures | ASCII switch. */ +.ux-view { + display: inline-flex; + gap: 4px; + margin-left: auto; +} + +.screen__situation + .screen__situation { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--grey-100); +} + +/* The situation names the pair below it, so it reads above Before/After. */ +.screen__situation-title { + margin: 0 0 8px; + font-size: 14px; + font-weight: 700; +} + +.screen__picture { + display: block; + max-width: 100%; + height: auto; + border: 1px solid var(--grey-300); + border-radius: 6px; +} + +.screen__legend { + margin: 6px 0 0; + padding-left: 20px; + font-size: 13px; +} + +/* Why the screen exists, from the person's side — collapsed, never spoken. */ +.screen__journey { + margin: 8px 0 4px; +} + +.screen__journey summary { + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} + +.journey { + margin-top: 8px; +} + +.screen__label { + display: flex; + gap: 8px; + align-items: center; +} + +.screen__extra { + padding: 0; + font-size: 14px; +} + +.screen__states { + margin: 0; + padding-left: 20px; +} + +.screen__states li { + margin: 4px 0; +} + +/* Titles already carry their build-order number ("1 - …"). */ +.plan-tickets { + margin: 0; + padding: 0; + list-style: none; +} + +.plan-tickets li { + display: flex; + gap: 12px; + align-items: flex-start; + justify-content: space-between; + padding: 10px 8px; + border-bottom: 1px solid var(--grey-100); +} + +/* A ticket's Today / After, labels in a narrow left column. */ +.today-after { + display: grid; + grid-template-columns: 52px 1fr; + gap: 4px 10px; + margin: 6px 0 0; +} + +.today-after dt { + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding-top: 3px; +} + +.today-after dd { + margin: 0; + line-height: 1.5; +} + +.plan-ticket--playing { + box-shadow: inset 3px 0 0 var(--black); + background: var(--grey-50); +} + +.plan-tickets li:last-child { + border-bottom: 0; +} + +.plan-tickets p { + margin: 4px 0 0; +} + +/* A decision's ticket numbers, beside its headline. */ +.decision__tickets { + padding: 1px 8px; + border: 1px solid var(--grey-300); + border-radius: 999px; + color: var(--grey-500); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.decisions__engineering { + margin-top: 14px; +} + +.decisions__engineering summary { + color: var(--grey-500); + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.decision__parts { + grid-template-columns: 62px 1fr; + margin-top: 8px; +} + +.decision__instead { + margin: 10px 0 0; + font-size: 14px; +} + +/* The justification sits under the headline, inside the same toggle. */ +.decision__why { + flex-basis: 100%; + margin-top: 4px; + font-weight: 400; +} + +.decision__chevron { + color: var(--grey-500); +} + +.decision__details { + margin: 10px 0 4px; + padding: 12px 14px; + overflow-x: auto; + border-radius: var(--radius); + background: var(--grey-50); + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; +} + +.kind--small { + font-size: 10px; + padding: 1px 6px; +} + +.decisions p { + margin: 2px 0 0; + font-size: 14px; +} + +/* ── conversation & thread ─────────────────────────────────────────────── */ +.conversation, +.thread { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 20px; +} + +/* The thread is the pod view's main content: it flows with the page instead + of scrolling inside a small box. */ +.thread { + padding-right: 6px; +} + +.pod-panel { + max-width: 860px; + margin: 36px auto 0; + padding-top: 20px; + border-top: 1px solid var(--grey-100); +} + +/* A pod's idle page: the phase's purpose under the lead line. */ +.empty__detail { + max-width: 560px; + margin: 8px auto 20px; +} + +.pod-panel h2 { + margin: 0; + font-size: 18px; +} + +/* ── composer ──────────────────────────────────────────────────────────── */ +.composer { + max-width: 860px; + margin: 20px auto 0; +} + +.composer textarea { + width: 100%; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + font: inherit; + font-size: 16px; + resize: vertical; + outline: none; +} + +.composer__actions { + display: flex; + gap: 10px; + align-items: center; + margin-top: 8px; +} + +/* ── agent terminals (the Agents rail) ─────────────────────────────────── */ +.tabs { + flex: none; +} + +.tabs { + display: flex; + gap: 2px; + padding: 8px 8px 0; +} + +.tab { + padding: 8px 14px; + border: 0; + border-radius: var(--radius) var(--radius) 0 0; + background: var(--grey-800); + color: var(--white); + font-weight: 600; +} + +.tab--active { + background: var(--white); + color: var(--black); +} + +.terminal { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px; +} + +/* ‹ Exchange 2 of 4 › — step back through earlier exchanges on an item. */ +.exchange-nav { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 10px; + color: var(--grey-500); + font-size: 13px; + font-weight: 600; +} + +.exchange-nav__step { + min-width: 32px; + padding: 2px 8px; + font-size: 16px; + line-height: 1.2; +} + +.exchange-nav__step:disabled { + opacity: 0.35; + cursor: default; +} + +/* Live questions asked since the exchange the item opens with. */ +.chat__live-after { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--grey-300); +} + +/* ── Sidebar split: pods on top, the workflow panel in the bottom half ── */ +.sidebar { + overflow: hidden; +} + +.sidebar__top { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} + +.workflow { + flex: 1 1 50%; + min-height: 0; + overflow-y: auto; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--grey-100); + font-size: 13px; +} + +.workflow__empty { + margin: 4px 10px; +} + +.workflow__agents { + display: flex; + flex-wrap: wrap; + gap: 2px 12px; + margin: 0 10px 6px; + color: var(--grey-500); + font-size: 12px; +} + +.workflow__watchdog { + margin: 0 10px 6px; + padding: 4px 8px; + border-radius: 6px; + background: var(--grey-100); + font-size: 12px; +} + +.workflow__stages, +.workflow__steps { + margin: 0; + padding: 0; + list-style: none; +} + +.workflow__row { + display: grid; + grid-template-columns: 16px 1fr auto 40px; + align-items: center; + gap: 6px; + width: 100%; + padding: 3px 10px; + border: 0; + border-radius: 6px; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.workflow__row:disabled { + cursor: default; +} + +.workflow__row:not(:disabled):hover { + background: var(--grey-100); +} + +.workflow__steps { + margin: 0 0 4px 22px; + border-left: 1px solid var(--grey-300); +} + +.workflow__step { + display: grid; + grid-template-columns: 16px 1fr auto; + align-items: center; + gap: 6px; + padding: 2px 10px 2px 8px; + font-size: 12px; +} + +.workflow__icon { + text-align: center; +} + +.workflow__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow__who, +.workflow__time { + color: var(--grey-500); + font-size: 11px; + text-align: right; +} + +.workflow__stage--todo, +.workflow__step--todo { + color: var(--grey-500); +} + +.workflow__stage--now > .workflow__row, +.workflow__stage--waiting-you > .workflow__row, +.workflow__step--now { + font-weight: 700; +} + +.workflow__step--untracked .workflow__icon { + opacity: 0.5; +} + +.workflow__legend { + margin: 8px 10px 0; + font-size: 11px; +} + +.workflow__tip strong { + display: block; + margin-bottom: 4px; +} + +.workflow__tip p { + margin: 0 0 4px; +} + +.workflow__tip-muted { + opacity: 0.7; +} + +.sidebar__audio { + display: block; + margin: 6px 10px 0; + overflow: hidden; + color: var(--grey-500); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.field__choice { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 6px; + font-size: 14px; + font-weight: 400; +} + +/* ── The split proposal (CP3) and the change lists (CP4/CP5) ── */ +.proposal__counts { + margin: 0 0 12px; +} + +.proposal__tickets { + margin: 0; + padding: 0; + list-style: none; +} + +.proposal__tickets > li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 12px 12px 16px; + border-top: 1px solid var(--grey-100); +} + +.proposal__tickets p { + margin: 4px 0; +} + +.proposal__e2e { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + font-size: 14px; +} + +.proposal__e2e > span:first-child { + padding: 1px 8px; + border-radius: 10px; + background: var(--grey-100); + font-weight: 600; +} + +.proposal__e2e--new-file > span:first-child { + background: var(--black); + color: #fff; +} + +.proposal__files summary, +.changes__engineering summary { + color: var(--grey-500); + font-size: 13px; + cursor: pointer; +} + +.changes { + margin: 0; + padding-left: 18px; +} + +.changes__kind { + color: var(--grey-500); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.workflow__stage--skipped { + color: var(--grey-500); + text-decoration: line-through; +} + +/* ── The chat (always in the center) ─────────────────────────────────── */ +.chat-panel__log { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 12px 16px; +} + +.chat-panel__topic { + margin: 14px 0 6px; + color: var(--grey-500); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.chat-panel__compose { + padding: 10px 16px 16px; + border-top: 1px solid var(--grey-100); +} + +.chat-panel__about { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + color: var(--grey-500); + font-size: 12px; +} + +.chat-panel__about select { + max-width: 100%; + font: inherit; +} + +.chat-panel__compose textarea { + width: 100%; + box-sizing: border-box; +} + +.workflow__request { + margin: 0 10px 6px; + padding: 4px 8px; + border-radius: 6px; + background: var(--black); + color: #fff; + font-size: 12px; + font-weight: 600; +} + +.workflow__requests-done { + margin: 0 10px 6px; + font-size: 12px; +} + +.chat-panel__request { + display: inline-flex; + align-items: center; + margin-left: auto; + font-size: 12px; + cursor: pointer; +} + +/* "Decided in Chat" — at the end of the card, with the one action that sends it. */ +.chat-decided { + padding: 14px 16px; + border-left: 3px solid var(--grey-300); + border-radius: var(--radius); + background: var(--grey-50); +} + +.chat-decided ol { + margin: 0 0 12px; + padding-left: 20px; +} + +.chat-decided li { + margin-bottom: 6px; +} + +.chat-decided p { + margin: 8px 0 0; +} + +ol.changes { + padding-left: 0; + list-style: none; +} + +/* Room for the playing bar (inset 3px on the left) — it never touches text. */ +ol.changes > li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 12px 10px 16px; +} + +.changes__context { + margin: 4px 0 0; + color: var(--grey-500); + font-size: 14px; +} + +/* Suggested judgements (CP5): tick the ones to keep. */ +.judgements__all { + display: inline-flex; + gap: 6px; + margin: 4px 0 8px; + font-weight: 600; + cursor: pointer; +} + +.judgements__list { + margin: 0; + padding: 0; + list-style: none; +} + +.judgements__list > li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 12px 12px 16px; + border-top: 1px solid var(--grey-100); +} + +.judgements__item { + display: flex; + gap: 10px; + align-items: flex-start; + cursor: pointer; +} + +.judgements__item input { + margin-top: 5px; +} + +.judgements__item p { + margin: 4px 0 0; +} + +.judgements__label { + color: var(--grey-500); + font-weight: 600; +} + +/* Skip a stage (the owner's call): shown on hover, beside the stage's row. */ +.workflow__stage { + position: relative; +} + +.workflow__skip { + position: absolute; + top: 3px; + right: 56px; + padding: 0 4px; + border-radius: 4px; + background: var(--grey-100); + font-size: 11px; + opacity: 0; +} + +.workflow__stage:hover .workflow__skip, +.workflow__skip:focus-visible { + opacity: 1; +} + +/* Batch pods: tickets × stages. */ +.workflow__grid { + margin: 0 10px 8px; + border-collapse: collapse; + font-size: 11px; +} + +.workflow__grid th, +.workflow__grid td { + padding: 1px 4px; + text-align: center; +} + +.workflow__grid tbody th { + text-align: left; + font-weight: 500; + white-space: nowrap; +} + +.workflow__grid thead th { + color: var(--grey-500); + font-weight: 400; +} + +.workflow__cell--done { + color: var(--grey-500); +} + +.workflow__cell--now { + font-weight: 700; +} + +.workflow__cell--todo { + color: var(--grey-300); +} + +/* New pod: one box, Freeform or Structured. */ +.new-pod__mode { + display: flex; + flex-direction: column; + gap: 6px; + margin: 12px 0 16px; +} + +/* New planning Lambda, like an empty ChatGPT chat: a question a little + above the middle, one rounded box (+ left, ↑ right) and quiet starting + points under it. The box is a pill until it holds more than a line. */ +.new-chat { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: 26vh 24px 48px; +} + +.new-chat__ask { + margin: 0 0 24px; + font-size: 26px; + font-weight: 500; + letter-spacing: -0.01em; +} + +.new-chat__box { + position: relative; + display: grid; + grid-template-areas: 'plus text send'; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 4px 8px; + width: 100%; + padding: 8px 8px 8px 10px; + border: 1px solid var(--border); + border-radius: 28px; + background: var(--white); + box-shadow: 0 2px 12px rgb(0 0 0 / 6%); +} + +.new-chat__box--tall { + grid-template-areas: + 'text text text' + 'tray tray tray' + 'plus . send'; + padding: 14px 10px 10px 18px; + border-radius: 24px; +} + +.new-chat__box--over { + border-color: var(--grey-500); +} + +.new-chat__box textarea { + grid-area: text; + min-height: 24px; + padding: 6px 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + line-height: 24px; + outline: none; + resize: none; +} + +.new-chat__box .image-drop__tray { + grid-area: tray; +} + +.new-chat__controls { + display: contents; +} + +.new-chat__icon { + grid-area: plus; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--grey-800); + font-size: 22px; + font-weight: 300; + cursor: pointer; +} + +.new-chat__icon:hover { + background: var(--grey-100); +} + +.new-chat__send { + grid-area: send; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 50%; + background: var(--black); + color: var(--white); + font-size: 18px; + font-weight: 600; + cursor: pointer; +} + +.new-chat__send:disabled { + background: var(--grey-100); + color: var(--grey-500); + cursor: default; +} + +.new-chat__ids { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-self: flex-start; + margin: 10px 0 0 18px; +} + +.new-chat__suggestions { + align-self: stretch; + margin: 18px 0 0; + padding: 0 8px; + list-style: none; +} + +.new-chat__suggestions button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 9px 12px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--grey-500); + font: inherit; + text-align: left; + cursor: pointer; +} + +.new-chat__suggestions button:hover { + background: var(--grey-100); + color: var(--grey-800); +} + +.new-chat__suggestion-icon { + flex: none; + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.new-chat__busy { + margin-top: 12px; +} + +/* The structured-planning pitch in the Plan rail. */ +.structure-pitch__eyebrow { + margin: 0 0 6px; + color: var(--grey-500); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.structure-pitch__what { + margin: 0; + font-size: 22px; + line-height: 1.3; +} + +.structure-pitch__why { + margin: 10px 0 0; + color: var(--grey-800); +} + +.structure-pitch__steps { + display: grid; + gap: 8px; + margin: 14px 0 18px; + padding-left: 22px; + font-weight: 500; +} + +.structure-pitch__steps li::marker { + color: var(--grey-500); + font-variant-numeric: tabular-nums; +} + +.structure-pitch__between, +.structure-pitch__wait { + margin: 0 0 10px; +} + +.new-pod__reads { + font-size: 13px; +} + +.new-pod__batch { + display: flex; + gap: 6px; + align-items: center; + font-size: 14px; +} + +/* ── messages, Claude Code style ─────────────────────────────────────────── + Claude's text sits on the page with a ⏺ in the gutter and renders as + markdown; the owner's is a grey prompt block with a ›. No bubbles. */ +.msg { + position: relative; + margin: 10px 0; + padding-left: 22px; + line-height: 1.55; + font-size: 15px; +} + +.msg::before { + position: absolute; + top: 0; + left: 2px; + font-size: 13px; + line-height: 1.85; +} + +.msg--claude::before { + content: '⏺'; + color: var(--black); +} + +.msg--you { + padding: 8px 12px 8px 26px; + border-radius: 6px; + background: var(--grey-100); +} + +.msg--you::before { + top: 8px; + left: 10px; + color: var(--grey-500); + font-weight: 700; + content: '›'; +} + +.msg--progress, +.msg--pending { + opacity: 0.6; +} + +.msg--progress::before { + color: var(--grey-500); +} + +.msg--latest { + outline: 2px solid var(--black); + outline-offset: 4px; + border-radius: 6px; +} + +.msg__label { + display: block; + margin-bottom: 2px; + color: var(--grey-500); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.msg__text { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.msg__images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} + +.msg__images img { + display: block; + max-width: 240px; + max-height: 180px; + border: 1px solid var(--grey-300); + border-radius: 6px; + object-fit: cover; +} + +.chat__parts .msg { + margin: 0 0 6px; +} + +/* Markdown from an agent. */ +.md { + overflow-wrap: anywhere; +} + +.md > :first-child { + margin-top: 0; +} + +.md > :last-child { + margin-bottom: 0; +} + +.md p, +.md ul, +.md ol, +.md pre, +.md table, +.md blockquote { + margin: 0 0 10px; +} + +.md ul, +.md ol { + padding-left: 22px; +} + +.md li { + margin: 2px 0; +} + +.md li > p { + margin: 0; +} + +.md h1, +.md h2, +.md h3, +.md h4 { + margin: 14px 0 6px; + font-size: 15px; + font-weight: 700; +} + +.md h1 { + font-size: 17px; +} + +.md code { + padding: 1px 5px; + border-radius: 4px; + background: var(--grey-100); + font-size: 0.88em; +} + +.md pre { + padding: 10px 12px; + border: 1px solid var(--grey-100); + border-radius: 6px; + background: var(--grey-50); + overflow-x: auto; + font-size: 13px; + line-height: 1.45; +} + +.md pre code { + padding: 0; + background: none; + font-size: inherit; +} + +.md blockquote { + padding-left: 12px; + border-left: 3px solid var(--grey-300); + color: var(--grey-800); +} + +.md table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + font-size: 14px; +} + +.md th, +.md td { + padding: 4px 10px; + border: 1px solid var(--grey-300); + text-align: left; + vertical-align: top; +} + +.md th { + background: var(--grey-50); +} + +.md a { + color: inherit; + text-decoration: underline; +} + +.md hr { + margin: 12px 0; + border: 0; + border-top: 1px solid var(--grey-300); +} + +.md input[type='checkbox'] { + margin: 0 6px 0 0; +} + +/* Images dropped into a message box. */ +.image-drop { + position: relative; + width: 100%; +} + +.image-drop--over textarea { + border-style: dashed; + background: var(--grey-50); +} + +.image-drop__hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + font-weight: 700; + pointer-events: none; +} + +.image-drop__tray { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.image-drop__thumb { + position: relative; +} + +.image-drop__thumb img { + display: block; + width: 64px; + height: 64px; + border: 1px solid var(--grey-300); + border-radius: 6px; + object-fit: cover; +} + +.image-drop__thumb button { + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--black); + color: var(--white); + font-size: 11px; + line-height: 20px; +} + +.image-drop__status { + font-size: 13px; +} + +.image-drop__error { + color: var(--black); + font-weight: 700; +} + +/* The checkpoint's goal: what to judge here, and what to leave for later. */ +.checkpoint-goal { + margin: 0 0 18px; + padding: 12px 16px; + border-left: 3px solid var(--grey-300); + border-radius: 0 var(--radius) var(--radius) 0; + background: var(--grey-50); +} + +.checkpoint-goal p { + margin: 4px 0 0; + font-size: 17px; + font-weight: 600; + line-height: 1.45; +} + +.checkpoint-goal__label { + color: var(--grey-500); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* A pod pulled out of a batch: which one it came from. */ +.nav-item__from { + display: block; + overflow: hidden; + color: var(--grey-500); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A plan row's lane — a glance, not a sentence (and never read aloud). */ +.lane-tag { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border: 1px solid var(--grey-300); + border-radius: 999px; + color: var(--grey-800); + font-size: 12px; + font-weight: 600; + vertical-align: 2px; + white-space: nowrap; +} + +.lane-tag--changed { + border-color: var(--black); + color: var(--black); +} + +/* The chat takes a dropped image anywhere on it. */ +.center-chat--dropping { + outline: 3px dashed var(--black); + outline-offset: -8px; +} + +.chat-panel__drop { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: rgb(255 255 255 / 85%); + font-size: 18px; + font-weight: 700; + pointer-events: none; +} + +/* Chat playlist: 🔊 on a message plays it and everything newer. */ +.msg__listen { + position: absolute; + top: 0; + right: 0; + padding: 0 2px; + opacity: 0.35; + cursor: pointer; +} + +.msg:hover .msg__listen, +.msg--listening .msg__listen { + opacity: 1; +} + +.msg--you .msg__listen { + top: 6px; + right: 6px; +} + +.chat-panel__log .msg { + padding-right: 28px; +} + +.msg--listening { + outline: 2px solid var(--black); + outline-offset: 4px; + border-radius: 6px; +} + +/* ── Lambda shell: sidebar brand, center header, rails ─────────────────── */ +.sidebar__brand { + flex: none; + padding: 2px 10px 14px; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.main[hidden], +.rail[hidden] { + display: none; +} + +.main { + padding: 0 24px; +} + +.main > .toolbar { + gap: 12px; + padding: 14px 0; +} + +.main .toolbar h1 { + font-size: 20px; +} + +.main .toolbar__title { + flex: 1 1 auto; + max-width: none; +} + +.main > .error { + flex: none; + max-width: 860px; + margin: 12px auto 0; +} + +.main > .empty { + flex: 1; +} + +/* All caught up: a quiet page. The λ breathes while a Lambda works. */ +.caught-up { + display: flex; + flex-direction: column; + align-items: center; + max-width: 440px; + margin: 0 auto; +} + +.caught-up .muted { + margin-top: 8px; +} + +/* The λ on a quiet page (caught up, a Lambda with nothing to review). */ +.lambda-mark { + display: block; + margin: 0 auto 16px; + fill: none; + stroke: var(--black); + stroke-width: 2.6; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.25; +} + +.lambda-mark--working { + animation: lambda-mark-breathe 2.4s ease-in-out infinite; +} + +/* Never all the way out: a still frame still shows the λ. */ +@keyframes lambda-mark-breathe { + 0%, + 100% { + opacity: 0.15; + } + + 50% { + opacity: 0.6; + } +} + +.caught-up__more { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin-top: 40px; +} + +@media (prefers-reduced-motion: reduce) { + .caught-up__lambda--working { + animation: none; + opacity: 0.6; + } +} + +.center-chat { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + max-width: 860px; + min-height: 0; + margin: 0 auto; +} + +.center-chat .chat-panel__log { + padding: 16px 0; +} + +.center-chat .chat-panel__compose { + padding: 10px 0 20px; +} + +.center-chat .chat-panel__compose textarea { + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + font: inherit; + font-size: 16px; + resize: none; + outline: none; +} + +.center-chat .chat-panel__about strong { + color: var(--black); +} + +.chat__empty { + margin-top: 32px; + text-align: center; +} + +/* The rail icons in the chat's header (Claude Code style). */ +.rail-switch { + display: flex; + gap: 4px; +} + +.rail-switch__button { + position: relative; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--grey-800); +} + +.rail-switch__button:hover { + background: var(--grey-100); +} + +.rail-switch__button--active { + background: var(--grey-100); + color: var(--black); + box-shadow: inset 0 0 0 1.5px var(--black); +} + +.rail-switch__badge { + position: absolute; + top: 6px; + right: 6px; + width: 8px; + height: 8px; + border: 2px solid var(--white); + border-radius: 50%; + background: var(--black); +} + +/* Play/pause with a progress ring, always in the header. */ +.mini-player { + position: relative; + display: grid; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + border: 0; + background: transparent; + color: var(--black); +} + +.mini-player:disabled { + opacity: 0.35; + cursor: default; +} + +.mini-player svg { + position: absolute; + inset: 1px; + transform: rotate(-90deg); +} + +.mini-player__track, +.mini-player__ring { + fill: none; + stroke-width: 2.5; +} + +.mini-player__track { + stroke: var(--grey-100); +} + +.mini-player__ring { + stroke: var(--black); + transition: stroke-dashoffset 0.25s linear; +} + +.mini-player__glyph { + position: relative; + font-size: 11px; + font-weight: 700; +} + +/* A rail: its head (title, expand, close), a draggable left edge, a body. */ +.rail { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100vh; + overflow: hidden; + background: var(--white); + border-left: 1px solid var(--grey-100); +} + +.rail--dark { + background: var(--black); + color: var(--white); + border-left-color: var(--grey-800); +} + +.rail--resizing { + user-select: none; +} + +.rail__edge { + position: absolute; + top: 0; + bottom: 0; + left: -3px; + z-index: 6; + width: 6px; + cursor: col-resize; +} + +.rail__edge:hover, +.rail--resizing .rail__edge { + background: var(--grey-300); +} + +.rail__head { + display: flex; + flex: none; + align-items: center; + gap: 4px; + padding: 14px 12px 14px 20px; + border-bottom: 1px solid var(--grey-100); +} + +.rail--dark .rail__head { + border-bottom-color: var(--grey-800); +} + +.rail__title { + flex: 1; + overflow: hidden; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rail__action { + width: 32px; + height: 32px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: inherit; + font-size: 16px; +} + +.rail__action:hover { + background: var(--grey-100); +} + +.rail--dark .rail__action:hover { + background: var(--grey-800); +} + +.rail__scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 20px 24px; +} + +.rail__scroll > .queue-bar, +.rail__scroll > .lost-message { + margin-top: 16px; +} + +.rail__scroll .card { + margin-top: 16px; +} + +.rail__actions { + display: flex; + flex: none; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + padding: 12px 20px 16px; + border-top: 1px solid var(--grey-100); +} + +.rail__empty { + padding: 24px 20px; + color: var(--grey-500); +} + +/* The workflow panel left the sidebar: no split line, no half-height. */ +.rail__scroll--workflow { + padding-top: 12px; +} + +.rail .workflow { + flex: none; + margin: 0; + padding: 0; + border-top: 0; + overflow: visible; +} + +/* Player rail: the full bar, speed + Autoplay, then the sections. */ +.player-rail { + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 20px; +} + +.player-rail .player { + flex: none; +} + +.player-rail__controls { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: center; +} + +.player-rail .audio-chips { + padding: 0; +} + +.player-rail__sections { + margin: 0; + padding: 0; + list-style: none; +} + +.player-rail__section { + display: flex; + justify-content: space-between; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: var(--radius); + background: transparent; + text-align: left; +} + +.player-rail__section:hover { + background: var(--grey-50); +} + +.player-rail__section--active { + background: var(--grey-100); + font-weight: 700; +} + +.player-rail__keys { + margin: 0; + font-size: 12px; +} + +/* Agents rail: the direct-to-dev box under the Dev terminal. */ +.rail__dev-compose { + flex: none; + padding: 10px 12px 14px; + border-top: 1px solid var(--grey-800); +} + +.rail__dev-compose textarea { + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: var(--radius); + font: inherit; + font-size: 14px; + resize: none; + outline: none; +} + +/* "Just chat" / "Answer the item": swap the box, at the right of its line. */ +.chat-panel__about .link-button { + margin-left: auto; + white-space: nowrap; +} + +/* ── Claude Code refinement ─────────────────────────────────────────────── */ +.sidebar { + border-right: 1px solid var(--border); +} + +.sidebar__brand { + font-family: var(--serif); + font-size: 24px; + font-weight: 500; + letter-spacing: -0.02em; +} + +.nav-item { + border-radius: var(--radius); +} + +.nav-item--active, +.nav-item--active:hover { + background: var(--sidebar-active); +} + +.count { + background: var(--accent); + color: var(--surface); +} + +/* The chat column: no rule under the header, as in Claude Code. */ +.main > .toolbar { + border-bottom: 0; + background: var(--white); +} + +.main .toolbar h1 { + font-size: 18px; + font-weight: 500; +} + +.center-chat .chat-panel__compose textarea { + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface); + box-shadow: var(--lift), var(--ring); +} + +.center-chat .chat-panel__compose textarea:focus { + border-color: var(--grey-300); + box-shadow: 0 4px 20px rgb(0 0 0 / 6%), var(--ring); +} + +.msg--you { + border-radius: 12px; + background: var(--grey-50); +} + +.primary { + background: var(--black); + color: var(--white); +} + +.ghost { + border-color: var(--border); + background: var(--surface); +} + +.ghost:hover { + background: var(--grey-50); +} + +.rail-switch__button { + color: var(--grey-800); +} + +.rail-switch__button:hover { + background: var(--grey-100); +} + +.rail-switch__button--active { + background: var(--grey-100); + box-shadow: none; +} + +.rail-switch__badge { + border-color: var(--white); + background: var(--accent); +} + +.mini-player__ring { + stroke: var(--accent); +} + +/* A rail is a floating card with a border — expanded too, as in Claude Code. */ +.rail { + height: calc(100vh - 16px); + margin: 8px 8px 8px 0; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--lift), var(--ring); +} + +.layout--expanded .rail { + margin-left: 8px; +} + +.rail--dark { + border-color: var(--terminal); + background: var(--terminal); +} + +.rail__head { + border-bottom-color: var(--border); +} + +.rail--dark .rail__head, +.rail--dark .rail__dev-compose { + border-color: #3d3d3a; +} + +.rail--dark .rail__action:hover { + background: #3d3d3a; +} + +.rail__edge { + /* Inside the card: the card clips what overflows it. */ + left: 0; + width: 8px; + border-radius: 14px 0 0 14px; +} + +.rail--dark .tab { + background: #3d3d3a; + color: var(--white); +} + +.rail--dark .tab--active { + background: var(--white); + color: var(--black); +} + +/* The Agents panel's window dropdown, in place of one tab per window. */ +.window-picker { + flex: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + padding: 10px 12px 6px; +} + +.window-picker select { + min-width: 0; + max-width: 100%; + padding: 7px 10px; + border: 0; + border-radius: var(--radius); + background: #3d3d3a; + color: var(--white); + font: inherit; + font-weight: 600; +} + +.window-picker__lambda, +.window-picker__note { + display: flex; + align-items: center; + gap: 6px; + color: var(--grey-300); + font-size: 13px; +} + +.rail__dev-compose textarea { + background: #30302e; + color: var(--white); +} + +.rail__actions { + border-top-color: var(--border); +} + +.player-rail__section--active { + background: var(--grey-50); + box-shadow: inset 3px 0 0 var(--accent); +} + +.player__fill, +.player__dot--active { + background: var(--accent); +} + +/* Measured: composer and panels r14, cards r10, nav rows 32px r8, header + icons r6; the brand is the serif at 20px/500. */ +.sidebar__brand { + font-size: 20px; + letter-spacing: 0; +} + +.nav-item { + border-radius: 8px; +} + +.center-chat .chat-panel__compose textarea { + border: 0; + border-radius: 14px; + font-size: 14px; +} + +.rail { + border: 0; + border-radius: 14px; +} + +.rail-switch__button { + border-radius: 6px; +} + +.card, +.ghost { + border-radius: 10px; +} + +.center-chat .chat-panel__log a, +.rail a { + color: var(--link); +} + +/* The item's summary in a ~500px rail: still the lead, not a headline. */ +.rail .lead { + font-size: 16px; + font-weight: 400; +} + +/* A box wrapped for image drops still fills its line (the New pod idea box + was left at the browser's default width). */ +.image-drop textarea { + display: block; + width: 100%; +} + +/* The Plan rail: Skip in the head; the card's top line wraps as whole + pieces; tighter section padding for a ~500px column. */ +/* Under the thinking dots while Lambda works: you can move on, and this + comes back when it's done. */ +.while-waiting { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + max-width: 420px; + margin: 24px auto 0; + text-align: center; + color: var(--grey-500); +} + +.while-waiting p { + margin: 0; +} + +.while-waiting strong { + display: block; + color: var(--grey-800); + font-weight: 500; +} + +.rail .card { + margin-top: 12px; +} + +.rail .card__meta { + flex-wrap: wrap; + align-items: center; + row-gap: 4px; + font-size: 12px; +} + +.rail .card__meta > * { + white-space: nowrap; +} + +.rail .section { + margin-top: 8px; + /* 11px + the 3px playing bar = the 14px of the boxes above. */ + padding: 12px 14px 12px 11px; +} + +.rail .origin, +.rail .led-to { + margin-top: 12px; + padding: 12px 14px; +} + +/* ── Sidebar, Claude Code style (measured 2026-10-16): 32px rows, r8, 14px + grey labels with 18px icons; quiet 13px section heads; account at the foot. */ +.sidebar { + gap: 2px; + padding: 6px 8px 8px; +} + +.sidebar__head { + display: flex; + flex: none; + align-items: center; + gap: 8px; + height: 44px; + padding: 0 4px; +} + +.sidebar__head .sidebar__brand { + padding: 0; +} + +.side-icon-button { + display: grid; + flex: none; + place-items: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--grey-800); +} + +.side-icon-button:hover { + background: var(--sidebar-hover); +} + +.side-icon { + flex: none; +} + +.side-search { + display: flex; + flex: none; + align-items: center; + gap: 10px; + height: 32px; + margin: 4px 0 6px; + padding: 0 10px; + border-radius: 8px; + background: rgb(255 255 255 / 50%); + box-shadow: var(--ring); + color: var(--grey-500); +} + +.side-search input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + outline: none; +} + +.side-search input::placeholder { + color: var(--grey-500); +} + +.sidebar__nav { + display: flex; + flex: none; + flex-direction: column; + gap: 1px; +} + +.side-row { + position: relative; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 32px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--grey-800); + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; + user-select: none; +} + +.side-row:hover { + background: var(--sidebar-hover); +} + +.side-row--active, +.side-row--active:hover { + background: var(--sidebar-active); + color: var(--black); +} + +.side-row__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-row__end { + display: flex; + flex: none; + align-items: center; + margin-left: auto; + color: var(--grey-500); +} + +.side-row__number { + color: var(--grey-500); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.sidebar__lists { + flex: 1; + min-height: 0; + margin-top: 12px; + overflow-y: auto; +} + +.side-section + .side-section { + margin-top: 14px; +} + +.side-section__head { + display: flex; + align-items: center; + height: 24px; + padding: 0 4px 0 10px; +} + +.side-section__title { + display: flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: var(--grey-500); + font: inherit; + font-size: 13px; +} + +.side-section__chevron { + display: grid; + opacity: 0; + transition: transform 0.15s; +} + +.side-section__chevron .side-icon { + width: 14px; + height: 14px; +} + +.side-section__head:hover .side-section__chevron, +.side-section__chevron--closed { + opacity: 1; +} + +.side-section__chevron--closed { + transform: rotate(-90deg); +} + +.side-section__action { + display: grid; + place-items: center; + width: 24px; + height: 24px; + margin-left: auto; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-500); +} + +.side-section__action:hover { + background: var(--sidebar-hover); + color: var(--black); +} + +.side-section__action .side-icon { + width: 16px; + height: 16px; +} + +/* A pod row: one line; its status at the right turns into ⋯ on hover. */ +.side-row--needs-you .side-row__label { + color: var(--black); + font-weight: 500; +} + +.side-row--dragging { + opacity: 0.4; +} + +.side-row__fresh { + position: absolute; + top: 50%; + left: 2px; + width: 6px; + height: 6px; + margin-top: -3px; + border-radius: 50%; + background: var(--black); +} + +.side-row__status { + display: grid; + place-items: center; + min-width: 18px; +} + +.side-row__status .side-icon { + width: 15px; + height: 15px; +} + +.side-row__status .count { + min-width: 18px; + padding: 1px 6px; + font-size: 11px; +} + +/* A Lambda at work: a small, quiet ring turning slowly. */ +.side-row__working { + box-sizing: border-box; + width: 12px; + height: 12px; + border: 1.5px solid var(--grey-100); + border-top-color: var(--grey-500); + border-radius: 50%; + animation: side-spin 1.2s linear infinite; +} + +@keyframes side-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .side-row__working { + animation: none; + } +} + +.side-row__more { + display: none; + place-items: center; + width: 24px; + height: 24px; + margin-right: -4px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-800); +} + +.side-row__more:hover { + background: var(--grey-300); +} + +/* ⋯ shows on hover, on keyboard focus, and while its menu is open — not + just because a click left focus in the row. */ +.side-row--pod:hover .side-row__status, +.side-row--pod:has(:focus-visible) .side-row__status, +.side-row--menu .side-row__status { + display: none; +} + +.side-row--pod:hover .side-row__more, +.side-row--pod:has(:focus-visible) .side-row__more, +.side-row--menu .side-row__more { + display: grid; +} + +.side-row--pod .rename { + flex: 1; + min-width: 0; +} + +/* The foot: planning time, then the account. */ +.sidebar__foot { + display: flex; + flex: none; + flex-direction: column; + gap: 2px; + margin: 8px -8px -8px; + padding: 8px; + border-top: 1px solid var(--border); +} + +.side-row--clock { + color: var(--grey-500); + font-size: 13px; +} + +.side-row__dot { + flex: none; + width: 8px; + height: 8px; + margin: 0 5px; + border-radius: 50%; + background: var(--grey-300); +} + +.side-row--ticking { + color: var(--black); +} + +.side-row--ticking .side-row__dot { + background: var(--black); + animation: dot-pulse 1s ease-in-out infinite alternate; +} + +.side-account { + position: relative; +} + +.side-avatar { + display: grid; + flex: none; + place-items: center; + width: 26px; + height: 26px; + margin-left: -4px; + border-radius: 50%; + background: var(--grey-100); + box-shadow: var(--ring); + color: var(--grey-800); + font-size: 11px; + font-weight: 600; +} + +.side-row--account { + color: var(--black); +} + +.side-account__plan { + color: var(--grey-500); +} + +.side-account__chevron { + display: grid; + color: var(--grey-500); +} + +.side-account__chevron .side-icon { + width: 14px; + height: 14px; +} + +.side-account__menu { + position: absolute; + right: 0; + bottom: calc(100% + 6px); + left: 0; +} + +.side-account__who { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 10px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.side-account__who .side-avatar { + margin: 0; +} + +/* Folded to an icon strip. */ +.sidebar--collapsed { + align-items: center; + padding: 6px 8px 8px; +} + +.sidebar--collapsed .sidebar__brand { + display: none; +} + +.sidebar--collapsed .sidebar__head { + padding: 0; +} + +.sidebar--collapsed .side-row { + justify-content: center; + width: 40px; + padding: 0; +} + +.sidebar--collapsed .side-row__label, +.sidebar--collapsed .side-row__end { + display: none; +} + +/* The strip's search button sits where the search box does, so the icons + below it don't move when the sidebar folds. */ +.sidebar--collapsed .side-row--search { + flex: none; + height: 32px; + margin: 4px 0 6px; +} + +.sidebar--collapsed .sidebar__foot { + align-self: stretch; + align-items: center; + margin-top: auto; +} + +.sidebar--collapsed .side-avatar { + margin: 0; +} + +.sidebar--collapsed .side-account__menu { + right: auto; + width: 220px; +} + +.context-menu { + border: 0; + border-radius: 10px; + background: var(--surface); + box-shadow: 0 8px 24px rgb(0 0 0 / 10%), var(--ring); +} + +/* Sidebar badges are quiet: a grey pill, darker grey number. */ +.side-row__status .count { + background: #e6e5e0; + color: var(--grey-800); +} + +/* "Lambda" is a link to New, as Claude Code's name is. */ +button.sidebar__brand { + border: 0; + background: transparent; + color: var(--black); + cursor: pointer; +} + +/* ── Claude Code density pass (measured 2026-10-16) ────────────────────── + One white card holds the chat and its rail; 32px header, 13px title, + 26px icon buttons; 14px/20px text; bubbles for your messages; a 44px + composer that grows; quiet sentence-case headings in the rail. */ +.sidebar { + border-right: 0; +} + +.workspace { + display: grid; + min-width: 0; + min-height: 0; + height: calc(100vh - 18px); + margin: 9px 9px 9px 0; + overflow: hidden; + border-radius: 10px; + background: var(--surface); + box-shadow: var(--ring); +} + +.workspace > .main, +.workspace > .rail { + height: auto; + min-height: 0; +} + +.main { + --main-pad: 24px; + + padding: 0 var(--main-pad); + background: transparent; +} + +.main > .toolbar { + gap: 4px; + padding: 4px 0; + min-height: 40px; + background: transparent; +} + +.main .toolbar h1 { + font-size: 13px; + font-weight: 500; + line-height: 19px; +} + +.rail-switch { + gap: 4px; +} + +.rail-switch__button, +.mini-player { + width: 26px; + height: 26px; + border-radius: 6px; +} + +.rail-switch__button svg { + width: 16px; + height: 16px; +} + +.rail-switch__badge { + top: 2px; + right: 2px; + width: 7px; + height: 7px; + border-width: 1.5px; +} + +.mini-player svg { + inset: 0; + width: 26px; + height: 26px; +} + +.mini-player__glyph { + font-size: 8px; +} + +/* The rail: a card inside the card. */ +.rail { + height: auto; + margin: 8px 8px 8px 0; + border-radius: 10px; + box-shadow: var(--ring); +} + +.layout--expanded .rail { + margin-left: 8px; +} + +.rail__head { + padding: 8px 8px 8px 16px; +} + +.rail__title { + font-size: 13px; + font-weight: 500; +} + +.rail__action { + width: 26px; + height: 26px; + border-radius: 6px; + font-size: 14px; +} + +.rail__scroll { + padding: 0 16px 20px; + line-height: 20px; +} + +/* The chat column. The log and composer span the whole pane, through + .main's padding (so the scrollbar sits at the window's right edge and a + wheel anywhere scrolls the chat); their padding keeps the words in a + 690px column. */ +.center-chat { + --chat-gutter: max(16px, calc((100% - 690px) / 2)); + + width: auto; + max-width: none; + margin: 0 calc(-1 * var(--main-pad, 0px)); + line-height: 20px; +} + +.center-chat .chat-panel__log { + padding-right: var(--chat-gutter); + padding-left: var(--chat-gutter); +} + +.msg { + margin: 12px 0; + padding-left: 0; + font-size: 14px; + line-height: 20px; +} + +.msg--claude::before, +.msg--you::before { + content: none; +} + +.msg--you { + width: fit-content; + max-width: 85%; + margin-left: auto; + padding: 8px 12px; + border-radius: 10px; + background: rgb(11 11 11 / 5%); +} + +.center-chat .chat-panel__log .msg--you { + padding-right: 12px; +} + +.center-chat .chat-panel__log .msg--claude { + padding-right: 24px; +} + +.msg__label { + margin: 4px 0 0; + color: var(--grey-500); + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + text-transform: none; +} + +.msg--you .msg__label { + text-align: right; +} + +.msg--you .msg__listen { + top: 8px; + right: auto; + left: -24px; +} + +.msg__listen { + color: var(--grey-500); +} + +.md strong, +.md b { + font-weight: 600; +} + +.md h1, +.md h2, +.md h3, +.md h4 { + margin: 16px 0 6px; + font-size: 15.75px; + font-weight: 600; + line-height: 20px; +} + +.md ul, +.md ol { + padding-left: 28px; +} + +.chat-panel__topic { + margin: 20px 0 4px; + color: var(--grey-500); + font-size: 13px; + font-weight: 400; + letter-spacing: 0; + text-transform: none; +} + +/* The composer: 44px, grows with its text; the hints sit under it. */ +.center-chat .chat-panel__compose { + display: flex; + flex-direction: column; + padding: 8px var(--chat-gutter) 12px; + border-top: 0; +} + +.center-chat .chat-panel__compose textarea { + min-height: 44px; + max-height: 240px; + padding: 11px 14px; + font-size: 14px; + line-height: 20px; + field-sizing: content; +} + +.center-chat .chat-panel__about { + order: 2; + flex-wrap: wrap; + gap: 4px 10px; + margin: 8px 6px 0; + color: var(--grey-500); + font-size: 13px; +} + +.center-chat .chat-panel__about strong { + font-weight: 500; +} + +.chat-panel__keys { + margin-left: auto; + color: var(--grey-500); + font-size: 12px; + white-space: nowrap; +} + +.center-chat .chat-panel__about .link-button { + margin-left: 0; + font-size: 13px; + font-weight: 500; +} + +/* The Plan rail's card: quiet, sentence-case headings; 14px/20px text. */ +.rail h3 { + color: var(--black); + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.rail .section p, +.rail .section li, +.rail .origin p { + font-size: 14px; + line-height: 20px; +} + +.rail .lead { + font-size: 15px; + line-height: 22px; +} + +.kind { + padding: 2px 8px; + background: var(--grey-100); + color: var(--grey-800); + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.primary, +.ghost { + min-height: 32px; + padding: 0 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; +} + +.speaker-icon { + display: block; + color: var(--grey-500); +} + +button:hover > .speaker-icon { + color: var(--black); +} + +/* Sidebar touch-ups. */ +.side-row { + line-height: 21px; +} + +.side-row--needs-you .side-row__label { + font-weight: 400; +} + +.side-avatar { + width: 24px; + height: 24px; + font-size: 10px; + font-weight: 500; +} + +.side-account__plan { + font-size: 12px; +} + +.rail .card__meta { + font-weight: 400; +} + +.rail__title { + line-height: 19px; +} + +/* The ⌘⏎ hint on the Plan rail's Done. */ +.kbd { + margin-left: 6px; + opacity: 0.6; + font-size: 12px; + font-weight: 400; +} + +.chat-panel__context { + min-width: 0; +} + +/* Pod badges: 18px circles, centred on the same line as the section "+" + and the Inbox count (the row's content ends 5px short of that centre). */ +.side-row__status { + margin-right: -5px; +} + +.side-row__status .count { + display: grid; + place-items: center; + width: 18px; + min-width: 18px; + height: 18px; + padding: 0; + border-radius: 50%; + font-size: 11px; + line-height: 1; +} + +.rail__action { + display: grid; + place-items: center; + width: 30px; + height: 30px; + color: var(--grey-800); +} + +.rail__action:hover { + color: var(--black); +} + +/* Options: click one to choose it (the recommendation starts chosen). */ +.option { + border: 1px solid var(--border); +} + +.option--pickable { + cursor: pointer; +} + +.option--pickable:hover { + border-color: var(--grey-300); +} + +.option--picked, +.option--picked:hover { + border-color: var(--black); + box-shadow: inset 0 0 0 1px var(--black); +} + +.option__rec { + margin-left: 8px; + padding: 1px 7px; + border-radius: 999px; + background: var(--grey-100); + color: var(--grey-800); + font-size: 11px; + font-weight: 500; + vertical-align: 2px; +} + +/* ── The Plan rail's footer player: one row beside Done, a drawer above ── */ +.rail__actions { + position: relative; + display: block; +} + +.plan-player__scrub { + position: absolute; + top: -7px; + right: 0; + left: 0; + z-index: 2; + height: 13px; + cursor: pointer; + touch-action: none; +} + +.plan-player__scrub--off { + cursor: default; +} + +/* A 2px hairline on the footer's top edge; 4px while the pointer is on it. */ +.plan-player__progress { + position: absolute; + top: 6px; + left: 0; + height: 2px; + background: var(--black); + transition: width 0.25s linear, height 0.1s, top 0.1s; +} + +.plan-player__scrub:not(.plan-player__scrub--off):hover .plan-player__progress { + top: 5px; + height: 4px; +} + +.plan-player__time { + position: absolute; + bottom: 14px; + padding: 2px 6px; + border-radius: 6px; + background: var(--black); + color: var(--surface); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + transform: translateX(-50%); + pointer-events: none; +} + +/* Tooltips on the audio controls: above them, after a beat. Each starts at + its control's left edge and runs right — the controls sit at the rail's + left, which clips anything past it. */ +.control-tip { + position: relative; + display: inline-flex; +} + +.control-tip__box { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 30; + display: flex; + flex-direction: column; + gap: 6px; + width: max-content; + max-width: 240px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--grey-100); + box-shadow: 0 4px 12px rgb(0 0 0 / 8%); + color: var(--grey-800); + font-size: 12px; + font-weight: 400; + line-height: 1.4; + text-align: left; + white-space: normal; + opacity: 0; + transform: translateY(4px); + transition: opacity 120ms ease, transform 120ms ease; + pointer-events: none; +} + +.control-tip:hover .control-tip__box, +.control-tip:focus-within .control-tip__box { + opacity: 1; + transform: none; + transition-delay: 400ms; +} + +.control-tip__keys { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + color: var(--grey-500); +} + +.control-tip__keys kbd { + display: inline-block; + min-width: 18px; + padding: 0 4px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--white); + color: var(--grey-800); + font: inherit; + text-align: center; +} + +.plan-player__row { + display: flex; + align-items: center; + gap: 2px; +} + +.plan-player__button { + position: relative; + display: grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-800); +} + +.plan-player__button:hover:not(:disabled) { + background: var(--grey-100); + color: var(--black); +} + +.plan-player__button:disabled { + opacity: 0.35; + cursor: default; +} + +.plan-player__button--play { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--black); + color: var(--surface); +} + +/* Preparing the audio: a thin ring turns around Play, instead of a notice + that would change the footer's height. */ +.plan-player__button--loading { + position: relative; +} + +.plan-player__button--loading::after { + content: ''; + position: absolute; + inset: -4px; + border: 1.5px solid var(--grey-100); + border-top-color: var(--grey-500); + border-radius: 50%; + animation: side-spin 1s linear infinite; + pointer-events: none; +} + +.plan-player__button--play:hover:not(:disabled) { + background: var(--grey-800); + color: var(--surface); +} + +/* A fixed width, wide enough for "2.25×", so changing speed doesn't shift + what follows along the row. */ +.plan-player__rate { + flex: none; + width: 60px; + font-variant-numeric: tabular-nums; + text-align: center; + height: 30px; + margin-left: 4px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-800); + font-size: 13px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.plan-player__rate:hover { + background: var(--grey-100); + color: var(--black); +} + +.plan-player__actions { + display: flex; + gap: 8px; + margin-left: auto; +} + +/* Done keeps to one line: when there's no room for ⌘⏎ beside the label it + wraps to a second line, which is cut off — hidden, not squeezed. */ +.done { + display: inline-flex; + flex-wrap: wrap; + align-content: flex-start; + align-items: center; + justify-content: center; + column-gap: 6px; + height: 32px; + overflow: hidden; + line-height: 32px; +} + +.done__label { + white-space: nowrap; +} + +.done .kbd { + margin-left: 0; + white-space: nowrap; +} + +.plan-player__notices { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 0 10px; +} + +.plan-player__notices .audio-chips { + padding: 0; +} + +/* Autoplay sits in the row, after the speed. */ +.plan-player__autoplay { + margin-left: 6px; + font-size: 13px; + color: var(--grey-800); +} + +/* No focus rings anywhere — no browser blue on whatever was last clicked. + (Text boxes show focus with their own border and shadow.) */ +*:focus, +*:focus-visible { + outline: none; +} + +/* ── Auto mode, above the account: ‹ › 2 of 3 ……… Auto [switch] ────────── */ +.side-auto { + position: relative; + display: flex; + align-items: center; + gap: 2px; + min-height: 32px; + padding: 0 4px 0 2px; +} + +.side-auto__step { + width: 26px; + height: 26px; +} + +.side-auto__step:disabled { + opacity: 0.3; + cursor: default; +} + +.side-auto__step:disabled:hover { + background: transparent; +} + +.side-auto__step .side-icon { + width: 16px; + height: 16px; +} + +.side-auto__count { + margin-left: 6px; + color: var(--grey-500); + font-size: 13px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.side-auto__switch { + margin-left: auto; + gap: 8px; + color: var(--grey-800); + font-size: 13px; +} + +.side-auto--on .side-auto__switch { + color: var(--black); +} + +/* Folded: just the switch, with the count as a badge. */ +.sidebar--collapsed .side-auto { + justify-content: center; + padding: 0; +} + +.sidebar--collapsed .side-auto__switch { + margin: 0; +} + +.side-auto__badge { + position: absolute; + top: -6px; + right: 2px; + display: grid; + place-items: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #e6e5e0; + color: var(--grey-800); + font-size: 10px; + font-weight: 700; +} + +/* ⋯ on hover sits on the same centre line as the badges and the "+". */ +.side-row__more { + margin-right: -8px; +} + +.your-turn__lead { + margin: 12px 0 0; +} + +/* The checkpoint's goal box sits below the pill row, like "How we got here". */ +.rail .checkpoint-goal { + margin-top: 12px; +} + +/* A live card: what the dev just changed tints softly, then fades. */ +@keyframes section-updated { + from { + background: var(--grey-100); + } + + to { + background: transparent; + } +} + +.section--updated { + border-radius: 8px; + animation: section-updated 6s ease-out forwards; +} + +.card__stale { + margin: 0 0 12px; + padding: 8px 12px; + border-radius: 10px; + background: var(--grey-50); + box-shadow: var(--ring); + color: var(--grey-800); + font-size: 13px; +} + +.card__stale summary { + cursor: pointer; +} + +.card__stale pre { + margin: 8px 0 0; + white-space: pre-wrap; + font-size: 12px; +} + +/* Proposed tickets: a little air between them, not just the rule. */ +.proposal__tickets > li + li { + margin-top: 4px; +} + +/* Offsets in the chat are measured from the log itself (send-to-top scroll). */ +.chat-panel__log { + position: relative; +} + +.chat-panel__spacer { + flex: none; +} + +/* As in Claude Code: no card around the chat — the main area runs flush + to the window, and one straight line, top to bottom, divides it from the + sidebar. (The rail keeps its own card.) */ +.sidebar { + border-right: 1px solid var(--border); +} + +.workspace { + height: 100vh; + margin: 0; + border-radius: 0; + box-shadow: none; +} + +/* Claude Code's chat area is the page's own off-white (#fcfcfb), not white. */ +.workspace { + background: var(--white); +} + +/* ── Claude Code's tints (measured 2026-10-17): fills and lines are + translucent near-black over the page — 5% for shaded boxes, 10% for + hairlines — not one-off opaque greys. ───────────────────────────────── */ +:root { + --tint-5: rgb(11 11 11 / 5%); + --tint-10: rgb(11 11 11 / 10%); +} + +/* 1. The sidebar's dividing line: one device pixel, as Claude's. */ +@media (min-resolution: 2dppx) { + .sidebar { + border-right-width: 0.5px; + } +} + +/* 2. Shaded boxes: the 5% tint, with a 10% bar. */ +.checkpoint-goal, +.origin, +.chat, +.led-to { + background: var(--tint-5); + border-left-color: var(--tint-10); +} + +.origin__quote, +.md blockquote { + border-left-color: var(--tint-10); +} + +/* 3. Inline code: 5% fill, 10% hairline, Claude's dark red. Code blocks are + white panels with the hairline ring. */ +.md code { + padding: 1px 3px; + border: 0.8px solid var(--tint-10); + border-radius: 5px; + background: var(--tint-5); + color: #8e2626; + font-size: 0.9em; +} + +.md pre { + border: 0; + border-radius: 10px; + background: var(--surface); + box-shadow: var(--ring); +} + +.md pre code { + border: 0; + color: var(--black); +} + +/* 4. The rail is an outline on the page, no fill of its own. */ +.rail { + background: var(--white); +} + +.rail--dark { + background: var(--terminal); +} + +/* 5. Thin, quiet scrollbars. */ +* { + scrollbar-color: rgb(30 30 29 / 35%) transparent; + scrollbar-width: thin; +} + +/* The old side chat's decisions: one quiet line in the chat, list on click. */ +.chat__decided { + margin: 8px 0; + font-size: 13px; +} + +.chat__decided summary { + cursor: pointer; +} + +.chat__decided ol { + margin: 6px 0 0; + padding-left: 20px; + color: var(--grey-800); +} + +/* The sidebar brand on load: the λ (which is also the sidebar toggle) starts + just past the word and slides left onto the toggle, fading in, as the word + fades in. --brand-from is that distance, measured in Sidebar. */ +.sidebar__brand-word { + display: inline-block; +} + +.sidebar__brand-text { + display: inline-block; + animation: brand-word-in 900ms ease 350ms both; +} + +.sidebar__brand-mark { + width: 15px; + height: 18px; + color: var(--black); + fill: none; + stroke: currentColor; + stroke-width: 2.8; + stroke-linecap: round; + stroke-linejoin: round; + animation: brand-mark-in 900ms ease both; +} + +.sidebar--collapsed .sidebar__brand-mark, +.sidebar--brand-leave .sidebar__brand-mark, +.sidebar--brand-arrive .sidebar__brand-mark { + animation: none; +} + +/* The sidebar sliding shut: the word slides left into the λ and fades; + opening, back out of it. */ +.sidebar--brand-leave .sidebar__brand-text { + animation: brand-word-leave 180ms ease both; +} + +.sidebar--brand-arrive .sidebar__brand-text { + animation: brand-word-arrive 220ms ease both; +} + +/* The sidebar sliding open or shut (App: SIDEBAR_SLIDE_MS): its column and + the rail beside the chat ease to their new widths together, while the + sidebar stays laid out open inside, clipped, so nothing re-wraps. Its words + fade as the edge passes over them; the icons stay where they are. */ +.layout--sidebar-opening, +.layout--sidebar-closing, +.layout--sidebar-opening .workspace, +.layout--sidebar-closing .workspace { + transition: grid-template-columns 220ms ease; +} + +.layout--sidebar-opening .sidebar, +.layout--sidebar-closing .sidebar { + overflow: hidden; +} + +.layout--sidebar-opening .sidebar > *, +.layout--sidebar-closing .sidebar > * { + min-width: 272px; +} + +.layout--sidebar-closing + :is( + .sidebar__lists, + .side-row__label, + .side-row__end, + .side-account__who, + .side-account__plan, + .side-account__chevron, + .side-auto__count + ) { + animation: sidebar-words-out 140ms ease both; +} + +.layout--sidebar-opening + :is( + .sidebar__lists, + .side-row__label, + .side-row__end, + .side-account__who, + .side-account__plan, + .side-account__chevron, + .side-auto__count + ) { + animation: sidebar-words-in 220ms ease 60ms both; +} + +@keyframes sidebar-words-out { + to { + opacity: 0; + } +} + +@keyframes sidebar-words-in { + from { + opacity: 0; + } +} + +@keyframes brand-word-leave { + to { + opacity: 0; + transform: translateX(-24px); + } +} + +@keyframes brand-word-arrive { + from { + opacity: 0; + transform: translateX(-24px); + } +} + +@keyframes brand-word-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes brand-mark-in { + from { + opacity: 0; + transform: translateX(var(--brand-from, 0)); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar__brand-text, + .sidebar__brand-mark { + animation: none; + } +} + +/* ── versions ────────────────────────────────────────────────────────────── + Under the item's meta: [v8 ▾] and the time on the checkpoint, then + "Discussed in chat". On each card inside it (a screen, a ticket, a + decision): "changed v8 · 2m ▾", opening the same version list with the + versions that touched that card marked. Cards the version on screen + changed get a soft tint. */ +.card__versions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + margin-top: 6px; + font-size: 12px; + color: var(--grey-500); +} + +.versions { + position: relative; + display: inline-flex; +} + +.versions__line { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.versions__pill { + padding: 1px 8px; + border: 0.8px solid var(--tint-10); + border-radius: 6px; + background: var(--tint-5); + color: var(--black); + font: inherit; + font-weight: 500; + cursor: pointer; +} + +.versions__pill:hover { + background: var(--tint-10); +} + +.versions__mark { + padding: 1px 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-500); + font: inherit; + font-size: 12px; + font-weight: 400; + white-space: nowrap; + cursor: pointer; +} + +.versions__mark:hover { + background: var(--tint-5); + color: var(--black); +} + +.screen__head .versions { + margin-left: auto; +} + +.screen__head .versions + .icon { + margin-left: 0; +} + +.versions__menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + min-width: 360px; + max-height: 420px; + overflow-y: auto; + padding: 4px; + border: 0.8px solid var(--border); + border-radius: 10px; + background: var(--surface); + box-shadow: 0 8px 28px rgb(0 0 0 / 10%); + color: var(--black); + font-size: 13px; + font-weight: 400; + text-align: left; +} + +.versions__line .versions__menu { + right: auto; + left: 0; +} + +.versions__head { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 6px 8px 8px; + border-bottom: 0.8px solid var(--border); + margin-bottom: 4px; + color: var(--grey-500); +} + +.versions__row { + display: grid; + grid-template-columns: 40px 1fr auto; + gap: 8px; + align-items: baseline; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-500); + font: inherit; + text-align: left; + cursor: pointer; +} + +/* Versions that touched the card read in full; the rest are greyed. */ +.versions__row--touched { + color: var(--black); +} + +.versions__row:hover, +.versions__row--on { + background: var(--tint-5); +} + +.versions__v { + font-weight: 600; + white-space: nowrap; +} + +.versions__ms { + font-variant-numeric: tabular-nums; +} + +/* An earlier version on screen: say so, above everything. */ +.versions__old { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; + padding: 8px 12px; + border: 0.8px solid var(--tint-10); + border-radius: 8px; + background: var(--tint-5); + font-size: 13px; +} + +/* The version on screen changed this card. */ +.card--changed-here { + background: var(--tint-5); +} + +.decision__mark { + display: flex; + justify-content: flex-end; +} + +.decision__mark:empty { + display: none; +} + +.proposal__title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 12px; +} + +/* The Plan rail's footer is as tall as its header (8 + 30 + 8 + 1px border), + its row centred in it; audio notices, when there are any, add to it. */ +.rail__actions { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 47px; + padding: 0 16px; +} + +/* A decision told under the screen that shows it (CP4). */ +.screen__decisions { + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.screen__decisions li { + margin-top: 8px; + padding: 8px 12px; + border-left: 2px solid var(--grey-300); +} + +.screen__decision-head { + display: block; + margin-bottom: 4px; + font-weight: 600; +} + +/* A picture in an agent's message (a pod's screenshot). */ +.md__img { + display: block; + max-width: 100%; + margin: 8px 0; + border: 1px solid var(--grey-300); + border-radius: 8px; +} + +/* ── prototypes: the chat card, the focus view, the lightbox, Made here ── */ +.chip { + background: var(--surface); +} + +.chip--on { + border-color: var(--black); + background: var(--black); + color: var(--white); +} + +.seg { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: var(--grey-100); +} + +.seg__button { + padding: 3px 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-800); + font-size: 13px; +} + +.seg__button--on { + background: var(--surface); + color: var(--black); + box-shadow: var(--ring); + font-weight: 600; +} + +.proto-card { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 640px; + margin: 10px 0 4px; + padding: 12px; + border-radius: 12px; + background: var(--surface); + box-shadow: var(--ring), var(--lift); +} + +.proto-card--missing { + flex-direction: row; + align-items: baseline; +} + +.proto-card__head { + display: flex; + align-items: baseline; + gap: 8px; +} + +.proto-card__kicker, +.made__kicker { + margin: 0; + color: var(--grey-500); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.proto-card__version { + display: inline-flex; + gap: 6px; + margin-left: auto; + color: var(--grey-800); + font-size: 12px; +} + +.proto-card__latest { + color: #2b8a3e; +} + +.proto-card__still { + position: relative; + display: block; + max-height: 360px; + padding: 0; + overflow: hidden; + border: 1px solid var(--grey-300); + border-radius: 8px; + background: var(--grey-50); + cursor: zoom-in; +} + +.proto-card__still img { + display: block; + width: 100%; +} + +.proto-card__try { + position: absolute; + right: 10px; + bottom: 10px; + padding: 6px 12px; + border-radius: 999px; + background: var(--black); + color: var(--white); + font-size: 13px; + font-weight: 600; + box-shadow: 0 2px 8px rgb(0 0 0 / 25%); +} + +.proto-card__still:hover .proto-card__try, +.proto-card__still:focus-visible .proto-card__try { + background: #2a2a28; +} + +.proto-card__boards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; +} + +.proto-card__board { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; + border: 1px solid var(--grey-300); + border-radius: 8px; + background: var(--surface); + color: var(--grey-800); + font-size: 12px; + text-align: left; +} + +.proto-card__board img { + width: 100%; + height: 72px; + border-radius: 4px; + object-fit: cover; + object-position: top; +} + +.proto-card__board--on { + border-color: var(--black); + box-shadow: 0 0 0 1px var(--black); + color: var(--black); +} + +.proto-card__note { + margin: 0; + color: var(--grey-800); + font-size: 13px; +} + +.proto-card__foot { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 12px; + font-size: 12px; +} + +.proto-card__foot a { + margin-left: auto; +} + +.screen__picture-button { + display: block; + width: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: zoom-in; +} + +.msg-flash { + animation: msg-flash 1.6s ease-out; +} + +@keyframes msg-flash { + 0%, + 40% { + border-radius: 8px; + background: #fff3bf; + } + 100% { + background: transparent; + } +} + +/* The focus view: the live prototype, over the dimmed inbox. */ +.proto-view, +.lightbox { + position: fixed; + z-index: 1000; + inset: 0; + background: rgb(11 11 11 / 55%); +} + +.proto-view { + display: flex; + padding: 10px; +} + +.proto-view__panel { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + overflow: hidden; + border-radius: 14px; + background: var(--surface); + box-shadow: 0 12px 48px rgb(0 0 0 / 30%); +} + +.proto-view__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--grey-100); +} + +.proto-view__zoom { + font-size: 12px; +} + +.proto-view__pick { + max-width: 100%; + padding: 3px 6px; + font-size: 13px; +} + +.proto-view__title { + font-size: 15px; +} + +.proto-view__version { + color: var(--grey-800); + font-size: 13px; +} + +.proto-view__spacer { + flex: 1; +} + +.proto-view__tool { + padding: 4px 10px; + border: 1px solid var(--grey-300); + border-radius: 8px; + background: var(--surface); + color: var(--black); + font-size: 13px; + text-decoration: none; +} + +.proto-view__stage { + display: flex; + flex: 1; + align-items: flex-start; + justify-content: center; + gap: 16px; + min-height: 0; + padding: 8px; + overflow: auto; + background: var(--grey-50); +} + +.proto-view__stage--phone { + align-items: center; +} + +.proto-view__column { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.proto-view__column-head { + align-self: flex-start; + font-size: 13px; +} + +.proto-view__frame-box { + position: relative; + flex: none; + overflow: hidden; + border-radius: 6px; + background: var(--surface); + box-shadow: var(--ring), 0 4px 16px rgb(0 0 0 / 8%); +} + +.proto-view__stage--phone .proto-view__frame-box { + border-radius: 28px; + box-shadow: 0 0 0 8px var(--black), 0 8px 28px rgb(0 0 0 / 25%); +} + +.proto-view__frame-box iframe { + display: block; + border: 0; + background: var(--surface); + transform-origin: 0 0; +} + +.proto-view__size { + font-size: 12px; +} + +/* A picture, full size; ← → steps through every picture the Lambda made. */ +.lightbox { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 16px; +} + +.lightbox__figure { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: calc(100% - 120px); + max-height: 100%; + margin: 0; +} + +.lightbox__figure img { + max-width: 100%; + max-height: calc(100vh - 110px); + border-radius: 8px; + background: var(--surface); + object-fit: contain; +} + +.lightbox__figure figcaption { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--white); + font-size: 13px; +} + +.lightbox__figure figcaption .muted { + color: var(--grey-300); +} + +.lightbox__step, +.lightbox__close { + border: 0; + border-radius: 999px; + background: rgb(255 255 255 / 15%); + color: var(--white); +} + +.lightbox__step { + flex: none; + width: 44px; + height: 44px; + font-size: 26px; + line-height: 1; +} + +.lightbox__close { + position: absolute; + top: 14px; + right: 14px; + width: 36px; + height: 36px; +} + +/* Made here: everything this Lambda made. */ +.made { + display: flex; + flex-direction: column; + gap: 18px; + padding: 12px 16px 24px; +} + +.made__tabs { + align-self: flex-start; +} + +.made__item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.made__proto { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px; + border: 1px solid var(--grey-300); + border-radius: 10px; + background: var(--surface); + text-align: left; + cursor: zoom-in; +} + +.made__proto img { + width: 100%; + max-height: 200px; + border-radius: 6px; + object-fit: cover; + object-position: top; +} + +.made__proto-title { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 0 4px 2px; + font-size: 13px; +} + +.made__jump, +.made__unsent { + align-self: flex-start; + font-size: 12px; +} + +.made__older { + font-size: 12px; +} + +.made__older ul { + margin: 6px 0 0; + padding-left: 16px; +} + +.made__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 8px; +} + +.made__thumb { + display: flex; + flex-direction: column; + gap: 3px; + padding: 3px; + border: 1px solid var(--grey-300); + border-radius: 8px; + background: var(--surface); + color: var(--grey-800); + font-size: 11px; + cursor: zoom-in; +} + +.made__thumb img { + width: 100%; + height: 72px; + border-radius: 5px; + object-fit: cover; + object-position: top; +} + +.made__thumb span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* On a phone: the focus view and the lightbox take the whole screen. */ +@media (max-width: 700px) { + .proto-view { + padding: 0; + } + + .proto-view__panel { + border-radius: 0; + } + + .proto-view__board-name { + display: none; + } + + .lightbox__figure { + max-width: 100%; + } + + .lightbox__step { + position: absolute; + bottom: 20px; + } + + .lightbox__step--back { + left: 20px; + } + + .lightbox__step--next { + right: 20px; + } +} + +/* ── Lambda: sign-in, account pages, projects ─────────────────── */ + +.sign-in { + min-height: 100vh; + display: grid; + place-items: center; + padding: 16px; + background: var(--white); +} + +.sign-in__card { + width: min(380px, 100%); + display: flex; + flex-direction: column; + gap: 12px; + padding: 28px; + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--ring), var(--lift); +} + +.sign-in__brand { + margin: 0; + font-family: var(--serif); + font-size: 18px; + text-align: center; +} + +.sign-in__title { + margin: 0 0 8px; + font-size: 20px; + font-weight: 600; + text-align: center; +} + +.sign-in__label { + font-size: 13px; +} + +.sign-in__code { + font: inherit; + font-size: 22px; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.sign-in__error { + margin: 0; + color: var(--danger); + font-size: 13px; +} + +.sign-in__hint { + margin: 4px 0 0; + font-size: 13px; + text-align: center; +} + +/* ── Lambda: first run (/setup) and the Claude renewal banner ──── */ + +.setup { + min-height: 100vh; + display: grid; + place-items: center; + padding: 16px; + background: var(--white); + overflow-y: auto; +} + +.setup__card { + width: min(560px, 100%); + display: flex; + flex-direction: column; + gap: 16px; + padding: 28px; + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--ring), var(--lift); +} + +.setup__brand { + margin: 0; + font-family: var(--serif); + font-size: 18px; +} + +.setup__steps { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.setup__step { + padding: 4px 0; + border: 0; + background: none; + color: var(--grey-800); +} + +.setup__step:disabled { + cursor: default; + color: var(--grey-500); +} + +.setup__step--current { + color: var(--black); + font-weight: 600; +} + +.setup__body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.setup__body p { + margin: 0; +} + +.setup__question { + font-size: 18px; + font-weight: 600; +} + +.setup__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.setup__actions--start { + justify-content: flex-start; +} + +.setup__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +/* A row's input takes the room beside its button. */ +.setup__row input { + flex: 1 1 200px; +} + +.setup__row input, +.setup__field input { + min-width: 0; + font: inherit; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.setup__field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; +} + +.setup__choices { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + border: 0; +} + +.setup__progress { + display: flex; + align-items: center; + gap: 8px; + font-variant-numeric: tabular-nums; +} + +.setup__bar { + position: relative; + flex: 1; + height: 8px; + overflow: hidden; + background: var(--grey-100); + border-radius: 4px; +} + +.setup__bar-fill { + height: 100%; + background: var(--black); + transition: width 0.3s ease; +} + +.setup__bar--moving .setup__bar-fill { + position: absolute; + width: 30%; + animation: setup-bar 1.4s ease-in-out infinite; +} + +@keyframes setup-bar { + from { + left: -30%; + } + + to { + left: 100%; + } +} + +.setup__browser { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.setup__browser p { + margin: 0; +} + +.setup__line { + font-size: 13px; + overflow-wrap: anywhere; +} + +.setup__lines { + max-height: 240px; + margin: 0; + padding: 10px 12px; + overflow: auto; + font-size: 12px; + color: var(--white); + background: var(--terminal); + border-radius: var(--radius); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.setup__error { + color: var(--danger); + font-size: 13px; +} + +.setup__folders { + display: flex; + flex-direction: column; + gap: 8px; +} + +.setup__folder-list { + max-height: 280px; + margin: 0; + padding: 4px; + overflow-y: auto; + list-style: none; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.setup__folder-list button { + width: 100%; + padding: 6px 8px; + text-align: left; + border: 0; + background: none; + border-radius: 6px; +} + +.setup__folder-list button:hover { + background: var(--grey-50); +} + +.setup__folder-list .muted { + padding: 6px 8px; +} + +.with-banner { + display: flex; + flex-direction: column; + height: 100%; +} + +.with-banner__app { + flex: 1; + min-height: 0; +} + +.with-banner .layout { + height: 100%; +} + +.renewal-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 8px 16px; + font-size: 13px; + background: var(--grey-100); + border-bottom: 1px solid var(--border); +} + +.renewal-banner button { + margin-left: auto; +} + +.account-page { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 720px; + margin: 0 auto; + padding: 8px 0 32px; +} + +.account-page__actions { + display: flex; + justify-content: flex-end; +} + +.account-list { + list-style: none; + margin: 0; + padding: 0; + border-radius: var(--radius); + box-shadow: var(--ring); + background: var(--surface); +} + +.account-list__row { + display: flex; + align-items: center; + gap: 4px; + padding: 12px 16px; +} + +.account-list__row + .account-list__row { + border-top: 1px solid var(--border); +} + +.account-list__row button { + margin-left: auto; +} + +.dialog { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--ring), var(--lift); +} + +.dialog h2 { + margin: 0; + font-size: 16px; +} + +.dialog__field { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: center; + gap: 8px; + margin: 0; +} + +.dialog__field input { + font: inherit; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; +} + +.dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.dialog__code { + display: flex; + align-items: center; + gap: 12px; + margin: 0; +} + +.dialog__steps { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding-left: 20px; +} + +.dialog__steps p { + margin: 4px 0 0; +} + +.dialog__code code { + overflow-wrap: anywhere; +} + +.dialog__big-code { + margin: 8px 0; + font-size: 32px; + letter-spacing: 0.12em; + text-align: center; +} + +.project-picker { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.new-chat__project { + position: relative; + align-self: flex-start; +} + +.new-chat__project-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 220px; + z-index: 20; +} + +.new-chat__project-menu hr { + border: none; + border-top: 1px solid var(--border); + margin: 4px 0; +} + +.side-row--waiting { + cursor: default; +} + +.side-row__sub { + display: block; + font-size: 12px; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A failed start's reason: shown whole, not cut off. */ +.side-row__sub--error { + color: var(--danger); + opacity: 1; + white-space: normal; +} + +.context-menu .context-menu__on { + font-weight: 600; +} + +.analytics__warning { + margin: 0; + padding: 8px 12px; + border-radius: 6px; + background: rgb(142 38 38 / 6%); + color: var(--danger); +} + +.analytics__table { + width: 100%; + border-collapse: collapse; +} + +.analytics__table th, +.analytics__table td { + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.analytics__table th:not(:first-child), +.analytics__table td:not(:first-child) { + text-align: right; +} + +.process h3 { + margin: 8px 0; + font-size: 14px; +} + +.process__row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +/* Settings → Design system: one line per fact, view-only once it exists. */ +.design-system { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; +} + +.design-system p { + margin: 0; +} + +.design-system__issue { + padding-left: 16px; +} + +.design-system__make { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 4px; +} + +/* ── building pods ───────────────────────────────────────────────────────── + The sidebar's Building rows, the Product manager chat's progress bubbles, + and the Workflow and Tickets rails. */ +.side-row__dot--building, +.side-row__dot--testing, +.side-row__dot--shipping { + background: var(--success); +} + +.side-row__dot--blocked { + background: var(--danger); +} + +.side-row--building .side-row__label { + display: flex; + flex-direction: column; +} + +.build-bubble { + display: flex; + flex-wrap: wrap; + gap: 0 4px; + align-items: baseline; + margin: 4px 0; + padding-left: 22px; + color: var(--grey-800); + font-size: 13px; +} + +/* Markdown after the role's name; a line break in it is a line on screen. */ +.build-bubble .md { + flex: 1 1 0; + min-width: 0; +} + +.build-bubble .md p { + white-space: pre-line; +} + +/* The answer box under an item waiting on the owner. */ +.build-answer { + display: flex; + flex-basis: 100%; + gap: 6px; + align-items: flex-end; + margin-top: 6px; +} + +.build-answer textarea { + flex: 1; + min-width: 0; + box-sizing: border-box; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + font: inherit; + resize: vertical; +} + +.build-bubble__dot { + color: var(--grey-500); +} + +.segmented { + display: inline-flex; + margin: 12px 0; + padding: 2px; + border-radius: var(--radius); + background: var(--grey-100); +} + +.segmented button { + padding: 4px 12px; + border: 0; + border-radius: 6px; + background: transparent; + font: inherit; + cursor: pointer; +} + +.segmented button.segmented--on { + background: var(--surface); + box-shadow: var(--ring); +} + +.build-workflow__title { + margin: 12px 0; + font-size: 14px; +} + +.build-steps { + margin: 0; + padding: 0; + list-style: none; +} + +.build-step { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 96px 48px; + gap: 4px 8px; + align-items: center; + padding: 4px 0; +} + +/* Whole run: no mark, and a share column before the bar. */ +.build-step--whole { + grid-template-columns: minmax(0, 1fr) 40px 96px 56px; +} + +.build-step--todo { + color: var(--grey-500); +} + +/* Skipped: never ran, so muted like a step still to come. */ +.build-step--skipped { + color: var(--grey-500); +} + +.build-step--active { + font-weight: 600; +} + +.build-step--total { + display: block; + margin-top: 8px; + color: var(--grey-800); +} + +.build-step__time { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.build-step__shots, +.build-step__stages { + grid-column: 2 / -1; + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--grey-800); + font-size: 12px; + font-weight: 400; +} + +.build-bar { + display: block; + height: 6px; + border-radius: 3px; + background: var(--grey-100); +} + +.build-bar__fill { + display: block; + height: 100%; + border-radius: 3px; + background: var(--black); +} + +.build-shot { + position: relative; + width: 56px; + height: 40px; + padding: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--grey-50); + cursor: pointer; +} + +.build-shot img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.build-shot--failed { + border-color: var(--danger); +} + +.build-shot__fail { + position: absolute; + top: 0; + right: 2px; + color: var(--danger); + font-weight: 700; +} + +/* The Visual check's rounds: one expected | actual pair per picture. */ +.build-step__pairs { + grid-column: 2 / -1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.build-pairs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.build-pair { + position: relative; + display: flex; + gap: 2px; + width: 116px; + height: 40px; + padding: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--grey-50); + cursor: pointer; +} + +.build-pair img { + width: 50%; + height: 100%; + object-fit: cover; +} + +.build-pair--missed { + border-color: var(--danger); +} + +.visual-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.visual-pair figure { + margin: 0; +} + +.visual-pair figcaption { + margin-bottom: 4px; + font-weight: 600; +} + +.visual-pair__issues { + margin: 12px 0 0; + padding-left: 18px; +} + +.build-bubble__pictures { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.build-bubble__pictures img { + max-width: 100%; + max-height: 240px; + border: 1px solid var(--border); + border-radius: 4px; +} + +.build-compare { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px 12px; + margin: 16px 0 0; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.build-compare dd { + display: flex; + gap: 8px; + align-items: center; + margin: 0; +} + +.build-compare dd .build-bar { + flex: 1; +} + +.build-tickets__head { + margin: 16px 0 6px; + color: var(--grey-500); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.build-tickets ul { + margin: 0; + padding: 0; + list-style: none; +} + +.build-ticket { + display: flex; + gap: 8px; + width: 100%; + padding: 6px 4px; + border: 0; + border-radius: 6px; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.build-ticket:hover { + background: var(--grey-50); +} + +.build-ticket__title { + flex: 1; + min-width: 0; +} + +.build-ticket__time { + color: var(--grey-800); + font-variant-numeric: tabular-nums; +} + +.dialog--screenshot { + position: fixed; + inset: 40px; + z-index: 50; + overflow-y: auto; +} + +.dialog__head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.screenshot__full { + max-width: 100%; + border: 1px solid var(--border); + border-radius: 4px; +} + +.screenshot__url, +.screenshot__console { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.screenshot__console { + color: var(--danger); +} + +/* ── the Tickets screen, as Linear lays issues out ───────────────────────── + A page of its own: no toolbar or rail beside it. The list runs edge to + edge of the card; a ticket's page puts its properties on the right. */ +.main > .toolbar[hidden] { + display: none; +} + +.main--tickets > .main__scroll { + margin: 0 calc(-1 * var(--main-pad)); + padding-bottom: 0; +} + +.tickets-screen { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.tickets, +.ticket { + display: flex; + flex: 1; + flex-direction: column; + min-height: 100%; +} + +.tickets__head, +.ticket__head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 14px 28px; + border-bottom: 1px solid rgb(11 11 11 / 8%); + background: var(--white); +} + +.tickets__title { + flex: 1; + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.tickets__count { + margin-left: 6px; + color: #6f6d68; + font-weight: 400; +} + +.tk-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 7px; + background: var(--surface); + color: var(--black); + font-size: 13.5px; + line-height: 1.2; +} + +.tk-button:hover { + background: var(--grey-50); +} + +.tk-button--primary { + padding: 7px 12px; + border-color: var(--black); + background: var(--black); + color: var(--surface); +} + +.tk-button--primary:hover { + background: #2a2a28; +} + +.tk-button--primary:disabled { + border-color: var(--grey-300); + background: var(--grey-300); + cursor: default; +} + +.tk-button--quiet { + border-color: transparent; + background: transparent; + color: var(--grey-800); +} + +.tk-icon-button { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 6px; + background: var(--surface); + color: var(--black); +} + +.tk-icon-button:disabled { + color: var(--grey-300); + cursor: default; +} + +.tk-icon-button--bare { + border-color: transparent; + background: transparent; + color: #6f6d68; +} + +/* Filter chips, "+ Add filter" and the search box. */ +.tickets__filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 28px; + border-bottom: 1px solid rgb(11 11 11 / 8%); +} + +.tk-filter-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 8px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 6px; + background: var(--surface); + font-size: 13px; +} + +.tk-filter-chip strong { + font-weight: 500; +} + +.tk-filter-chip__field { + color: #6f6d68; +} + +.tk-filter-chip button { + display: grid; + place-items: center; + padding: 2px 4px; + border: 0; + background: transparent; + color: #6f6d68; +} + +.tk-add-filter { + padding: 3px 6px; + border: 0; + background: transparent; + color: #6f6d68; + font-size: 13px; +} + +.tk-add-filter:hover { + color: var(--black); +} + +.tickets__search { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + padding: 3px 8px; + border-radius: 6px; + color: #6f6d68; +} + +.tickets__search:focus-within { + box-shadow: var(--ring); +} + +.tickets__search input { + width: 140px; + border: 0; + outline: 0; + background: transparent; + font: inherit; + font-size: 13px; +} + +.tickets__list { + flex: 1; + container-type: inline-size; +} + +.tickets__offline, +.tickets__empty, +.tickets__loading { + margin: 12px 28px; +} + +/* A group: a faint ▾ that folds it, its status icon, name and count. */ +.tk-group__head { + display: flex; + align-items: center; + padding: 0 22px 0 6px; + border-bottom: 1px solid rgb(11 11 11 / 6%); + background: var(--grey-50); +} + +.tk-group__toggle { + display: flex; + flex: 1; + align-items: center; + gap: 10px; + padding: 9px 8px; + border: 0; + background: transparent; + color: var(--black); + text-align: left; +} + +.tk-group__chevron { + color: #b9b7b1; + transition: transform 120ms ease; +} + +.tk-group__chevron--folded { + transform: rotate(-90deg); +} + +.tk-group__name { + font-weight: 500; +} + +.tk-group__count { + color: #6f6d68; +} + +.tk-group__add { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: #8a8882; + opacity: 0; +} + +.tk-group__head:hover .tk-group__add, +.tk-group__add:focus-visible { + opacity: 1; +} + +.tk-group__add:hover { + background: var(--grey-100); + color: var(--black); +} + +/* One row per ticket, 44px, everything on one line. */ +.tk-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 44px; + padding: 0 28px; + border: 0; + border-bottom: 1px solid rgb(11 11 11 / 5%); + background: transparent; + color: var(--black); + font: inherit; + text-align: left; +} + +.tk-row:hover, +.tk-row--at { + background: var(--grey-50); +} + +.tk-row:focus-visible { + outline: 2px solid var(--black); + outline-offset: -2px; +} + +.tk-row--drop { + box-shadow: inset 0 -2px 0 var(--black); +} + +.tk-row[draggable='true'] { + cursor: grab; +} + +.tk-row__priority { + display: grid; + flex: none; + place-items: center; + width: 16px; +} + +.tk-row__id { + flex: none; + box-sizing: border-box; + width: 92px; + color: #6f6d68; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* A ticket under its epic: indented, with the epic's line down its side. */ +.tk-row--child .tk-row__id { + position: relative; + width: 106px; + padding-left: 14px; +} + +.tk-row--child .tk-row__id::before { + position: absolute; + top: -14px; + bottom: -14px; + left: 2px; + width: 1px; + background: rgb(11 11 11 / 12%); + content: ''; +} + +.tk-row__title { + display: flex; + flex: 1; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.tk-row__text { + overflow: hidden; + text-overflow: ellipsis; +} + +.tk-row__reason { + flex: none; + color: var(--danger); +} + +.tk-row__commit { + flex: none; + color: var(--grey-800); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.tk-row__assignee { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + width: 180px; + overflow: hidden; + color: var(--grey-800); + font-size: 13px; + white-space: nowrap; +} + +.tk-row__assignee span { + overflow: hidden; + text-overflow: ellipsis; +} + +.tk-row__assignee--none { + color: #8a8882; +} + +.tk-row__date { + flex: none; + width: 56px; + color: #6f6d68; + font-size: 13px; + text-align: right; +} + +.tk-chip { + display: inline-flex; + flex: none; + align-items: center; + gap: 5px; + padding: 2px 8px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 999px; + background: transparent; + color: var(--grey-800); + font-size: 12px; + line-height: 1.4; + white-space: nowrap; +} + +.tk-chip--progress { + padding: 1px 8px; +} + +.tk-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--label); +} + +.tk-chip__remove { + display: grid; + place-items: center; + margin-right: -4px; + padding: 1px; + border: 0; + background: transparent; + color: #8a8882; +} + +.tk-chip--add { + padding: 0 8px; + border-style: dashed; + color: #6f6d68; +} + +.tickets__keys { + margin: 0; + padding: 14px 28px; + border-top: 1px solid rgb(11 11 11 / 6%); + color: #6f6d68; + font-size: 12.5px; +} + +.tickets__keys kbd { + padding: 0 5px; + border: 1px solid rgb(11 11 11 / 15%); + border-radius: 4px; + font-family: inherit; +} + +/* The one icon set: status, priority and computer. */ +.status-icon { + flex: none; +} + +.status-icon--inbox, +.status-icon--planning, +.status-icon--canceled { + color: #8a8882; +} + +.status-icon--ready { + color: var(--grey-800); +} + +.status-icon--building { + color: var(--amber); +} + +.status-icon--blocked { + color: var(--danger); +} + +.status-icon--shipped { + color: var(--shipped); +} + +.priority-icon { + flex: none; + color: var(--grey-800); +} + +.priority-icon__on { + fill: var(--grey-800); +} + +.priority-icon__off { + fill: #d6d4cf; +} + +.priority-icon--urgent { + color: var(--urgent); +} + +.priority-icon--none { + color: #8a8882; + font-size: 12px; + letter-spacing: 1px; + line-height: 1; +} + +.computer-icon { + flex: none; +} + +/* A ticket that's yours, by hand: your initials, as in the account row. */ +.tk-avatar { + display: inline-grid; + flex: none; + place-items: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--grey-300); + color: var(--black); + font-size: 9px; + font-weight: 600; +} + +.state-dot { + display: inline-block; + width: 8px; + height: 8px; + margin: 0 3px; + border-radius: 50%; + background: #c9c7c1; +} + +.state-dot--building, +.state-dot--testing, +.state-dot--shipping { + background: var(--success); +} + +.state-dot--blocked { + background: var(--danger); +} + +.state-dot--paused { + border: 1.5px solid #8a8882; + background: transparent; +} + +.state-dot--offline { + border: 1.5px dashed #8a8882; + background: transparent; +} + +/* Linear's dropdowns: a filter box, then the options. */ +.menu-anchor { + position: relative; + display: inline-flex; +} + +.ticket-menu { + position: absolute; + top: calc(100% + 4px); + z-index: 20; + width: 260px; + padding: 4px; + border-radius: 8px; + background: var(--surface); + box-shadow: var(--ring), 0 8px 28px rgb(0 0 0 / 10%); + text-align: left; +} + +.ticket-menu--left { + left: 0; +} + +.ticket-menu--right { + right: 0; +} + +.ticket-menu__filter { + box-sizing: border-box; + width: 100%; + margin-bottom: 4px; + padding: 8px 10px; + border: 0; + border-bottom: 1px solid rgb(11 11 11 / 8%); + outline: 0; + background: transparent; + font: inherit; +} + +.ticket-menu__list { + max-height: 300px; + overflow-y: auto; +} + +.ticket-menu__option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--black); + font: inherit; + text-align: left; +} + +.ticket-menu__option--at { + background: var(--grey-50); +} + +.ticket-menu__option:disabled { + color: var(--grey-500); +} + +.ticket-menu__icon { + display: grid; + flex: none; + place-items: center; + width: 16px; +} + +.ticket-menu__label { + flex: 1; + min-width: 0; +} + +.ticket-menu__hint { + display: block; + color: #6f6d68; + font-size: 12px; +} + +.ticket-menu__trail { + color: #6f6d68; + font-size: 12px; +} + +.ticket-menu__tick { + flex: none; +} + +.ticket-menu__empty { + margin: 6px 10px; + color: #6f6d68; +} + +/* In Properties, a menu is as wide as the column, as Linear's. */ +.ticket__props .ticket-menu { + right: 0; + left: 0; + width: auto; +} + +/* The + beside the labels is small: its menu spans the Labels row, as wide + as the other property menus, not the + itself. */ +.ticket__props .tk-labels { + position: relative; +} + +.ticket__props .tk-labels .menu-anchor { + position: static; +} + +/* A ticket's page: the main column, then Properties. */ +.ticket__crumbs { + display: flex; + flex: 1; + align-items: center; + gap: 8px; + min-width: 0; + color: #6f6d68; +} + +.ticket__crumb { + padding: 0; + border: 0; + background: transparent; + color: #6f6d68; + font: inherit; + white-space: nowrap; +} + +button.ticket__crumb:hover { + color: var(--black); +} + +.ticket__crumb--here { + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; +} + +.ticket__position { + color: #6f6d68; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.ticket__loading { + margin: 24px 28px; +} + +.ticket__body { + display: flex; + flex: 1; + flex-wrap: wrap; +} + +.ticket__main { + display: flex; + flex: 999 1 520px; + flex-direction: column; + gap: 18px; + box-sizing: border-box; + min-width: 0; + max-width: 900px; + padding: 36px 56px 28px; +} + +.ticket__notice { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + background: var(--amber-tint); + color: var(--amber-ink); + font-size: 13.5px; +} + +.ticket__title { + width: 100%; + margin: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--black); + font: inherit; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.ticket__parent { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: -8px 0 0; + color: #6f6d68; +} + +.ticket__parent-link { + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; +} + +.ticket__description { + color: #1d1d1b; + line-height: 1.6; +} + +/* The description is always the editor, as in Linear: no box, no buttons. */ +.md-editor .ProseMirror { + min-height: 48px; + outline: 0; +} + +.md-editor .ProseMirror > :first-child { + margin-top: 0; +} + +.md-editor .ProseMirror p { + margin: 0 0 10px; +} + +.md-editor .ProseMirror h1, +.md-editor .ProseMirror h2, +.md-editor .ProseMirror h3 { + margin: 18px 0 6px; + font-size: 16px; + font-weight: 600; +} + +.md-editor .ProseMirror h1 { + font-size: 19px; +} + +.md-editor .ProseMirror ul, +.md-editor .ProseMirror ol { + margin: 0 0 10px; + padding-left: 22px; +} + +.md-editor .ProseMirror li p { + margin: 0; +} + +.md-editor .ProseMirror code { + padding: 1px 5px; + border-radius: 4px; + background: var(--grey-100); + font-size: 0.92em; +} + +.md-editor .ProseMirror pre { + padding: 10px 12px; + border-radius: 8px; + background: var(--grey-100); + overflow-x: auto; +} + +.md-editor .ProseMirror pre code { + padding: 0; + background: transparent; +} + +.md-editor .ProseMirror blockquote { + margin: 0 0 10px; + padding-left: 14px; + border-left: 2px solid var(--grey-300); + color: var(--grey-800); +} + +.md-editor .ProseMirror a { + color: var(--link); + cursor: text; +} + +.md-editor .ProseMirror img { + max-width: 100%; + border-radius: 6px; +} + +.md-editor .ProseMirror table { + border-collapse: collapse; + margin: 0 0 10px; +} + +.md-editor .ProseMirror td, +.md-editor .ProseMirror th { + padding: 4px 8px; + border: 1px solid var(--grey-300); + text-align: left; + vertical-align: top; +} + +.md-editor .ProseMirror td p, +.md-editor .ProseMirror th p { + margin: 0; +} + +.md-editor .ProseMirror p.is-editor-empty:first-child::before { + float: left; + height: 0; + color: #8a8882; + content: attr(data-placeholder); + pointer-events: none; +} + +.md-editor__problem { + margin: 6px 0 0; + color: var(--danger); + font-size: 13px; +} + +/* Select text: Linear's floating formatting bar. */ +.md-bar { + z-index: 30; + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: 8px; + background: var(--surface); + box-shadow: var(--ring), 0 8px 24px rgb(0 0 0 / 12%); +} + +.md-bar__tool { + display: grid; + place-items: center; + min-width: 28px; + height: 28px; + padding: 0 4px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-800); +} + +.md-bar__tool:hover { + background: var(--grey-100); + color: var(--black); +} + +.md-bar__tool--on { + background: var(--grey-100); + color: var(--black); +} + +.md-bar__text { + font-size: 13px; + font-weight: 500; +} + +.md-bar__line { + width: 1px; + height: 18px; + margin: 0 3px; + background: rgb(11 11 11 / 12%); +} + +.md-bar__link { + width: 240px; + padding: 4px 8px; + border: 0; + outline: 0; + font: inherit; + font-size: 13px; +} + +.md-bar--linking .md-bar__tool, +.md-bar--linking .md-bar__line { + display: none; +} + +.ticket__activity { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid rgb(11 11 11 / 8%); +} + +.ticket__activity h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.ticket__event { + display: flex; + gap: 10px; + color: var(--grey-800); + font-size: 13.5px; +} + +.ticket__event strong { + color: var(--black); + font-weight: 500; +} + +.ticket__event-dot { + flex: none; + width: 4px; + height: 4px; + margin: 8px 7px 0; + border-radius: 50%; + background: #8a8882; +} + +.ticket__talk { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border-radius: 8px; + background: var(--surface); + box-shadow: 0 0 0 1px rgb(11 11 11 / 10%); +} + +.ticket__talk--you { + background: var(--grey-50); + box-shadow: none; +} + +.ticket__talk-who { + color: #6f6d68; + font-size: 12.5px; +} + +.ticket__talk p { + margin: 0; +} + +.ticket__quiet, +.ticket__sent { + margin: 0; +} + +.ticket__comment { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 6px; + padding: 12px 14px; + border-radius: 10px; + background: var(--surface); + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); +} + +.ticket__comment textarea { + border: 0; + outline: 0; + background: transparent; + font: inherit; + resize: none; +} + +.ticket__comment-foot { + display: flex; + align-items: center; + justify-content: space-between; + color: #6f6d68; + font-size: 12.5px; +} + +.ticket__props { + display: flex; + flex: 1 1 300px; + flex-direction: column; + gap: 4px; + box-sizing: border-box; + max-width: 340px; + padding: 28px 24px; + border-left: 1px solid rgb(11 11 11 / 8%); +} + +.tk-props__title { + margin: 0 0 8px; + color: #6f6d68; + font-size: 13px; + font-weight: 500; +} + +.ticket__props > .menu-anchor { + display: flex; +} + +.tk-prop-line { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--black); + font: inherit; + text-align: left; +} + +button.tk-prop-line:hover, +.tk-prop-line--open { + background: var(--grey-100); +} + +button.tk-prop-line:disabled { + color: var(--grey-800); + cursor: default; +} + +button.tk-prop-line:disabled:hover { + background: transparent; +} + +.tk-prop-line--inline { + padding: 4px 8px; +} + +.tk-prop-line__text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tk-prop-line__lock { + display: grid; + color: #8a8882; +} + +.tk-prop { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 18px; +} + +.tk-prop__heading { + color: #6f6d68; + font-size: 13px; + font-weight: 500; +} + +.tk-prop__value { + padding: 0 8px; +} + +.tk-prop__value .menu-anchor { + display: flex; + margin: 0 -8px; +} + +.tk-labels { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.tk-source { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; +} + +.tk-source code { + font-size: 12.5px; +} + +/* New ticket: Linear's dialog. */ +.tk-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: start center; + padding: 12vh 16px 16px; + background: rgb(11 11 11 / 18%); +} + +.tk-dialog { + display: flex; + flex-direction: column; + gap: 10px; + box-sizing: border-box; + width: min(680px, 100%); + padding: 16px 18px; + border-radius: 12px; + background: var(--surface); + box-shadow: var(--ring), 0 24px 60px rgb(0 0 0 / 18%); +} + +.tk-dialog__head { + display: flex; + align-items: center; + gap: 8px; +} + +.tk-dialog__head h2 { + flex: 1; + margin: 0; + color: #6f6d68; + font-size: 13px; + font-weight: 500; +} + +.tk-dialog__project { + padding: 4px 8px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 6px; + background: var(--surface); + font-size: 13px; +} + +.tk-dialog__title { + padding: 6px 0; + border: 0; + outline: 0; + font: inherit; + font-size: 19px; + font-weight: 600; +} + +.tk-dialog__description { + border: 0; + outline: 0; + font: inherit; + line-height: 1.55; + resize: vertical; +} + +.tk-dialog__props { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tk-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 7px; + background: var(--surface); + color: var(--grey-800); + font-size: 13px; +} + +.tk-pill:hover, +.tk-pill--open { + background: var(--grey-50); +} + +.tk-dialog__problem { + margin: 0; + color: var(--danger); +} + +.tk-dialog__foot { + display: flex; + align-items: center; + justify-content: space-between; + margin: 4px -18px -16px; + padding: 12px 18px; + border-top: 1px solid rgb(11 11 11 / 8%); +} + +.tk-toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 70; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px 10px 14px; + border-radius: 10px; + background: var(--surface); + box-shadow: var(--ring), 0 8px 28px rgb(0 0 0 / 12%); +} + +/* The sidebar's Building rows: offline greyed, paused bars, the ⋯ menu. */ +.side-row--offline .side-row__label { + color: var(--grey-500); +} + +.side-row__dot--offline { + border: 1.5px dashed var(--grey-500); + background: transparent; +} + +.side-row__paused { + display: grid; + flex: none; + place-items: center; + width: 18px; + color: var(--grey-500); +} + +.side-row--building .side-row__more { + align-self: center; +} + +.context-menu button .side-icon { + margin-right: 6px; + vertical-align: -4px; +} + +/* Narrow: Linear's way, the quieter columns go first. */ +@container (max-width: 820px) { + .tk-row > .tk-chip, + .tk-row__commit { + display: none; + } + + .tk-row__assignee { + width: 140px; + } +} + +@container (max-width: 600px) { + .tk-row { + gap: 8px; + } + + .tk-row__date, + .tk-row__assignee span { + display: none; + } + + .tk-row__assignee { + width: auto; + } + + .tk-row__id { + width: 84px; + } +} + +@media (max-width: 720px) { + .tickets__head, + .ticket__head, + .tickets__filters, + .tickets__keys { + padding-right: 16px; + padding-left: 16px; + } + + .tk-row { + padding: 0 16px; + } + + .ticket__main { + padding: 24px 16px; + } + + .ticket__props { + max-width: none; + border-top: 1px solid rgb(11 11 11 / 8%); + border-left: 0; + } +} + +/* ── one flow: a Lambda across its whole life ────────────────────────────── + The sidebar's Lambda rows (Needs you, Planning, Queued, Building, Shipped), + the computers line at its foot, a Lambda's page past planning (phase + strip, one thread, its phase's rail and player bar), and caught up. */ +.side-section__count { + margin-left: 4px; + color: var(--grey-500); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.side-section__count--badge { + min-width: 18px; + padding: 0 6px; + border-radius: 999px; + background: var(--black); + color: #fff; + font-size: 11px; + line-height: 18px; + text-align: center; +} + +.side-group + .side-group { + margin-top: 4px; +} + +.side-group__name { + margin: 4px 10px 2px 32px; + color: var(--grey-500); + font-size: 11.5px; +} + +.side-row--lambda { + align-items: flex-start; + padding-top: 6px; + padding-bottom: 6px; +} + +.side-row--lambda .side-row__label { + white-space: normal; +} + +.side-row--lambda .side-row__label, +.side-row--lambda .side-row__sub { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-row--lambda .side-row__sub { + color: var(--grey-500); + font-size: 12.5px; + opacity: 1; +} + +.side-row--lambda .side-row__dot { + width: 8px; + height: 8px; + margin: 6px 0 0; +} + +.side-row__dot--needs { + background: var(--black); +} + +.side-row--lambda .side-row__sub--urgent { + color: var(--danger); +} + +.side-row__ring, +.side-row__shipped { + flex: none; + margin-top: 4px; +} + +.side-row__shipped { + color: var(--shipped); +} + +.side-row--lambda .side-row__when { + align-self: center; + font-size: 12.5px; +} + +.side-row--epic { + align-items: center; + color: var(--black); +} + +.side-row__caret { + display: grid; + flex: none; + width: 9px; + color: var(--grey-500); +} + +.side-row__caret .side-icon { + width: 12px; + height: 12px; +} + +.side-chip { + padding: 0 7px; + border-radius: 999px; + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); + color: var(--grey-800); + font-size: 11.5px; + line-height: 18px; +} + +.side-row--child { + align-items: flex-start; + min-height: 0; + padding: 5px 10px 5px 30px; +} + +.side-row--child .side-row__dot { + width: 7px; + height: 7px; + margin: 6px 0 0; +} + +.side-row--child .side-row__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-row--child .side-row__sub { + color: var(--grey-500); + font-size: 12.5px; + opacity: 1; +} + +/* The computers line: one line however many, opening a popover. */ +.computers { + position: relative; + flex: none; +} + +.computers__line { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin: 6px 0 4px; + padding: 8px 10px; + border: 0; + border-top: 1px solid var(--border); + background: transparent; + color: var(--grey-800); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} + +.computers__line:hover, +.computers__line--open { + background: var(--sidebar-hover); +} + +.computers__counts { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.computers__count--paused { + color: #9a5b0b; +} + +.computers__count--offline { + color: var(--grey-500); +} + +.computers__popover { + position: fixed; + z-index: 40; + box-sizing: border-box; + width: 420px; + max-height: min(70vh, 640px); + padding: 6px; + overflow-y: auto; + border-radius: 10px; + background: var(--surface); + box-shadow: 0 0 0 1px var(--border), 0 12px 36px rgb(0 0 0 / 14%); + color: var(--black); + font-size: 13.5px; +} + +.computers__top { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 6px; +} + +.computers__top strong { + flex: 1; + font-weight: 600; +} + +.computers__search { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 6px; + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); + color: var(--grey-500); +} + +.computers__search input { + width: 90px; + border: 0; + outline: 0; + background: transparent; + font: inherit; + font-size: 12.5px; +} + +.computers__head { + margin: 10px 10px 4px; + color: var(--grey-500); + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; +} + +.computers__row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 6px; +} + +.computers__group--attention .computers__row:has(.computers__dot--paused) { + background: var(--amber-tint); +} + +.computers__dot { + flex: none; + box-sizing: border-box; + width: 8px; + height: 8px; + border-radius: 50%; + background: #c9c7c1; +} + +.computers__dot--building, +.computers__dot--testing, +.computers__dot--shipping { + background: var(--success); +} + +.computers__dot--blocked { + background: var(--danger); +} + +.computers__dot--paused { + background: #9a5b0b; +} + +.computers__dot--offline { + border: 1.5px dashed var(--grey-500); + background: transparent; +} + +.computers__name { + flex: 1; + min-width: 0; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.computers__name:disabled { + cursor: default; +} + +.computers__detail { + flex: none; + max-width: 55%; + overflow: hidden; + color: var(--grey-500); + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.computers__group--attention + .computers__row:has(.computers__dot--paused) + .computers__detail { + color: var(--amber-ink); +} + +.computers__act { + flex: none; + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; +} + +.computers__act--quiet { + color: var(--grey-500); + font-weight: 400; + opacity: 0; +} + +.computers__row:hover .computers__act--quiet { + opacity: 1; +} + +.computers__more { + margin: 0 0 4px 28px; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--grey-500); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.computers__foot { + display: flex; + gap: 8px; + margin-top: 6px; + padding: 8px 10px 4px; + border-top: 1px solid var(--border); +} + +.computers__button { + padding: 5px 10px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 6px; + background: var(--surface); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.computers__button:disabled { + color: var(--grey-500); + cursor: default; +} + +.computers__spacer { + flex: 1; +} + +.computers__link { + align-self: center; + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +/* The phase strip, under a Lambda's header in every phase. */ +.lambda-page__strip { + margin: 0 calc(-1 * var(--main-pad, 24px)); + padding: 10px var(--main-pad, 24px); + border-bottom: 1px solid rgb(11 11 11 / 6%); +} + +.phase-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.phase-strip__item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.phase-strip__line { + width: 22px; + height: 1px; + background: rgb(11 11 11 / 15%); +} + +.phase-strip__line--done { + background: var(--black); +} + +.phase-strip__step { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + background: var(--surface); + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); + color: var(--grey-500); +} + +.phase-strip__dot { + box-sizing: border-box; + width: 7px; + height: 7px; + border: 1.5px solid #b9b7b1; + border-radius: 50%; +} + +.phase-strip__step--done { + color: var(--black); +} + +.phase-strip__step--done .phase-strip__dot { + border: 0; + background: var(--black); +} + +.phase-strip__step--current { + background: var(--black); + box-shadow: 0 0 0 1px var(--black); + color: #fff; + font-weight: 600; +} + +.phase-strip__step--current .phase-strip__dot { + border: 0; + background: #fff; +} + +/* A Lambda's page: the header, one thread, its phase's rail. */ +.lambda-page { + display: flex; + flex-direction: column; +} + +.lambda-page .toolbar__title { + max-width: none; +} + +.lambda-page__where { + margin-left: 8px; + color: var(--grey-500); + font-weight: 400; +} + +.lambda-page .toolbar__right { + display: flex; + align-items: center; + gap: 8px; +} + +.lambda-page__button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: 1px solid rgb(11 11 11 / 15%); + border-radius: 7px; + background: var(--surface); + color: var(--black); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.lambda-page__button:hover { + background: var(--grey-50); +} + +.lambda-page__menu { + position: relative; +} + +.lambda-page__more { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--black); + cursor: pointer; +} + +.lambda-page__more:hover { + background: var(--grey-50); +} + +.lambda-page__menu-list.context-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + left: auto; + min-width: 200px; +} + +.lambda-thread { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + margin: 0 calc(-1 * var(--main-pad, 24px)); +} + +.lambda-thread__log { + display: flex; + flex: 1; + flex-direction: column; + gap: 14px; + min-height: 0; + padding: 24px max(24px, calc((100% - 690px) / 2)); + overflow-y: auto; +} + +.lambda-thread__log .msg { + margin: 0; +} + +.lambda-thread__fold { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 8px; + padding: 6px 10px; + border: 0; + border-radius: 7px; + background: var(--grey-50); + color: var(--grey-800); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.lambda-thread__fold svg { + transition: transform 0.15s; +} + +.lambda-thread__fold .lambda-thread__caret--open { + transform: rotate(90deg); +} + +.lambda-thread__folded { + display: flex; + flex-direction: column; + gap: 10px; + padding-left: 12px; + border-left: 2px solid var(--grey-100); +} + +.lambda-thread__divider { + display: flex; + align-items: center; + gap: 10px; + color: var(--grey-500); + font-size: 12.5px; +} + +.lambda-thread__divider::before, +.lambda-thread__divider::after { + flex: 1; + height: 1px; + background: rgb(11 11 11 / 10%); + content: ''; +} + +.lambda-thread__activity { + display: flex; + flex-direction: column; + gap: 4px; +} + +.lambda-thread__line { + display: flex; + gap: 10px; + color: var(--grey-800); + font-size: 13.5px; +} + +.lambda-thread__line > span:first-child { + color: var(--grey-500); +} + +.lambda-thread__line strong { + color: var(--black); + font-weight: 500; +} + +.lambda-thread__said { + display: flex; + flex-direction: column; + gap: 4px; + line-height: 1.55; +} + +.lambda-thread__said p { + margin: 0; +} + +.lambda-thread__who { + color: var(--grey-500); + font-size: 12.5px; +} + +.lambda-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + border-radius: 10px; + background: var(--surface); + box-shadow: var(--ring), var(--lift); +} + +.lambda-card strong { + font-size: 15px; +} + +.lambda-card--blocked { + gap: 12px; + box-shadow: 0 0 0 1.5px var(--danger), var(--lift); +} + +.lambda-card__top { + display: flex; + align-items: center; + gap: 8px; +} + +.lambda-card__badge { + padding: 2px 8px; + border-radius: 999px; + background: var(--danger); + color: #fff; + font-size: 12px; + font-weight: 600; +} + +.lambda-card__body { + line-height: 1.55; +} + +.lambda-card__body p { + margin: 0 0 8px; +} + +.lambda-card__from { + display: block; + margin-bottom: 4px; + color: var(--grey-500); + font-size: 12.5px; +} + +.lambda-card__answer { + box-sizing: border-box; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 7px; + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); + font: inherit; + resize: vertical; +} + +.lambda-card__answer:focus { + outline: 0; + box-shadow: 0 0 0 1.5px var(--black); +} + +.lambda-card__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.lambda-compose { + flex: none; + padding: 8px max(24px, calc((100% - 690px) / 2)) 16px; +} + +.lambda-compose label { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border-radius: 12px; + background: var(--surface); + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); +} + +.lambda-compose label:focus-within { + box-shadow: 0 0 0 1.5px var(--black); +} + +.lambda-compose__to { + color: var(--grey-500); + font-size: 12.5px; +} + +.lambda-compose textarea { + min-height: 40px; + border: 0; + outline: 0; + background: transparent; + font: inherit; + resize: none; + field-sizing: content; +} + +/* Its phase's rail. */ +.lambda-rail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px 24px; +} + +.lambda-rail__head { + display: flex; + align-items: center; + gap: 4px; + margin: 6px 0 0; + font-size: 13.5px; + font-weight: 600; +} + +.lambda-rail__read { + display: grid; + place-items: center; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--grey-500); + cursor: pointer; +} + +.lambda-rail__read:hover { + color: var(--black); +} + +.lambda-rail__text { + margin: 0; + color: #1d1d1b; + line-height: 1.55; +} + +.lambda-rail__text p { + margin: 0 0 8px; +} + +.lambda-rail__muted { + color: var(--grey-500); + font-size: 13px; +} + +.lambda-rail__place { + padding: 14px 16px; + border-radius: 10px; + background: var(--white); + box-shadow: var(--ring); +} + +.lambda-rail__big { + font-size: 22px; + font-weight: 600; +} + +.lambda-rail__place .lambda-rail__muted { + margin-top: 2px; + color: var(--grey-800); + font-size: 14px; +} + +.lambda-rail__row, +.lambda-rail__ticket { + display: flex; + align-items: center; + gap: 10px; +} + +.lambda-rail__row--this { + margin: 0 -8px; + padding: 6px 8px; + border-radius: 6px; + background: var(--grey-100); + font-weight: 500; +} + +.lambda-rail__grow { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lambda-rail__note { + flex: none; + color: var(--grey-500); + font-size: 12.5px; +} + +.lambda-rail__list { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.lambda-rail__ticket { + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.lambda-rail__ticket:hover, +.lambda-rail__ticket--on { + background: var(--grey-100); +} + +.lambda-rail .link-button { + align-self: flex-start; + font-size: 13px; +} + +.lambda-rail__checks { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13.5px; +} + +.lambda-rail__send-back { + align-self: flex-start; +} + +.lambda-rail__facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.lambda-rail__fact { + display: flex; + flex-direction: column; + padding: 10px 12px; + border-radius: 8px; + background: var(--white); + box-shadow: var(--ring); +} + +.lambda-rail__fact > span:first-child { + color: var(--grey-500); + font-size: 12px; +} + +.lambda-rail__file { + display: flex; + gap: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; +} + +.lambda-rail__added { + color: var(--success); +} + +.lambda-rail__removed { + color: var(--danger); +} + +.lambda-rail__chips { + display: flex; + gap: 6px; +} + +.lambda-rail__chip { + padding: 4px 10px; + border: 1px solid rgb(11 11 11 / 12%); + border-radius: 999px; + background: var(--surface); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.lambda-rail__chip--on { + border-color: var(--black); + background: var(--black); + color: #fff; +} + +.lambda-rail__group { + margin: 8px 0 0; + color: var(--grey-500); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.lambda-rail__shots { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +/* The player bar: read aloud, and the phase's main action. */ +.lambda-player.rail__actions { + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 12px 18px 12px 24px; + background: var(--white); +} + +.lambda-player__skip, +.lambda-player__play, +.lambda-player__speed { + display: grid; + place-items: center; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + cursor: pointer; +} + +.lambda-player__skip { + width: 28px; + height: 28px; +} + +.lambda-player__play { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--black); + color: #fff; +} + +.lambda-player__speed { + padding: 4px 8px; + font-size: 14px; +} + +.lambda-player__spacer { + flex: 1; +} + +/* All caught up: where things stand, and only what they could do. */ +.caught-up { + box-sizing: border-box; + gap: 22px; + width: 100%; + max-width: 620px; + padding: 48px 24px; +} + +.caught-up__top { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; +} + +.caught-up__top .lambda-mark { + margin-bottom: 4px; +} + +.caught-up__title { + margin: 0; + font-family: var(--serif); + font-size: 34px; + font-weight: 400; +} + +.caught-up__line { + margin: 0; + color: var(--grey-800); +} + +.launch { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border-radius: 12px; + background: var(--surface); + box-shadow: var(--ring), var(--lift); +} + +.launch__head { + margin: 0; + padding: 14px 18px 8px; + color: var(--grey-500); + font-size: 13px; + font-weight: 500; +} + +.launch__row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + border-top: 1px solid rgb(11 11 11 / 6%); +} + +.launch__text { + flex: 1; + min-width: 0; +} + +.launch__sub { + display: block; + color: var(--grey-500); + font-size: 12.5px; +} + +.launch__button { + padding: 6px 14px; + border: 1px solid rgb(11 11 11 / 15%); + border-radius: 7px; + background: var(--surface); + font: inherit; + cursor: pointer; +} + +.launch__offline { + box-sizing: border-box; + flex: none; + width: 14px; + height: 14px; + margin: 0 2px; + border: 1.5px dashed var(--grey-500); + border-radius: 50%; +} + +.caught-up__note { + margin: 0; + color: var(--grey-500); + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.lambda-card__choices { + display: flex; + flex-direction: column; + gap: 6px; +} + +.lambda-card__choice { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 7px; + box-shadow: 0 0 0 1px rgb(11 11 11 / 12%); + cursor: pointer; +} + +.lambda-card__choice:has(:checked) { + box-shadow: 0 0 0 1.5px var(--black); +} + +.lambda-card__rec { + color: var(--grey-500); + font-size: 12.5px; +} + +.lambda-card__body { + margin: 0; +} + +/* Wins over the Plan rail's stacked footer. */ +.lambda-player.rail__actions { + display: flex; + flex-direction: row; + min-height: 62px; +} + +.lambda-page .toolbar__title { + font-size: 15px; + font-weight: 600; +} + +.lambda-rail .link-button { + color: var(--link); + text-decoration: none; +} + +.lambda-rail .link-button:hover { + text-decoration: underline; +} + +/* The sidebar's section heads, as small caps: Needs you's in ink with its + badge at the end, the rest quiet with a count ("QUEUED · 4"). */ +.side-section__title { + font-size: 12px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.side-section--needs-you .side-section__title { + color: var(--black); + font-weight: 600; +} + +.side-section__head .side-section__count--badge { + margin-right: 6px; + margin-left: auto; +} + +.side-section__hint { + margin-left: 4px; + color: var(--grey-500); + font-size: 11.5px; + letter-spacing: 0; + text-transform: none; +} + +.side-more { + margin: 0 0 4px 34px; + padding: 2px 0 4px; + border: 0; + background: transparent; + color: var(--grey-500); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} + +/* A planning row: its state mark first, a word for it at the end. */ +.side-row__lead { + display: grid; + flex: none; + place-items: center; + width: 12px; +} + +.side-row__lead .count { + display: none; +} + +.side-row__state { + font-size: 12.5px; +} + +.side-section__title .side-section__count { + margin-left: 0; +} + +/* A ticket and its Lambda: Chat with Lambda, the block or the planning it's + in, and what to do with a reply. */ +.tk-chat-button { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 6px; + padding: 5px 12px; + border: 0; + border-radius: 7px; + background: var(--black); + color: #fff; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.ticket__notice--blocked { + background: #f8ecec; + color: var(--danger); +} + +.ticket__notice-dot { + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--danger); +} + +.ticket__notice--planning { + background: var(--grey-50); + color: var(--grey-800); +} + +.ticket__notice--planning strong { + color: var(--black); + font-weight: 500; +} + +.ticket__notice-text { + flex: 1; +} + +.ticket__notice .link-button { + color: var(--link); + font-size: 13px; + text-decoration: none; +} + +.ticket__queued { + margin: -6px 0 0; + font-size: 12px; + text-align: right; +} + +.ticket__reply-actions { + display: flex; + gap: 8px; + margin-top: -4px; +} + +.ticket__comment-foot .link-button { + color: var(--link); + text-decoration: none; +} + +.ticket__blocked-word { + color: var(--danger); +} + +/* The ticket a new chat is about, attached in the box. */ +.new-chat__about { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 6px; + max-width: 100%; + margin: 2px 0 4px; + padding: 3px 4px 3px 8px; + border-radius: 7px; + background: var(--grey-100); + font-size: 13px; +} + +.new-chat__about .new-chat__suggestion-icon { + flex: none; + width: 14px; + height: 14px; +} + +.new-chat__about-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.new-chat__about-remove { + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--grey-500); + font: inherit; + cursor: pointer; +} + +.new-chat__about-remove:hover { + background: var(--grey-300); + color: var(--black); +} + +/* With a ticket attached the box is tall: the chip on its own row first. */ +.new-chat__box--about { + grid-template-areas: + 'about about about' + 'text text text' + 'tray tray tray' + 'plus . send'; +} + +.new-chat__box .new-chat__about { + grid-area: about; + justify-self: start; +} + +/* ── chat readability ────────────────────────────────────────────────────── + The chats read like ChatGPT's: 16px text on 28px lines, room between + paragraphs and list items, a 768px column. The planning chat, a + computer's chat and a Lambda's page alike; rails and the sidebar keep + their denser 14px. */ +.center-chat, +.lambda-thread { + --chat-gutter: max(24px, calc((100% - 768px) / 2)); +} + +.lambda-thread__log, +.lambda-compose { + padding-right: var(--chat-gutter); + padding-left: var(--chat-gutter); +} + +.center-chat .chat-panel__log .msg, +.lambda-thread__log .msg, +.lambda-thread__said, +.center-chat .build-bubble, +.lambda-thread__log .build-bubble { + font-size: 16px; + line-height: 28px; +} + +.center-chat .chat-panel__log .msg { + margin: 24px 0; +} + +.lambda-thread__log { + gap: 24px; +} + +.center-chat .md p, +.center-chat .md ul, +.center-chat .md ol, +.center-chat .md pre, +.center-chat .md table, +.center-chat .md blockquote, +.lambda-thread .md p, +.lambda-thread .md ul, +.lambda-thread .md ol, +.lambda-thread .md pre, +.lambda-thread .md table, +.lambda-thread .md blockquote { + margin: 0 0 18px; +} + +.center-chat .md li, +.lambda-thread .md li { + margin: 6px 0; +} + +.center-chat .md h1, +.center-chat .md h2, +.center-chat .md h3, +.center-chat .md h4, +.lambda-thread .md h1, +.lambda-thread .md h2, +.lambda-thread .md h3, +.lambda-thread .md h4 { + margin: 28px 0 10px; + font-size: 18px; + line-height: 28px; +} + +.center-chat .md code, +.lambda-thread .md code { + font-size: 14px; +} + +/* Your messages: a softer, rounder bubble at the same size. */ +.center-chat .chat-panel__log .msg--you, +.lambda-thread__log .msg--you { + padding: 10px 18px; + border-radius: 20px; +} + +.center-chat .chat-panel__compose textarea, +.lambda-compose textarea { + font-size: 16px; + line-height: 24px; +} + +.lambda-thread__line, +.lambda-thread__who, +.lambda-compose__to { + font-size: 13.5px; +} + +/* The last block of a message ends it: no space under its last line. */ +.center-chat .md > :last-child, +.lambda-thread .md > :last-child { + margin-bottom: 0; +} + +/* Yours on the right, as in every chat. */ +.lambda-thread__log .msg--you { + align-self: flex-end; +} + +/* The ticket a chat or Lambda is about, by the title: back to its page. */ +.toolbar__ticket { + margin-left: 8px; + padding: 1px 7px; + border: 0; + border-radius: 6px; + background: var(--grey-100); + color: var(--grey-800); + font: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; +} + +.toolbar__ticket:hover { + background: var(--grey-300); + color: var(--black); +} + +.lambda-rail__ticket--about { + margin: 0 -8px; +} + +.center-chat .chat-panel__log .msg--you { + margin-left: auto; +} + +/* The tracker's own state, where it says more than Lambda's ("In review"). */ +.tk-row__status { + display: inline-flex; + flex: none; +} + +.tk-chip--state { + color: var(--grey-800); + background: var(--grey-50); +} + +/* Settings → Computers: who each computer is in its trackers. */ +.account-list__trackers { + display: block; + margin-top: 2px; + color: var(--grey-500); + font-size: 12.5px; +} + +.account-list__warning { + padding: 8px 12px; + border-radius: 8px; + background: var(--amber-tint); + color: var(--amber-ink); +} + +.account-list__row:has(.account-list__trackers) { + flex-wrap: wrap; +} + +.account-list__row .account-list__trackers { + flex-basis: 100%; +} + +/* An epic's name beside a row: never more room than the ticket's own title. */ +.tk-chip--epic { + flex: 0 1 auto; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The title keeps its room: labels and the epic give way first, each cut + short rather than pushing the title out. */ +.tk-row .tk-row__title { + flex: 1 1 260px; + min-width: 200px; +} + +.tk-row .tk-chip--label, +.tk-row .tk-chip--epic { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tk-row .tk-chip--epic { + max-width: 160px; +} + +.tk-row .tk-chip__dot { + flex: none; +} + +/* ── Projects, views and favorites (laid out as Linear's) ─────────────── */ + +.project-icon { + display: inline-grid; + flex: none; + place-items: center; + line-height: 1; +} + +.side-row__favicon { + display: inline-grid; + flex: none; + place-items: center; + width: 16px; + height: 16px; + font-size: 13px; + line-height: 1; +} + +.star-button { + display: inline-grid; + flex: none; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--grey-500); +} + +.star-button:hover { + background: var(--grey-100); + color: var(--black); +} + +.star-button--on, +.star-button--on:hover { + color: var(--amber); +} + +.tk-icon-button--on { + background: var(--grey-100); +} + +.tk-button--danger { + border-color: var(--danger); + background: var(--danger); + color: var(--white); +} + +/* A view's or project's page: the list, then its panel on the right. */ +.tickets-wrap { + display: flex; + flex: 1; + min-height: 100%; +} + +.tickets-wrap > .tickets { + min-width: 0; +} + +.tickets__crumbs { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: #6f6d68; + font-weight: 400; +} + +.tickets__crumb-here { + display: inline-flex; + align-items: center; + gap: 7px; + overflow: hidden; + color: var(--black); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tickets__save { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.tk-chip--project { + flex: 0 1 auto; + max-width: 180px; +} + +.tk-chip__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.view-panel { + position: sticky; + top: 0; + display: flex; + flex: none; + flex-direction: column; + gap: 10px; + box-sizing: border-box; + width: 320px; + max-height: 100vh; + overflow-y: auto; + padding: 20px 20px 28px; + border-left: 1px solid rgb(11 11 11 / 8%); + font-size: 13px; +} + +.view-panel__name, +.view-panel__description { + width: 100%; + box-sizing: border-box; + padding: 4px 6px; + margin-left: -6px; + border: 0; + border-radius: 6px; + outline: 0; + background: transparent; + color: var(--black); + font: inherit; + resize: none; +} + +.view-panel__name { + font-size: 16px; + font-weight: 600; +} + +.view-panel__description { + color: #6f6d68; +} + +.view-panel__name:hover, +.view-panel__description:hover, +.view-panel__name:focus, +.view-panel__description:focus { + background: var(--grey-50); +} + +.view-panel__title { + display: flex; + align-items: center; + gap: 8px; +} + +.view-panel__title h2 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.view-panel__text { + margin: 0; + color: #6f6d68; + line-height: 1.5; +} + +.view-panel__props { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 10px 8px; + align-items: center; + margin: 6px 0 4px; +} + +.view-panel__props dt { + color: #6f6d68; +} + +.view-panel__props dd { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin: 0; +} + +.view-panel__owner { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.view-panel__avatar { + display: inline-grid; + flex: none; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--grey-300); + color: var(--black); + font-size: 10px; + font-weight: 600; +} + +.view-panel__avatar--none { + border: 1px dashed var(--grey-500); + background: transparent; +} + +.view-panel__breakdown { + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid rgb(11 11 11 / 8%); +} + +.view-panel__tabs { + display: flex; + gap: 2px; + margin-bottom: 10px; + padding: 2px; + border-radius: 7px; + background: var(--grey-100); +} + +.view-panel__tab { + flex: 1; + padding: 4px 6px; + border: 0; + border-radius: 5px; + background: transparent; + color: #6f6d68; + font: inherit; + font-size: 12px; +} + +.view-panel__tab--on { + background: var(--surface); + color: var(--black); + box-shadow: var(--ring); +} + +.view-panel__counts { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.view-panel__count { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 56px 24px; + gap: 8px; + align-items: center; + padding: 4px 0; +} + +.view-panel__count-icon { + display: inline-grid; + place-items: center; +} + +.view-panel__count-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.view-panel__count-number { + color: #6f6d68; + text-align: right; +} + +.view-panel__bar { + display: block; + height: 4px; + overflow: hidden; + border-radius: 2px; + background: var(--grey-100); +} + +.view-panel__bar > span { + display: block; + height: 100%; + background: var(--grey-500); +} + +.view-panel__progress { + gap: 8px; +} + +.view-panel__progress .view-panel__bar { + width: 80px; +} + +.view-panel__progress .view-panel__bar > span { + background: var(--success); +} + +.view-panel__none { + margin: 4px 0; +} + +.view-panel__link { + color: var(--link); +} + +.view-panel__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: auto; + padding-top: 16px; +} + +.view-panel__delete { + color: var(--danger); +} + +/* Projects: one table, as Linear's. */ +.projects-table { + display: flex; + flex-direction: column; + font-size: 13px; +} + +.projects-table__head, +.projects-table__row { + display: grid; + grid-template-columns: minmax(0, 1fr) 140px 110px 120px 32px; + gap: 12px; + align-items: center; + padding: 0 28px; +} + +.projects-table__head:has(> :nth-child(6)), +.projects-table__row--several { + grid-template-columns: minmax(0, 1fr) 140px 140px 110px 120px 32px; +} + +.projects-table__head { + height: 34px; + border-bottom: 1px solid rgb(11 11 11 / 8%); + color: #6f6d68; + font-size: 12px; +} + +.projects-table__row { + height: 44px; + border-bottom: 1px solid rgb(11 11 11 / 5%); +} + +.projects-table__row:hover { + background: var(--grey-50); +} + +.projects-table__name { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + height: 100%; + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + font-weight: 500; + text-align: left; +} + +.projects-table__name > span:last-child, +.projects-table__cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.projects-table__status { + display: flex; + align-items: center; + gap: 7px; +} + +.projects-table__star .star-button { + opacity: 0; +} + +.projects-table__row:hover .star-button, +.projects-table__star .star-button--on, +.projects-table__star .star-button:focus-visible { + opacity: 1; +} + +/* Views: a list of every saved view. */ +.views-list { + display: flex; + flex-direction: column; + font-size: 13px; +} + +.views-list__row { + display: flex; + align-items: center; + gap: 12px; + height: 44px; + padding: 0 28px; + border-bottom: 1px solid rgb(11 11 11 / 5%); +} + +.views-list__row:hover { + background: var(--grey-50); +} + +.views-list__open { + display: flex; + flex: 1; + align-items: center; + gap: 10px; + min-width: 0; + height: 100%; + padding: 0; + border: 0; + background: transparent; + color: var(--black); + font: inherit; + text-align: left; +} + +.views-list__name { + flex: none; + font-weight: 500; +} + +.views-list__description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.views-list__visibility { + flex: none; + font-size: 12px; +} + +.views-list__row .star-button { + opacity: 0; +} + +.views-list__row:hover .star-button, +.views-list__row .star-button--on, +.views-list__row .star-button:focus-visible { + opacity: 1; +} + +.views-page__imported { + position: relative; + margin: 12px 28px 4px; + padding: 10px 40px 10px 14px; + border-radius: 8px; + background: var(--grey-50); + box-shadow: var(--ring); + font-size: 13px; +} + +.views-page__imported p { + margin: 0 0 4px; +} + +.views-page__imported ul { + margin: 4px 0 0; + padding-left: 18px; +} + +.views-page__dismiss { + position: absolute; + top: 8px; + right: 8px; +} + +.tk-dialog--small { + width: min(480px, 100%); +} + +@media (max-width: 900px) { + .tickets-wrap--aside { + flex-direction: column; + } + + .view-panel { + position: static; + width: 100%; + max-height: none; + border-top: 1px solid rgb(11 11 11 / 8%); + border-left: 0; + } + + .projects-table__head, + .projects-table__row, + .projects-table__row--several, + .projects-table__head:has(> :nth-child(6)) { + grid-template-columns: minmax(0, 1fr) 90px 32px; + padding: 0 16px; + } + + .projects-table__head > :nth-child(n + 2):not(:nth-last-child(-n + 2)), + .projects-table__row > :nth-child(n + 2):not(:nth-last-child(-n + 2)) { + display: none; + } + + .views-list__row { + padding: 0 16px; + } + + .views-list__visibility { + display: none; + } +} + +/* Setup's "is this login you?", under Connected as. */ +.setup__who { + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/src/app/client/terminalErrors.ts b/src/app/client/terminalErrors.ts new file mode 100644 index 0000000..feabe4e --- /dev/null +++ b/src/app/client/terminalErrors.ts @@ -0,0 +1,33 @@ +import { ServerError } from './api'; + +/** + * What the terminal says when it can't reach the pane. A keystroke that + * didn't land is always said ("Leslie's Mac mini is offline."), or typing + * would vanish into a pane that looks alive. A poll the server refused is + * said once, until a frame lands again (it polls every 700 ms). The runner's + * own refusal (400: the window is gone, the fork closed, the pod paused) and + * a request that died on the way stay quiet: the last frame stays up. + */ +export const terminalErrors = (report: (caught: unknown) => void) => { + const state = { said: false }; + + return { + keys: (caught: unknown): void => report(caught), + poll: (caught: unknown): void => { + if ( + state.said || + !(caught instanceof ServerError) || + caught.status === 400 + ) { + return; + } + + state.said = true; + report(caught); + }, + /** A frame landed: the next refusal is news again. */ + polled: (): void => { + state.said = false; + }, + }; +}; diff --git a/src/app/client/timeline.ts b/src/app/client/timeline.ts new file mode 100644 index 0000000..ea19b22 --- /dev/null +++ b/src/app/client/timeline.ts @@ -0,0 +1,24 @@ +import type { TimedSection } from '../shared/speech'; + +/** + * The timeline math behind the player. Section times come from each clip's + * real duration once known (an estimate from its words until then; see + * usePlayer.ts). All times are seconds at 1× — the speed control scales + * playback, not the axis. + */ + +/** Index of the section playing at `time` (the last one that has started). */ +export const sectionIndexAt = ( + sections: TimedSection[], + time: number +): number => + sections.reduce( + (found, section, index) => (time >= section.start ? index : found), + 0 + ); + +export const formatTime = (seconds: number): string => { + const whole = Math.max(0, Math.floor(seconds)); + + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; +}; diff --git a/src/app/client/uiLog.ts b/src/app/client/uiLog.ts new file mode 100644 index 0000000..1bd0b8c --- /dev/null +++ b/src/app/client/uiLog.ts @@ -0,0 +1,135 @@ +/** + * The page's own trail: what changed on screen and why, sent to the server + * every few seconds and kept in `/_ui.log` (one JSON line each). + * When something odd happens ("the checkpoint just vanished"), that file says + * what the page did, instead of us guessing (2026-10-10). + * + * Never logs what they type — key names only, and only what had focus. + */ +type Entry = Record & { at: string; event: string }; + +const buffer: Entry[] = []; +/** Kept while they can't be sent (signed out, server restarting): newest. */ +const MAX_KEPT = 500; +const SESSION = Math.random().toString(36).slice(2, 8); + +export const uiLog = ( + event: string, + data: Record = {} +): void => { + buffer.splice(buffer.length, 0, { + at: new Date().toISOString(), + event, + session: SESSION, + ...data, + }); + + if (buffer.length > MAX_KEPT) { + buffer.splice(0, buffer.length - MAX_KEPT); + } +}; + +/** Signed out, `/api/ui-log` answers 401: entries wait for the session. */ +const session = { signedIn: false }; + +export const setUiLogSignedIn = (value: boolean): void => { + session.signedIn = value; +}; + +const flush = (useBeacon = false): void => { + if (buffer.length === 0 || !session.signedIn) { + return; + } + + const entries = buffer.splice(0, buffer.length); + const body = JSON.stringify({ entries }); + + if (useBeacon && typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon( + '/api/ui-log', + new Blob([body], { type: 'application/json' }) + ); + + return; + } + + fetch('/api/ui-log', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }).catch(() => { + // The server is restarting: keep them for the next try (newest last). + buffer.splice(0, 0, ...entries.slice(-MAX_KEPT)); + buffer.splice(0, Math.max(0, buffer.length - MAX_KEPT)); + }); +}; + +/** What had focus, briefly: tag, role, class, and a pod/item hint if any. */ +export const describeTarget = (target: EventTarget | null): string => { + if (!(target instanceof HTMLElement)) { + return String(target); + } + + const label = + target.getAttribute('aria-label') ?? + target.querySelector('.nav-item__title')?.textContent ?? + ''; + + return [ + target.tagName.toLowerCase(), + target.getAttribute('role') ?? '', + (target.className || '').toString().split(' ').slice(0, 2).join('.'), + label.slice(0, 40), + ] + .filter((part) => part !== '') + .join(' · '); +}; + +export const startUiLog = (): void => { + window.setInterval(() => flush(), 3_000); + window.addEventListener('pagehide', () => flush(true)); + uiLog('page-load', { url: window.location.pathname }); + document.addEventListener('visibilitychange', () => + uiLog('visibility', { state: document.visibilityState }) + ); + window.addEventListener('focus', () => uiLog('window-focus')); + window.addEventListener('blur', () => uiLog('window-blur')); + // Capture phase: every key, whatever handles it after. + window.addEventListener( + 'keydown', + (event) => { + const typing = + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLInputElement && + event.target.type !== 'checkbox' && + event.target.type !== 'radio'); + + // In a text box only the keys that DO something are logged. + if (typing && !['Enter', 'Escape', 'Tab'].includes(event.key)) { + return; + } + + uiLog('key', { + key: event.key, + mods: [ + event.metaKey ? 'cmd' : '', + event.ctrlKey ? 'ctrl' : '', + event.shiftKey ? 'shift' : '', + event.altKey ? 'alt' : '', + ] + .filter((mod) => mod !== '') + .join('+'), + focus: describeTarget(event.target), + }); + }, + true + ); + window.addEventListener( + 'click', + (event) => uiLog('click', { on: describeTarget(event.target) }), + true + ); + window.addEventListener('error', (event) => + uiLog('js-error', { message: event.message }) + ); +}; diff --git a/src/app/client/uniqueKeys.ts b/src/app/client/uniqueKeys.ts new file mode 100644 index 0000000..aa5908c --- /dev/null +++ b/src/app/client/uniqueKeys.ts @@ -0,0 +1,20 @@ +/** + * React keys for a list whose rows can read the same (two bad lines carry + * one warning; one ticket ships twice). The first of a kind keeps its plain + * key, so it holds steady as later duplicates arrive; each later one gets + * `#2`, `#3`…. + */ +export const withKeys = ( + items: T[], + keyOf: (item: T) => string +): { key: string; item: T }[] => { + const seen = new Map(); + + return items.map((item) => { + const base = keyOf(item); + const count = (seen.get(base) ?? 0) + 1; + seen.set(base, count); + + return { key: count === 1 ? base : `${base}#${count}`, item }; + }); +}; diff --git a/src/app/client/untilFound.ts b/src/app/client/untilFound.ts new file mode 100644 index 0000000..117bf77 --- /dev/null +++ b/src/app/client/untilFound.ts @@ -0,0 +1,30 @@ +import { ServerError } from './api'; + +const pause = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * A pod just made isn't known to the server until its runner reports it, so + * its page first gets a 404. That is "not yet": ask again for a few seconds + * before it is an error worth a banner. Any other failure is one at once. + */ +export const untilFound = async ( + get: () => Promise, + options: { tries?: number; waitMs?: number; wait?: typeof pause } = {} +): Promise => { + const { tries = 10, waitMs = 500, wait = pause } = options; + + try { + return await get(); + } catch (error) { + if (!(error instanceof ServerError) || error.status !== 404 || tries <= 1) { + throw error; + } + + await wait(waitMs); + + return untilFound(get, { tries: tries - 1, waitMs, wait }); + } +}; diff --git a/src/app/client/useActiveTime.ts b/src/app/client/useActiveTime.ts new file mode 100644 index 0000000..ca331f5 --- /dev/null +++ b/src/app/client/useActiveTime.ts @@ -0,0 +1,351 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { TimeSummary } from '../shared/types'; + +/** + * Active planning time. A second counts only while the owner is actually at it: + * they scrolled, typed or clicked in the last IDLE_MS with the tab visible — or audio is playing, or a live conversation is waiting on + * Claude (they're listening/reading for the reply). Merely having the page open + * counts for nothing. + * + * Seconds are credited to what's on screen (an item, a pod's page, or the + * inbox), batched, and reported to the server every FLUSH_MS (and when the + * tab is hidden or closed), so totals survive reloads and add up across + * devices. Until the server's totals include a second, it is added locally. + */ + +const IDLE_MS = 15_000; +/** Input this soon after the tab is shown/focused is the switch, not work. */ +const SWITCH_GRACE_MS = 1_000; +const MODIFIER_KEYS = new Set([ + 'Shift', + 'Control', + 'Meta', + 'Alt', + 'CapsLock', + 'Fn', + 'OS', +]); +const FLUSH_MS = 15_000; + +export interface TimeScope { + podId?: string; + qid?: string; + /** The version of the card on screen, while the Plan rail shows it. */ + version?: number; + /** The card inside it that has their attention (see cardHistory.ts). */ + card?: string; +} + +const scopeKey = (scope: TimeScope): string => + JSON.stringify([scope.podId, scope.qid, scope.version, scope.card]); + +type Batch = Map; + +interface Sent { + batch: Batch; + /** The server has saved it — the next state it sends includes it. */ + saved: boolean; +} + +const post = (batch: Batch, beacon: boolean): Promise => { + const body = JSON.stringify({ + entries: [...batch.values()].map(({ scope, ms }) => ({ ...scope, ms })), + }); + + if (beacon && 'sendBeacon' in navigator) { + navigator.sendBeacon( + '/api/time', + new Blob([body], { type: 'application/json' }) + ); + + return Promise.resolve(); + } + + return fetch('/api/time', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + keepalive: true, + }).then(() => undefined); +}; + +export interface ItemTimes { + itemMs: number; + versionMs: (version: number) => number; + /** A card's time — on the given versions of the item, or on all of them. */ + cardMs: (card: string, versions?: number[]) => number; +} + +const sumOf = (batches: Batch[]): number => + batches.reduce( + (total, batch) => + total + [...batch.values()].reduce((sum, entry) => sum + entry.ms, 0), + 0 + ); + +export const useActiveTime = (args: { + scope: TimeScope; + /** Audio playing, or a live conversation waiting on a reply. */ + busy: boolean; + summary: TimeSummary | undefined; + /** Not planning right now (e.g. looking at the Time page): don't count. */ + paused?: boolean; + /** Which card inside the item has their attention, asked each second. */ + cardAt?: () => string | undefined; +}) => { + const lastInput = useRef(0); + const pending = useRef(new Map()); + const sent = useRef([]); + /** + * Saved batches whose per-card and per-version split the server doesn't + * keep (a server from before it did): kept here for this page's life so + * card and version times don't fall back each time it reports in. + */ + const unsplit = useRef([]); + const [ticking, setTicking] = useState(false); + /** Re-render once a second while ticking (the refs above hold the counts). */ + const [, setTick] = useState(0); + const live = useRef(args); + live.current = args; + + // Real interaction marks the owner as active: scrolling, typing, clicking. + // NOT pointer movement — just switching to the tab sweeps the pointer across + // the page (and browsers fire a move on focus). Anything in the first moment + // after the tab becomes visible is the switch itself, not work. NOR the left + // sidebar: picking a pod or the inbox is getting somewhere, not planning. + // And no `scroll` events: the app scrolls itself when a view opens (the chat + // to its newest message), which fired one — wheel, touch, keys and a + // scrollbar drag (pointerdown) are the scrolling they do. + useEffect(() => { + const shownAt = { at: 0 }; + const mark = (event: Event): void => { + // Keys: modifiers alone (about to Ctrl+Tab) and Ctrl/⌘/Alt shortcuts + // are moving around the browser, not planning. Shift+letter is typing. + if ( + event instanceof KeyboardEvent && + (MODIFIER_KEYS.has(event.key) || + event.ctrlKey || + event.metaKey || + event.altKey) + ) { + return; + } + + if ( + event.target instanceof Element && + event.target.closest('.sidebar') !== null + ) { + return; + } + + if (Date.now() - shownAt.at > SWITCH_GRACE_MS) { + lastInput.current = Date.now(); + } + }; + const onShow = (): void => { + if (document.visibilityState === 'visible') { + shownAt.at = Date.now(); + } + }; + const onFocus = (): void => { + shownAt.at = Date.now(); + }; + // Left for another tab or app: stop now, not 15 s from the last input. + const onBlur = (): void => { + lastInput.current = 0; + }; + const events = ['wheel', 'keydown', 'pointerdown', 'touchmove', 'input']; + events.forEach((name) => + window.addEventListener(name, mark, { passive: true, capture: true }) + ); + document.addEventListener('visibilitychange', onShow); + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onBlur); + + return () => { + events.forEach((name) => + window.removeEventListener(name, mark, { capture: true }) + ); + document.removeEventListener('visibilitychange', onShow); + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); + }; + }, []); + + // The clock: one tick per second while active. + useEffect(() => { + const id = window.setInterval(() => { + const { busy, paused, cardAt } = live.current; + const card = cardAt?.(); + const scope = + card === undefined + ? live.current.scope + : { ...live.current.scope, card }; + // Audio / a live reply count even in the background (listening is + // planning); input counts only while this page has focus. + const active = + paused !== true && + (busy || + (document.visibilityState === 'visible' && + document.hasFocus() && + Date.now() - lastInput.current < IDLE_MS)); + setTicking(active); + + if (!active) { + return; + } + + const key = scopeKey(scope); + const entry = pending.current.get(key); + pending.current.set(key, { scope, ms: (entry?.ms ?? 0) + 1_000 }); + setTick((value) => value + 1); + }, 1_000); + + return () => window.clearInterval(id); + }, []); + + // Report in batches, and before the tab goes away. + useEffect(() => { + const flush = (beacon: boolean): void => { + const batch = pending.current; + + if (batch.size === 0) { + return; + } + + pending.current = new Map(); + const record: Sent = { batch, saved: false }; + sent.current = [...sent.current, record]; + post(batch, beacon) + .then(() => { + record.saved = true; + }) + .catch(() => { + // Not saved: put it back so the next flush retries it. + sent.current = sent.current.filter((item) => item !== record); + batch.forEach((entry, key) => { + const current = pending.current.get(key); + pending.current.set(key, { + scope: entry.scope, + ms: (current?.ms ?? 0) + entry.ms, + }); + }); + }); + }; + const id = window.setInterval(() => flush(false), FLUSH_MS); + const onHide = (): void => { + if (document.visibilityState === 'hidden') { + flush(true); + } + }; + const onLeave = (): void => flush(true); + document.addEventListener('visibilitychange', onHide); + window.addEventListener('pagehide', onLeave); + + return () => { + window.clearInterval(id); + document.removeEventListener('visibilitychange', onHide); + window.removeEventListener('pagehide', onLeave); + }; + }, []); + + // A new state from the server includes every batch saved before it. + const { summary } = args; + + useEffect(() => { + const splits = Object.values(summary?.pods ?? {}).some( + (pod) => pod.cards !== undefined + ); + + if (!splits) { + unsplit.current = [ + ...unsplit.current, + ...sent.current.filter((item) => item.saved).map((item) => item.batch), + ]; + } + + sent.current = sent.current.filter((item) => !item.saved); + }, [summary]); + + const local = [pending.current, ...sent.current.map((item) => item.batch)]; + /** Seconds not yet in the server's totals, for scopes `match` accepts. */ + const localMs = ( + match: (scope: TimeScope) => boolean, + batches: Batch[] = local + ): number => + batches.reduce( + (total, batch) => + total + + [...batch.values()] + .filter((entry) => match(entry.scope)) + .reduce((sum, entry) => sum + entry.ms, 0), + 0 + ); + const { podId, qid } = args.scope; + const serverScope = + podId === undefined + ? 0 + : qid === undefined + ? summary?.pods[podId]?.totalMs ?? 0 + : summary?.pods[podId]?.items[qid] ?? 0; + + return { + ticking, + todayMs: (summary?.todayMs ?? 0) + sumOf(local), + // An item's time is the same whichever version of it was on screen. + scopeMs: + serverScope + + localMs( + (scope) => + scope.podId === podId && (qid === undefined || scope.qid === qid) + ), + /** + * One item's active time: all of it, per version of it (while the Plan + * rail showed that version), and per card inside it. + */ + itemTime: (itemPodId: string, itemQid: string): ItemTimes => { + const pod = summary?.pods[itemPodId]; + const ofItem = (scope: TimeScope): boolean => + scope.podId === itemPodId && scope.qid === itemQid; + + return { + itemMs: (pod?.items[itemQid] ?? 0) + localMs(ofItem), + versionMs: (version) => + (pod?.versions?.[itemQid]?.[String(version)] ?? 0) + + localMs( + (scope) => ofItem(scope) && scope.version === version, + [...local, ...unsplit.current] + ), + cardMs: (card, versions) => { + const byVersion = pod?.cards?.[itemQid]?.[card] ?? {}; + const counts = (version: number | undefined): boolean => + versions === undefined || versions.includes(version ?? 1); + + return ( + Object.entries(byVersion) + .filter(([version]) => counts(Number(version))) + .reduce((total, [, ms]) => total + ms, 0) + + localMs( + (scope) => + ofItem(scope) && scope.card === card && counts(scope.version), + [...local, ...unsplit.current] + ) + ); + }, + }; + }, + }; +}; + +/** 1:05:42 / 12:07 */ +export const formatDuration = (ms: number): string => { + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = String(total % 60).padStart(2, '0'); + + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` + : `${minutes}:${seconds}`; +}; diff --git a/src/app/client/usePlayer.ts b/src/app/client/usePlayer.ts new file mode 100644 index 0000000..383f2ab --- /dev/null +++ b/src/app/client/usePlayer.ts @@ -0,0 +1,1091 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + type SectionKey, + type SpokenSection, + type TimedSection, + sectionParts, +} from '../shared/speech'; +import type { AudioMeta, ClipStatus } from '../shared/types'; +import { api } from './api'; +import { type ChatTrack, nextChunk } from './chatPlaylist'; +import { waitForClip } from './clipWait'; +import { loadPlace, savePlace } from './planPlace'; +import { + clampRate, + loadAutoplay, + loadRate, + saveAutoplay, + saveRate, +} from './speech'; +import { sectionIndexAt } from './timeline'; +import { uiLog } from './uiLog'; + +/** + * The inbox's audio player. Each section is cut into PARTS (sectionParts: a + * short first one, then longer ones) and each part is its own clip, rendered + * the moment the player needs it (plus two ahead) and cached on the server by + * its exact text — so the first words of any section come after one short + * render, the rest render while it plays, jumping anywhere works, and replays + * are free. The timeline they see is still per section. + * + * There is NO browser-voice fallback (2026-10-12). A clip that isn't ready yet is waited for — the player + * shows "Preparing audio…" — and one that can't be rendered at all is asked + * for again, then reported ("Audio unavailable") instead of read by another + * voice. Waits land in `/_ui.log` (audio-wait / audio-play) and the + * server's side in `/_tts.log`. + * + * The timeline is in seconds at 1×: a section's span is its clip's real + * duration once known, an estimate from its words until then. + */ + +export type PlayerSource = 'elevenlabs' | 'unavailable' | 'none'; + +/** Browsers block audio until the first click/keypress on the page. */ +const unlock = { done: false, listeners: new Set<() => void>() }; + +if (typeof window !== 'undefined') { + const onGesture = (): void => { + if (!unlock.done) { + unlock.done = true; + unlock.listeners.forEach((listener) => listener()); + } + }; + window.addEventListener('pointerdown', onGesture, { capture: true }); + window.addEventListener('keydown', onGesture, { capture: true }); +} + +/** A clip that came back unavailable is asked for this many more times… */ +const CLIP_RETRIES = 2; +/** …this far apart (a Kokoro worker restart takes a few seconds). */ +const CLIP_RETRY_GAP_MS = 3_000; +/** How long a playExtra / reply waits before giving up on its clip. */ +const EXTRA_WAIT_MS = 120_000; +/** Clips rendered ahead of the one playing. */ +const AHEAD = 2; +/** ~160 words per minute at 1×, plus a short pause — until the real duration is known. */ +const WORDS_PER_SECOND = 2.7; +const SECTION_PAUSE = 0.4; + +const estimate = (text: string): number => + text.split(/\s+/).filter((word) => word !== '').length / WORDS_PER_SECOND + + SECTION_PAUSE; + +type Ready = { url: string; duration: number }; + +const readyOf = (clip: ClipStatus | undefined): Ready | null => + clip?.status === 'ready' ? { url: clip.url, duration: clip.duration } : null; + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(() => resolve(null), ms); + }); + +export const usePlayer = (args: { + itemKey: string | null; + podId: string | null; + sections: SpokenSection[] | null; + audio: AudioMeta | undefined; + title: string; + artist: string; +}) => { + const [rate, setRateState] = useState(loadRate); + const [autoplay, setAutoplayState] = useState(loadAutoplay); + const [playing, setPlaying] = useState(false); + const [time, setTime] = useState(0); + const [needsGesture, setNeedsGesture] = useState(false); + /** The section waiting on its clip (a spinner on its dot, "Preparing audio…"). */ + const [loadingKey, setLoadingKey] = useState(null); + /** Why playback stopped: a clip that could not be rendered. */ + const [audioError, setAudioError] = useState(null); + /** Clips fetched on demand for this item, by section key. */ + const [fetched, setFetched] = useState>({}); + /** The chat message the chat playlist is on (see playChat). */ + const [chat, setChat] = useState<{ + msgId: string; + loading: boolean; + paused: boolean; + } | null>(null); + + /** Every clip in order: `key` is the part's, `section` its section's. */ + const speech = useMemo( + () => + (args.sections ?? []).flatMap((section) => + sectionParts(section).map((part) => ({ + key: part.key, + section: section.key, + label: section.label, + text: part.text, + })) + ), + [args.sections] + ); + + const clipFor = useCallback( + (key: string): Ready | null => + fetched[key] ?? readyOf(args.audio?.clips[key]), + [fetched, args.audio] + ); + + /** Each part's span: what playback steps through. */ + const parts: TimedSection[] = useMemo( + () => + speech.reduce((line, part) => { + const start = line.at(-1)?.end ?? 0; + const length = clipFor(part.key)?.duration ?? estimate(part.text); + + return [ + ...line, + { key: part.key, label: part.label, start, end: start + length }, + ]; + }, []), + [speech, clipFor] + ); + + /** Each section's span (its parts end to end): what they see and seek by. */ + const timeline: { sections: TimedSection[]; duration: number } = useMemo( + () => ({ + sections: speech.reduce((line, part, index) => { + const span = parts[index]; + const last = line.at(-1); + + if (span === undefined) { + return line; + } + + if (last?.key === part.section) { + return [...line.slice(0, -1), { ...last, end: span.end }]; + } + + return [ + ...line, + { + key: part.section, + label: part.label, + start: span.start, + end: span.end, + }, + ]; + }, []), + duration: parts.at(-1)?.end ?? 0, + }), + [speech, parts] + ); + + const clips = Object.values(args.audio?.clips ?? {}); + const source: PlayerSource = + speech.length === 0 + ? 'none' + : clips.length > 0 && clips.every((clip) => clip.status === 'unavailable') + ? 'unavailable' + : 'elevenlabs'; + + // Latest values for callbacks that outlive a render. + const live = useRef({ + rate, + speech, + parts, + timeline, + time, + playing, + podId: args.podId, + itemKey: args.itemKey, + clipFor, + chatPlaying: false, + chatLoading: false, + }); + live.current = { + rate, + speech, + parts, + timeline, + time, + playing, + podId: args.podId, + itemKey: args.itemKey, + clipFor, + chatPlaying: chat !== null, + chatLoading: chat?.loading === true, + }; + + const element = useRef(null); + /** Bumped on every stop/seek: a stale run's callbacks do nothing. */ + const run = useRef(0); + /** The part playing in the current run. */ + const playingIndex = useRef(0); + const requests = useRef( + new Map; need: 'now' | 'soon' }>() + ); + + const audioElement = useCallback((): HTMLAudioElement => { + if (element.current === null) { + const created = new Audio(); + created.preload = 'auto'; + created.preservesPitch = true; + element.current = created; + } + + return element.current; + }, []); + + /** + * This part's clip: cached, or rendered now. `now`: the player is + * waiting to play it; `soon`: look-ahead. One request per part — except + * that a look-ahead still pending when the player reaches it asks again as + * `now`, which raises its priority on the server (Kokoro's next piece). + */ + const ensureClip = useCallback( + (index: number, need: 'now' | 'soon' = 'now'): Promise => { + const section = live.current.speech[index]; + const { podId } = live.current; + + if (section === undefined || podId === null) { + return Promise.resolve(null); + } + + const known = live.current.clipFor(section.key); + + if (known !== null) { + return Promise.resolve(known); + } + + const pending = requests.current.get(section.text); + + if ( + pending !== undefined && + (pending.need === 'now' || need === 'soon') + ) { + return pending.promise; + } + + const request = api + .ttsClip(podId, section.text, need) + .then((clip) => { + const ready = readyOf(clip); + + if (ready !== null) { + setFetched((previous) => ({ ...previous, [section.key]: ready })); + } + + return ready; + }) + .catch(() => null); + requests.current.set(section.text, { promise: request, need }); + + return request; + }, + [] + ); + + // On-demand extras (a screen's edge cases): one clip outside the item's + // sequence, rendered only when asked for. + const [extra, setExtra] = useState<{ key: string; loading: boolean } | null>( + null + ); + const extraRun = useRef(0); + const extraAudio = useRef(null); + + const stopExtra = useCallback((): void => { + extraRun.current += 1; + extraAudio.current?.pause(); + setExtra(null); + }, []); + + // The Chat panel's playlist: from the message pressed to the newest. + const chatRun = useRef(0); + const chatAudio = useRef(null); + /** Where the chat playlist is, so the shortcuts can steer it. */ + const chatAt = useRef<{ + podId: string; + at: { id: string; chunk: number }; + tracks: () => ChatTrack[]; + } | null>(null); + const chatPaused = useRef(false); + + const stopChat = useCallback((): void => { + chatRun.current += 1; + chatAudio.current?.pause(); + chatAt.current = null; + chatPaused.current = false; + setChat(null); + }, []); + + /** Space on a playing chat message: pause it, or carry on. */ + const toggleChat = useCallback((): void => { + chatPaused.current = !chatPaused.current; + const paused = chatPaused.current; + setChat((now) => (now === null ? null : { ...now, paused })); + + if (paused) { + chatAudio.current?.pause(); + + return; + } + + // Still loading: the clip plays by itself once it's ready. + if (!live.current.chatLoading) { + chatAudio.current?.play().catch(() => undefined); + } + }, []); + + /** The item's own playback, leaving the chat playlist alone. */ + const stopItem = useCallback((): void => { + run.current += 1; + element.current?.pause(); + setPlaying(false); + setLoadingKey(null); + }, []); + + const stopAll = useCallback((): void => { + stopItem(); + stopExtra(); + stopChat(); + }, [stopItem, stopExtra, stopChat]); + + /** Play `text` on its own (press again to stop). Not part of the timeline. */ + const playExtra = useCallback( + (key: string, text: string): void => { + const { podId } = live.current; + + if (extra?.key === key) { + stopExtra(); + + return; + } + + stopAll(); + // Pressing the button is the gesture browsers need before audio. + unlock.done = true; + extraRun.current += 1; + const { current } = extraRun; + setExtra({ key, loading: true }); + const speed = live.current.rate; + const done = (): void => { + if (extraRun.current === current) { + setExtra(null); + } + }; + + Promise.race([ + podId === null + ? Promise.resolve(null) + : api + .ttsClip(podId, text, 'now') + .then(readyOf) + .catch(() => null), + sleep(EXTRA_WAIT_MS), + ]) + .then((ready) => { + if (extraRun.current !== current) { + return; + } + + setExtra({ key, loading: false }); + + if (ready === null) { + uiLog('audio-unavailable', { kind: 'extra', key }); + setAudioError('This audio could not be prepared.'); + done(); + + return; + } + + const audio = extraAudio.current ?? new Audio(); + audio.preservesPitch = true; + extraAudio.current = audio; + audio.src = ready.url; + audio.playbackRate = speed; + audio.onended = done; + audio.play().catch(done); + }) + .catch(done); + }, + [extra, stopAll, stopExtra] + ); + + /** + * Play the chat from message `msgId` through the newest (press the playing + * one again to stop). `tracks` is read afresh at every step, so messages + * that arrive meanwhile are played too. + */ + const startChat = useCallback( + ( + podId: string, + from: { id: string; chunk: number }, + tracks: () => ChatTrack[] + ): void => { + stopAll(); + // Pressing the button is the gesture browsers need before audio. + unlock.done = true; + setAudioError(null); + chatRun.current += 1; + const { current } = chatRun; + const asked = new Map>(); + const clip = ( + text: string, + need: 'now' | 'soon' + ): Promise => { + const known = asked.get(text); + + if (known !== undefined && need === 'soon') { + return known; + } + + const request = api + .ttsClip(podId, text, need) + .then(readyOf) + .catch(() => null); + asked.set(text, request); + + return request; + }; + const textOf = ( + at: { id: string; chunk: number } | null + ): string | null => + at === null + ? null + : tracks().find((track) => track.id === at.id)?.chunks[at.chunk] ?? + null; + + const step = (at: { id: string; chunk: number } | null): void => { + if (chatRun.current !== current) { + return; + } + + const text = textOf(at); + + if (at === null || text === null) { + uiLog('audio-chat-end', {}); + chatAt.current = null; + setChat(null); + + return; + } + + chatAt.current = { podId, at, tracks }; + setChat({ msgId: at.id, loading: true, paused: chatPaused.current }); + const first = clip(text, 'now'); + // One ahead: the next chunk, or the next message's first. + const ahead = textOf(nextChunk(tracks(), at.id, at.chunk)); + + if (ahead !== null) { + clip(ahead, 'soon').catch(() => undefined); + } + + const askedAt = performance.now(); + waitForClip(first, { + again: () => clip(text, 'now'), + retries: CLIP_RETRIES, + gapMs: CLIP_RETRY_GAP_MS, + sleep, + cancelled: () => chatRun.current !== current, + }) + .then((ready) => { + if (chatRun.current !== current) { + return; + } + + if (ready === null) { + uiLog('audio-unavailable', { kind: 'chat', msgId: at.id }); + setAudioError('Audio unavailable for this chat message.'); + setChat(null); + + return; + } + + uiLog('audio-play', { + kind: 'chat', + msgId: at.id, + chunk: at.chunk, + waitedMs: Math.round(performance.now() - askedAt), + }); + setChat({ + msgId: at.id, + loading: false, + paused: chatPaused.current, + }); + const audio = chatAudio.current ?? new Audio(); + audio.preservesPitch = true; + chatAudio.current = audio; + audio.src = ready.url; + audio.playbackRate = live.current.rate; + audio.onended = () => step(nextChunk(tracks(), at.id, at.chunk)); + + // Paused while it loaded: Space starts it. + if (chatPaused.current) { + return; + } + + audio.play().catch(() => { + if (chatRun.current === current) { + setChat(null); + } + }); + }) + .catch(() => setChat(null)); + }; + + step(from); + }, + [stopAll] + ); + + /** + * A message's 🔊: play from it through the newest. Pressing the one that's + * playing stops it; pressing it while paused carries on. + */ + const playChat = useCallback( + (podId: string, msgId: string, tracks: () => ChatTrack[]): void => { + if (chat?.msgId === msgId) { + if (chat.paused) { + toggleChat(); + + return; + } + + stopChat(); + + return; + } + + startChat(podId, { id: msgId, chunk: 0 }, tracks); + }, + [chat, startChat, stopChat, toggleChat] + ); + + /** ⇧→ / D: the next message. ⇧← / A: back to this one's start, or the one before. */ + const stepChat = useCallback( + (direction: 1 | -1): void => { + const place = chatAt.current; + + if (place === null) { + return; + } + + const tracks = place.tracks(); + const index = tracks.findIndex((track) => track.id === place.at.id); + const heard = + place.at.chunk > 0 || (chatAudio.current?.currentTime ?? 0) > 1.5; + const target = + direction === 1 + ? tracks[index + 1] + : heard + ? tracks[index] + : tracks[index - 1] ?? tracks[index]; + + if (target === undefined) { + stopChat(); + + return; + } + + startChat(place.podId, { id: target.id, chunk: 0 }, place.tracks); + }, + [startChat, stopChat] + ); + + /** Play part `index` from `offset` seconds, then carry on to the next. */ + const playSection = useCallback( + (index: number, offset: number, current: number): void => { + const section = live.current.speech[index]; + const span = live.current.parts[index]; + + if (section === undefined || span === undefined) { + setPlaying(false); + setTime(live.current.timeline.duration); + + return; + } + + playingIndex.current = index; + setPlaying(true); + setTime(span.start + offset); + // Was this clip's render already under way (requested ahead)? + const inFlight = requests.current.has(section.text); + // Ask for THIS clip before the ones ahead: the server renders requests in + // the order they arrive, and a jump must not wait behind its look-ahead. + const clip = ensureClip(index); + // Render ahead while this one plays. + Array.from( + { length: AHEAD }, + (_unused, step) => index + 1 + step + ).forEach((ahead) => { + ensureClip(ahead, 'soon').catch(() => undefined); + }); + + const next = (): void => { + if (run.current === current) { + playSection(index + 1, 0, current); + } + }; + + const known = live.current.clipFor(section.key); + const askedAt = performance.now(); + + if (known === null) { + setLoadingKey(section.section); + uiLog('audio-wait', { section: section.key, inFlight }); + } + + waitForClip(clip, { + again: () => { + requests.current.delete(section.text); + + return ensureClip(index); + }, + retries: CLIP_RETRIES, + gapMs: CLIP_RETRY_GAP_MS, + sleep, + cancelled: () => run.current !== current, + }) + .then((ready) => ready ?? live.current.clipFor(section.key)) + .then((ready) => { + if (run.current !== current) { + return; + } + + setLoadingKey(null); + + if (ready === null) { + uiLog('audio-unavailable', { + section: section.key, + waitedMs: Math.round(performance.now() - askedAt), + }); + setAudioError( + `Audio unavailable for "${section.label}" — it could not be rendered.` + ); + setPlaying(false); + + return; + } + + if (known === null) { + uiLog('audio-play', { + section: section.key, + waitedMs: Math.round(performance.now() - askedAt), + }); + } + + const audio = audioElement(); + + if (audio.src !== new URL(ready.url, window.location.href).href) { + audio.src = ready.url; + } + + audio.playbackRate = live.current.rate; + audio.currentTime = Math.min( + offset, + Math.max(0, ready.duration - 0.05) + ); + audio.onended = next; + audio.play().catch(() => setNeedsGesture(true)); + }) + .catch(next); + }, + [audioElement, ensureClip] + ); + + const playAt = useCallback( + (at: number): void => { + if (!unlock.done) { + setNeedsGesture(true); + + return; + } + + setNeedsGesture(false); + setAudioError(null); + stopAll(); + const { parts: line } = live.current; + const index = sectionIndexAt(line, at); + const start = line[index]?.start ?? 0; + playSection(index, Math.max(0, at - start), run.current); + }, + [playSection, stopAll] + ); + + // Progress: the clip's own clock while it plays; the estimate's otherwise. + useEffect(() => { + if (!playing) { + return undefined; + } + + const frame = { id: 0 }; + const tick = (): void => { + const audio = element.current; + const span = live.current.parts[playingIndex.current]; + + if (span !== undefined && audio !== null && !audio.paused) { + setTime(span.start + audio.currentTime); + } + + frame.id = requestAnimationFrame(tick); + }; + frame.id = requestAnimationFrame(tick); + + return () => cancelAnimationFrame(frame.id); + }, [playing]); + + // ── remember the position (see planPlace.ts) ────────────────────────── + // At most once a second while playing; at once when paused or seeking. + const savedAt = useRef(0); + /** The item whose place has been put back (restorePlace) — saving waits for it. */ + const restoredFor = useRef(null); + + useEffect(() => { + const { itemKey } = args; + + if (itemKey === null || (playing && Date.now() - savedAt.current < 1_000)) { + return; + } + + // Not until this item's place has been put back: its 0:00 before then + // isn't where they are, and saving it would wipe where they were. + if (restoredFor.current !== itemKey) { + return; + } + + const line = live.current.timeline; + const section = line.sections[sectionIndexAt(line.sections, time)]; + + if (section === undefined) { + return; + } + + savedAt.current = Date.now(); + savePlace(itemKey, { + audio: { + section: section.key, + offset: Math.max(0, time - section.start), + }, + }); + // Keyed on the position only: the item is read as it is now. + }, [time, playing]); + + /** + * Put the position back where they left this item (planPlace.ts). Needs the + * item's sections: until they're known (a reload, before the card has + * loaded) it waits, and nothing is saved meanwhile. Returns where it put it. + */ + const restorePlace = (): number => { + const { itemKey } = args; + const line = live.current.timeline; + + if (itemKey === null || line.sections.length === 0) { + setTime(0); + + return 0; + } + + const place = loadPlace(itemKey)?.audio; + const section = line.sections.find((item) => item.key === place?.section); + const resumeAt = + place === undefined || section === undefined + ? 0 + : Math.min( + section.start + place.offset, + Math.max(0, line.duration - 0.05) + ); + setTime(resumeAt); + savedAt.current = Date.now(); + restoredFor.current = itemKey; + + return resumeAt; + }; + const sectionsKnown = timeline.sections.length > 0; + + useEffect(() => { + if ( + sectionsKnown && + args.itemKey !== null && + restoredFor.current !== args.itemKey && + !live.current.playing + ) { + restorePlace(); + } + }, [args.itemKey, sectionsKnown]); + + // ── item changes: load it; play only if Autoplay is on ──────────────── + // The chat playlist keeps going across items unless Autoplay starts one. + useEffect(() => { + stopItem(); + stopExtra(); + setAudioError(null); + setFetched({}); + requests.current = new Map(); + // Pick up where they were in this item's audio, if they've been here before + // (or as soon as its sections are known — see the effect below). + restoredFor.current = null; + const resumeAt = restorePlace(); + + if (args.itemKey === null || speech.length === 0 || !loadAutoplay()) { + setNeedsGesture(false); + + return undefined; + } + + const start = (): void => playAt(resumeAt); + + if (unlock.done) { + start(); + + return stopItem; + } + + setNeedsGesture(true); + unlock.listeners.add(start); + + return () => { + unlock.listeners.delete(start); + stopItem(); + }; + // Deliberately keyed on item identity only: a state refresh of the same + // item must not restart playback. Latest values are read through refs. + }, [args.itemKey]); + + // ── public controls ─────────────────────────────────────────────────── + // While a chat message plays, the controls are its. + const pause = useCallback((): void => { + if (live.current.chatPlaying) { + if (!chatPaused.current) { + toggleChat(); + } + + return; + } + + stopAll(); + }, [stopAll, toggleChat]); + + const toggle = useCallback((): void => { + if (live.current.chatPlaying) { + toggleChat(); + + return; + } + + if (live.current.playing) { + pause(); + + return; + } + + const { time: at, timeline: line } = live.current; + playAt(at >= line.duration - 0.05 ? 0 : at); + }, [pause, playAt, toggleChat]); + + const seek = useCallback( + (to: number, keepPlaying?: boolean): void => { + const { timeline: line, playing: isPlaying } = live.current; + const at = Math.min(Math.max(0, to), Math.max(0, line.duration - 0.05)); + + if (keepPlaying === true || isPlaying) { + playAt(at); + + return; + } + + setTime(at); + }, + [playAt] + ); + + /** A section's 🔊 or dot: jump there and play from it. */ + const seekSection = useCallback( + (key: SectionKey): void => { + const target = live.current.timeline.sections.find( + (section) => section.key === key + ); + + if (target !== undefined) { + seek(target.start, true); + } + }, + [seek] + ); + + const stepSection = useCallback( + (direction: 1 | -1): void => { + if (live.current.chatPlaying) { + stepChat(direction); + + return; + } + + const { timeline: line, time: at } = live.current; + const index = sectionIndexAt(line.sections, at); + const current = line.sections[index]; + // "Previous" restarts the current section unless we are at its very start. + const target = + direction === -1 && current !== undefined && at - current.start > 1.5 + ? current + : line.sections[index + direction]; + + if (target !== undefined) { + seek(target.start, true); + } + }, + [seek, stepChat] + ); + + const skip = useCallback( + (seconds: number): void => { + const audio = chatAudio.current; + + if (!live.current.chatPlaying) { + seek(live.current.time + seconds); + + return; + } + + if (audio === null || live.current.chatLoading) { + return; + } + + audio.currentTime = Math.min( + Math.max(0, audio.currentTime + seconds), + Math.max(0, audio.duration - 0.05) + ); + }, + [seek] + ); + + const setRate = useCallback((next: number): void => { + const clamped = clampRate(next); + setRateState(clamped); + saveRate(clamped); + + if (element.current !== null) { + element.current.playbackRate = clamped; + } + + if (chatAudio.current !== null) { + chatAudio.current.playbackRate = clamped; + } + }, []); + + const setAutoplay = useCallback((on: boolean): void => { + setAutoplayState(on); + saveAutoplay(on); + }, []); + + /** + * Live conversation replies (Autoplay on only): stream the reply's audio as + * it renders. If it fails, nothing is read — the text is already on screen, + * and audio never holds it back. + */ + const sayReply = useCallback( + (podId: string, msgId: string): void => { + // A playing chat playlist reaches the reply by itself. + if (!loadAutoplay() || live.current.chatPlaying) { + return; + } + + stopAll(); + const askedAt = performance.now(); + const stream = new Audio( + `/api/tts/stream?podId=${encodeURIComponent( + podId + )}&msgId=${encodeURIComponent(msgId)}` + ); + stream.preservesPitch = true; + stream.playbackRate = live.current.rate; + const failed = (): void => { + uiLog('audio-unavailable', { kind: 'reply', msgId }); + stream.removeAttribute('src'); + }; + stream.addEventListener( + 'playing', + () => + uiLog('audio-play', { + kind: 'reply', + msgId, + waitedMs: Math.round(performance.now() - askedAt), + }), + { once: true } + ); + stream.addEventListener('error', failed, { once: true }); + stream.play().catch(failed); + }, + [stopAll] + ); + + // ── headphones / lock screen ────────────────────────────────────────── + useEffect(() => { + if (!('mediaSession' in navigator) || args.itemKey === null) { + return; + } + + navigator.mediaSession.metadata = new MediaMetadata({ + title: args.title, + artist: args.artist, + album: 'Lambda', + }); + navigator.mediaSession.setActionHandler('play', () => toggle()); + navigator.mediaSession.setActionHandler('pause', () => pause()); + navigator.mediaSession.setActionHandler('nexttrack', () => stepSection(1)); + navigator.mediaSession.setActionHandler('previoustrack', () => + stepSection(-1) + ); + navigator.mediaSession.setActionHandler('seekforward', () => skip(10)); + navigator.mediaSession.setActionHandler('seekbackward', () => skip(-10)); + navigator.mediaSession.setActionHandler('seekto', (details) => { + if (details.seekTime !== undefined) { + seek(details.seekTime); + } + }); + }, [ + args.itemKey, + args.title, + args.artist, + toggle, + pause, + stepSection, + skip, + seek, + ]); + + const activeKey = + source === 'none' || (!playing && time === 0) + ? null + : timeline.sections[sectionIndexAt(timeline.sections, time)]?.key ?? null; + + return { + source, + playing, + time, + duration: timeline.duration, + sections: timeline.sections, + activeKey, + loadingKey, + audioError, + dismissAudioError: () => setAudioError(null), + needsGesture, + rate, + autoplay, + toggle, + pause, + stop: stopAll, + seek, + seekSection, + next: () => stepSection(1), + previous: () => stepSection(-1), + skip, + setRate, + setAutoplay, + sayReply, + /** The on-demand clip playing / loading (by key), if any. */ + extra, + playExtra, + /** The chat message playing / loading, if the chat playlist is on. */ + chat, + playChat, + enable: () => { + unlock.done = true; + setNeedsGesture(false); + playAt(live.current.time); + }, + }; +}; + +export type Player = ReturnType; diff --git a/src/app/client/useReadAloud.ts b/src/app/client/useReadAloud.ts new file mode 100644 index 0000000..84d9b1e --- /dev/null +++ b/src/app/client/useReadAloud.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { api } from './api'; + +/** + * Read aloud for a Lambda's page: its rail's sections, one clip each (the + * same clips the Plan rail plays), with previous/next section and 1×/2×. + */ + +export interface ReadAloudSection { + id: string; + text: string; +} + +export interface ReadAloud { + playing: boolean; + /** The section playing or paused at; null before the first play. */ + at: string | null; + speed: 1 | 2; + /** Play from one section (or the first), or pause/resume. */ + toggle: () => void; + playSection: (id: string) => void; + step: (by: -1 | 1) => void; + setSpeed: (speed: 1 | 2) => void; + /** The last clip that wouldn't render. */ + trouble: string | null; +} + +export const useReadAloud = ( + podId: string | null, + sections: ReadAloudSection[] +): ReadAloud => { + const audio = useRef(null); + const [playing, setPlaying] = useState(false); + const [at, setAt] = useState(null); + const [speed, setSpeedNow] = useState<1 | 2>(1); + const [trouble, setTrouble] = useState(null); + const latest = useRef(sections); + latest.current = sections; + const speedRef = useRef(speed); + speedRef.current = speed; + + const stop = useCallback(() => { + audio.current?.pause(); + setPlaying(false); + }, []); + + // Leaving the page stops it. + useEffect(() => stop, [stop, podId]); + + const play = useCallback( + (id: string): void => { + const index = latest.current.findIndex((one) => one.id === id); + const section = latest.current[index]; + + if (podId === null || section === undefined) { + stop(); + + return; + } + + setAt(id); + setPlaying(true); + setTrouble(null); + api + .ttsClip(podId, section.text, 'now') + .then((clip) => { + if (clip.status !== 'ready') { + setTrouble("Read aloud isn't available right now."); + setPlaying(false); + + return; + } + + audio.current?.pause(); + const player = new Audio(clip.url); + player.playbackRate = speedRef.current; + player.onended = () => { + const next = latest.current[index + 1]; + + if (next === undefined) { + setPlaying(false); + + return; + } + + play(next.id); + }; + audio.current = player; + player.play().catch(() => setPlaying(false)); + }) + .catch(() => { + setTrouble("Read aloud isn't available right now."); + setPlaying(false); + }); + }, + [podId, stop] + ); + + const toggle = (): void => { + if (playing) { + stop(); + + return; + } + + if (audio.current !== null && at !== null && !audio.current.ended) { + audio.current.play().catch(() => setPlaying(false)); + setPlaying(true); + + return; + } + + const [first] = sections; + + if (first !== undefined) { + play(first.id); + } + }; + + const step = (by: -1 | 1): void => { + const index = sections.findIndex((one) => one.id === at); + const next = sections[Math.max(0, index + by)]; + + if (next !== undefined) { + play(next.id); + } + }; + + const setSpeed = (value: 1 | 2): void => { + setSpeedNow(value); + + if (audio.current !== null) { + audio.current.playbackRate = value; + } + }; + + return { + playing, + at, + speed, + toggle, + playSection: play, + step, + setSpeed, + trouble, + }; +}; diff --git a/src/app/client/useWorkspace.ts b/src/app/client/useWorkspace.ts new file mode 100644 index 0000000..042ca39 --- /dev/null +++ b/src/app/client/useWorkspace.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { TrackerProject } from '../../tickets/source'; +import type { TrackerProjectsResponse } from '../shared/building'; +import type { Favorite, FavoriteKind, SavedView } from '../shared/savedViews'; +import { api } from './api'; + +/** + * Saved views, favorites and the trackers' projects, as the whole app shares + * them: the sidebar draws the favorites, Tickets scopes to a view or project. + * Views and favorites are the mothership's own (cheap), read again on each + * tick; the trackers' projects ask every codebase's tracker, so they're read + * once a minute at most, or when asked. + */ + +const PROJECTS_REFRESH_MS = 60_000; + +export interface Workspace { + views: SavedView[] | null; + favorites: Favorite[]; + trackerProjects: TrackerProjectsResponse | null; + /** A codebase's tracker projects (none while loading or offline). */ + projectsOf: (codebaseId: string) => TrackerProject[]; + isFavorite: (kind: FavoriteKind, ref: string) => boolean; + /** Star or unstar: shown at once, the mothership's answer kept. */ + setFavorite: ( + kind: FavoriteKind, + ref: string, + on: boolean, + /** Its name for the sidebar, when the mothership can't know it. */ + label?: string + ) => void; + orderFavorites: (ids: string[]) => void; + /** Read views (and favorites) again: after a save, a delete, an import. */ + reloadViews: () => Promise; + reloadProjects: () => void; +} + +export const useWorkspace = ( + tick: number, + fail: (caught: unknown) => void +): Workspace => { + const [views, setViews] = useState(null); + const [favorites, setFavorites] = useState([]); + const [trackerProjects, setTrackerProjects] = + useState(null); + const projectsAt = useRef(null); + + const reloadViews = useCallback( + () => + Promise.all([api.views(), api.favorites()]).then( + ([nextViews, nextFavorites]) => { + setViews(nextViews); + setFavorites(nextFavorites); + } + ), + [] + ); + + const reloadProjects = useCallback(() => { + projectsAt.current = Date.now(); + api + .trackerProjects() + .then(setTrackerProjects) + .catch(() => { + // A tracker that's away: its projects come with the next read. + projectsAt.current = null; + }); + }, []); + + useEffect(() => { + // A failed read is retried on the next tick; nothing to show meanwhile. + reloadViews().catch(() => undefined); + + if ( + projectsAt.current === null || + Date.now() - projectsAt.current > PROJECTS_REFRESH_MS + ) { + reloadProjects(); + } + }, [tick, reloadViews, reloadProjects]); + + const projectsOf = (codebaseId: string): TrackerProject[] => { + const codebase = trackerProjects?.codebases.find( + (one) => one.projectId === codebaseId + ); + + return codebase !== undefined && 'projects' in codebase + ? codebase.projects + : []; + }; + + return { + views, + favorites, + trackerProjects, + projectsOf, + isFavorite: (kind, ref) => + favorites.some((one) => one.kind === kind && one.ref === ref), + setFavorite: (kind, ref, on, label) => { + setFavorites((now) => + on + ? [ + ...now.filter((one) => one.kind !== kind || one.ref !== ref), + { + id: `pending:${kind}:${ref}`, + kind, + ref, + position: Number.MAX_SAFE_INTEGER, + label: label ?? null, + }, + ] + : now.filter((one) => one.kind !== kind || one.ref !== ref) + ); + api.favorite(kind, ref, on, label).then(setFavorites).catch(fail); + }, + orderFavorites: (ids) => { + setFavorites((now) => + ids.flatMap((id) => now.filter((one) => one.id === id)) + ); + api.orderFavorites(ids).then(setFavorites).catch(fail); + }, + reloadViews, + reloadProjects, + }; +}; diff --git a/src/app/devProxy.ts b/src/app/devProxy.ts new file mode 100644 index 0000000..caa5ef8 --- /dev/null +++ b/src/app/devProxy.ts @@ -0,0 +1,19 @@ +/** Vite's own page in development: `vite.config.mts`'s server port. */ +export const DEV_PAGE_ORIGINS = [ + 'http://localhost:4581', + 'http://127.0.0.1:4581', +]; + +/** + * The Origin the dev proxy sends on to the mothership. Its own page is + * presented as the mothership's (or the Origin check refuses every POST); + * any other origin goes through unchanged, so the mothership refuses it as + * it would without the proxy. + */ +export const proxiedOrigin = ( + origin: string | undefined, + mothership: string +): string | undefined => + origin !== undefined && DEV_PAGE_ORIGINS.includes(origin) + ? mothership + : origin; diff --git a/src/app/server/keys.ts b/src/app/server/keys.ts new file mode 100644 index 0000000..bb10b5b --- /dev/null +++ b/src/app/server/keys.ts @@ -0,0 +1,66 @@ +/** + * Turn the raw bytes xterm emits into `tmux send-keys` calls. + * + * Raw bytes (`send-keys -H`) are right for printable text, but agent TUIs + * such as Claude Code do NOT treat a raw CR byte as Enter — only tmux's named + * key submits. So control bytes and the common escape sequences become named + * keys; everything else stays raw. Returns one argument list per send-keys call. + */ + +const NAMED_BYTES: Record = { + '0d': 'Enter', + '0a': 'Enter', + '7f': 'BSpace', + '08': 'BSpace', + '09': 'Tab', + '03': 'C-c', + '15': 'C-u', +}; + +/** ESC [ sequences (and ESC O , used in application mode). */ +const NAMED_SEQUENCES: Record = { + '1b5b41': 'Up', + '1b5b42': 'Down', + '1b5b43': 'Right', + '1b5b44': 'Left', + '1b5b48': 'Home', + '1b5b46': 'End', + '1b4f41': 'Up', + '1b4f42': 'Down', + '1b4f43': 'Right', + '1b4f44': 'Left', + '1b5b5a': 'BTab', +}; + +export const toSendKeysCalls = (hex: string[]): string[][] => { + const step = (at: number, raw: string[], calls: string[][]): string[][] => { + const flush = (): string[][] => + raw.length > 0 ? [...calls, ['-H', ...raw]] : calls; + + if (at >= hex.length) { + return flush(); + } + + const byte = hex[at] ?? ''; + const triple = hex.slice(at, at + 3).join(''); + const sequence = NAMED_SEQUENCES[triple]; + + if (sequence !== undefined) { + return step(at + 3, [], [...flush(), [sequence]]); + } + + if (byte === '1b' && at === hex.length - 1) { + return step(at + 1, [], [...flush(), ['Escape']]); + } + + const named = NAMED_BYTES[byte]; + + if (named !== undefined) { + return step(at + 1, [], [...flush(), [named]]); + } + + return step(at + 1, [...raw, byte], calls); + }; + + return step(0, [], []); +}; diff --git a/src/app/server/liveCards.ts b/src/app/server/liveCards.ts new file mode 100644 index 0000000..e6526b3 --- /dev/null +++ b/src/app/server/liveCards.ts @@ -0,0 +1,102 @@ +import type { PodStore } from '../../pod-store'; + +/** + * Cards edited like a doc: an open checkpoint records the files its card was + * built from (`checkpoint.sources`). When one of them is newer than the card's + * last read, the card is stale — the server runs `lambda pod checkpoint refresh`, + * which re-reads them with the same checks as posting and updates the card in + * place (or records why the edit didn't take). + */ + +export interface StaleCard { + podId: string; + cp: number; + /** The newest of its files: refreshing for this edit once is enough. */ + mtime: number; +} + +const SOURCE_FILES = [ + 'planFile', + 'screensFile', + 'proposalFile', + 'changesFile', + 'judgementsFile', +] as const; + +/** + * Open checkpoints whose files changed after the card was last read. + * `tried`: the newest edit already refreshed for (by "podId/cp"), so an edit + * that failed its checks isn't re-run until the file changes again. + */ +export const staleCards = ( + store: PodStore, + mtimeOf: (file: string) => number | null, + tried: ReadonlyMap +): StaleCard[] => + store.listPodIds().flatMap((podId) => + store.listQuestions(podId).flatMap((question): StaleCard[] => { + const { checkpoint } = question; + const sources = checkpoint?.sources; + + if ( + question.kind !== 'checkpoint' || + question.status !== 'open' || + checkpoint === undefined || + sources === undefined + ) { + return []; + } + + const newest = Math.max( + 0, + ...SOURCE_FILES.flatMap((key) => { + const file = sources[key]; + const mtime = file === undefined ? null : mtimeOf(file); + + return mtime === null ? [] : [mtime]; + }) + ); + const readAt = Date.parse(checkpoint.refreshedAt ?? question.createdAt); + const key = `${podId}/${checkpoint.cp}`; + + return newest > readAt && newest !== tried.get(key) + ? [{ podId, cp: checkpoint.cp, mtime: newest }] + : []; + }) + ); + +/** + * One pass of the re-read: `lambda pod checkpoint refresh` (`refresh`) for + * each stale card not already being refreshed. Answers once they're done. + */ +export const refreshStaleCards = (deps: { + store: PodStore; + mtimeOf: (file: string) => number | null; + tried: Map; + refreshing: Set; + refresh: (card: StaleCard) => Promise; + log: (line: string) => void; +}): Promise => + Promise.all( + staleCards(deps.store, deps.mtimeOf, deps.tried).map(async (card) => { + const key = `${card.podId}/${card.cp}`; + + if (deps.refreshing.has(key)) { + return; + } + + deps.refreshing.add(key); + deps.tried.set(key, card.mtime); + + await deps + .refresh(card) + .catch((error: unknown) => + deps.log( + `Couldn't refresh checkpoint ${card.cp} of ${card.podId}: ${ + error instanceof Error ? error.message : String(error) + }. It's tried again when its files change.` + ) + ) + .finally(() => deps.refreshing.delete(key)); + }) + ).then(() => undefined); diff --git a/src/app/server/podFile.ts b/src/app/server/podFile.ts new file mode 100644 index 0000000..9678133 --- /dev/null +++ b/src/app/server/podFile.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { ID_PATTERN } from '../../pod-store'; + +/** + * `GET /api/pod-file///`: the UX + * review's pictures, screenshots a pod took for the chat (`shots/`), and a + * building pod's test screenshots (`screenshots///`, with + * that folder's `manifest.json`). `GET /api/pod//visual/`: the + * visual check's rounds (`visual///`, with each round's + * `manifest.json`). Only a PNG, or one of those manifests, inside one of + * those folders of that pod is served (symlinks resolved, and never through + * a folder that is itself a symlink), so questions, thread and intent stay + * unreachable. The runner holding the pod answers; the mothership never + * reads them. + */ + +const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const FOLDERS = ['designs', 'shots', 'screenshots', 'visual']; +/** The folders whose `manifest.json` is served beside their pictures. */ +const MANIFEST_FOLDERS = ['screenshots', 'visual']; +export const MANIFEST = 'manifest.json'; + +/** The 8 bytes every PNG starts with, whatever its name. */ +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +/** The one PNG check: what qa records and what the runner serves. */ +export const isPng = (file: string): boolean => { + const handle = fs.openSync(file, 'r'); + + try { + const head = Buffer.alloc(PNG_SIGNATURE.length); + const read = fs.readSync(handle, head, 0, head.length, 0); + + return read === head.length && head.equals(PNG_SIGNATURE); + } finally { + fs.closeSync(handle); + } +}; + +/** The file's absolute path, or null when it isn't one we serve. */ +export const podFilePath = ( + root: string, + podId: string, + rest: string +): string | null => { + const segments = rest.split('/'); + + if ( + !ID_PATTERN.test(podId) || + !FOLDERS.includes(segments[0] ?? '') || + segments.length < 2 || + !segments.every((segment) => SEGMENT.test(segment)) || + !( + rest.endsWith('.png') || + (MANIFEST_FOLDERS.includes(segments[0] ?? '') && + segments.at(-1) === MANIFEST) + ) + ) { + return null; + } + + const file = path.join(root, podId, ...segments); + const folderPath = path.join(root, podId, segments[0] ?? ''); + + // A served folder that is itself a symlink could point anywhere. + if (!fs.existsSync(file) || fs.lstatSync(folderPath).isSymbolicLink()) { + return null; + } + + // Inside the pod's own folder, never the folder a symlink resolves to. + const folder = path.join( + fs.realpathSync(path.join(root, podId)), + segments[0] ?? '' + ); + const real = fs.realpathSync(file); + + return real.startsWith(`${folder}${path.sep}`) && fs.statSync(real).isFile() + ? real + : null; +}; diff --git a/src/app/server/prototypes.ts b/src/app/server/prototypes.ts new file mode 100644 index 0000000..f0626ef --- /dev/null +++ b/src/app/server/prototypes.ts @@ -0,0 +1,297 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { + ID_PATTERN, + PROTOTYPE_SLUG, + listPrototypes, + prototypeVersionDir, +} from '../../pod-store'; +import type { Made, MadePicture } from '../shared/prototypes'; + +/** + * A pod's prototypes, live in the inbox (the snapshot command keeps them): + * + * - `GET /proto///v/` serves a version's `files/`. The + * page frames it with `sandbox="allow-scripts"` (no same-origin: it can't + * read the inbox or its storage) and every response forbids fetching, + * posting forms and loading anything but itself and Google Fonts. Each + * artboard gets BRIDGE_SCRIPT, the only way the inbox talks to it. + * - `GET /api/made/` lists what the pod has made, for the Made here + * rail: its prototypes (every version) and its pictures. + */ + +const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +const CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', +}; + +export const contentTypeOf = (file: string): string | null => + CONTENT_TYPES[path.extname(file).toLowerCase()] ?? null; + +/** + * The file a `/proto/` path names, or null: a known type, inside that + * version's `files/` (symlinks resolved), of a pod and slug we'd name. + */ +export const prototypeFilePath = ( + root: string, + rest: string +): string | null => { + const [podId, slug, version, ...segments] = rest.split('/'); + + if ( + podId === undefined || + !ID_PATTERN.test(podId) || + slug === undefined || + !PROTOTYPE_SLUG.test(slug) || + version === undefined || + !/^v[1-9]\d*$/.test(version) || + segments.length === 0 || + !segments.every((segment) => SEGMENT.test(segment)) || + contentTypeOf(segments.at(-1) ?? '') === null + ) { + return null; + } + + const podDir = path.join(root, podId); + const files = path.join( + prototypeVersionDir(podDir, slug, Number(version.slice(1))), + 'files' + ); + const file = path.join(files, ...segments); + + if (!fs.existsSync(file) || !fs.existsSync(files)) { + return null; + } + + // Inside the pod's own folder: no folder on the way to `files/` may be a + // symlink, or the root moves wherever it points. + const realFiles = path.join( + fs.realpathSync(podDir), + path.relative(podDir, files) + ); + const real = fs.realpathSync(file); + + return fs.realpathSync(files) === realFiles && + real.startsWith(`${realFiles}${path.sep}`) && + fs.statSync(real).isFile() + ? real + : null; +}; + +const HOST = /^[A-Za-z0-9.-]+(?::\d+)?$|^\[[0-9a-fA-F:]+\](?::\d+)?$/; + +/** A version's folder, with or without its link token (base36 expiry `.` HMAC). */ +export const PROTO_BASE = + /^\/proto\/(?:[a-z0-9]+\.[A-Za-z0-9_-]+\/)?[a-z0-9-]+\/[a-z0-9-]+\/v\d+\/$/; + +/** + * The headers on every prototype file. `host` (the inbox as the browser + * reached it, e.g. through Tailscale) names "this server" for a sandboxed + * page, whose own origin is opaque. + */ +export const prototypeHeaders = ( + file: string, + host: string | undefined, + /** + * The version's own folder, all it may fetch: on the mothership + * `/proto////v/` (the token is what lets the sandboxed + * frame in), else `/proto///v/`. + */ + base: string +): Record => { + const trusted = host !== undefined && HOST.test(host); + const self = trusted ? `'self' http://${host} https://${host}` : "'self'"; + // The runtime reads its own artboard back; nothing else on the inbox. + const ownFolder = + trusted && PROTO_BASE.test(base) + ? `http://${host}${base} https://${host}${base}` + : "'none'"; + + return { + 'content-type': contentTypeOf(file) ?? 'application/octet-stream', + 'cache-control': 'no-cache', + 'x-content-type-options': 'nosniff', + // Fonts are fetched in CORS mode, and a sandboxed page's origin is opaque. + 'access-control-allow-origin': '*', + 'content-security-policy': [ + "default-src 'none'", + // The Design runtime compiles its templates. + `script-src ${self} 'unsafe-inline' 'unsafe-eval'`, + `style-src ${self} 'unsafe-inline' https://fonts.googleapis.com`, + `font-src ${self} data: https://fonts.gstatic.com`, + `img-src ${self} data: blob:`, + `connect-src ${ownFolder}`, + "form-action 'none'", + "base-uri 'none'", + // Sandboxed in its own tab too: an opaque origin, never the inbox's. + 'sandbox allow-scripts', + `frame-ancestors ${self}`, + ].join('; '), + }; +}; + +/** + * Runs inside every artboard. It only answers the inbox (its parent): + * `state` clicks the board's own `[data-state]` switch (once it has + * rendered, if asked before). It reports the situations it has (`ready`), + * a switch they clicked inside the board (`switched`), and Esc. + */ +export const BRIDGE_SCRIPT = `(function () { + var parent = window.parent; + // Opened in its own tab (?state=): just that situation, its own + // switch kept so they can change it there. + var framed = parent !== window; + var wanted = null; + var post = function (message) { + if (!framed) return; + message.lambda = 'prototype'; + parent.postMessage(message, '*'); + }; + var switches = function () { + return Array.prototype.slice.call(document.querySelectorAll('[data-state]')); + }; + var apply = function () { + var control = switches().filter(function (el) { + return el.getAttribute('data-state') === wanted; + })[0]; + if (control && control.getAttribute('aria-pressed') !== 'true') control.click(); + }; + // The inbox's bar switches situations, so the board's own switch (the + // [data-state] controls' bar, outside [data-screen]) is hidden; it still + // works when clicked from here. + var hideSwitch = function () { + var controls = switches(); + if (controls.length === 0) return; + var screen = document.querySelector('[data-screen]'); + var bar = controls[0].parentElement; + while (bar && !controls.every(function (el) { return bar.contains(el); })) bar = bar.parentElement; + if (!bar || bar === document.body || (screen && bar.contains(screen))) return; + while (screen && bar.parentElement && bar.parentElement !== document.body && !(screen && bar.parentElement.contains(screen)) && bar.parentElement.id !== 'dc-root') bar = bar.parentElement; + bar.setAttribute('data-lambda-hidden', ''); + if (!document.getElementById('lambda-hidden-style')) { + var style = document.createElement('style'); + style.id = 'lambda-hidden-style'; + style.textContent = '[data-lambda-hidden]{display:none!important}'; + document.head.appendChild(style); + } + }; + var report = function () { + post({ + type: 'ready', + states: switches().map(function (el) { return el.getAttribute('data-state'); }) + }); + }; + window.addEventListener('message', function (event) { + if (event.source !== parent) return; + var data = event.data || {}; + if (data.type === 'state' && typeof data.slug === 'string') { + wanted = data.slug; + apply(); + } + if (data.type === 'hello') report(); + }); + document.addEventListener('click', function (event) { + var hit = event.target instanceof Element ? event.target : null; + var control = hit ? hit.closest('[data-state]') : null; + if (control) { + wanted = control.getAttribute('data-state'); + post({ type: 'switched', state: wanted }); + } + }, true); + window.addEventListener('keydown', function (event) { + if (event.key === 'Escape') post({ type: 'escape' }); + }); + if (!framed) { + var asked = /[?&]state=([a-z0-9-]+)/.exec(window.location.search); + wanted = asked ? asked[1] : null; + } + var tries = 0; + var wait = setInterval(function () { + tries += 1; + var root = document.getElementById('dc-root'); + if ((root && root.childElementCount > 0) || tries > 100) { + clearInterval(wait); + setTimeout(function () { + if (framed) hideSwitch(); + apply(); + report(); + }, 60); + } + }, 100); +})();`; + +/** An artboard with the bridge added at the end of its body. */ +export const withBridge = (html: string): string => { + const script = ``; + const end = html.lastIndexOf(''); + + return end === -1 + ? `${html}${script}` + : `${html.slice(0, end)}${script}${html.slice(end)}`; +}; + +const MAX_PICTURES = 400; + +/** Round photos are the loop's working drafts; a prototype's stills are in its card. */ +const SKIPPED_FOLDERS = new Set(['walk', 'prototype']); + +const pngsUnder = (podDir: string, rel: string): MadePicture[] => { + const dir = path.join(podDir, rel); + + if (!fs.existsSync(dir)) { + return []; + } + + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const child = path.posix.join(rel, entry.name); + + if (entry.isDirectory()) { + return SKIPPED_FOLDERS.has(entry.name) || !SEGMENT.test(entry.name) + ? [] + : pngsUnder(podDir, child); + } + + return entry.isFile() && + entry.name.endsWith('.png') && + SEGMENT.test(entry.name) + ? [ + { + path: child, + group: rel, + at: fs.statSync(path.join(dir, entry.name)).mtime.toISOString(), + }, + ] + : []; + }); +}; + +/** Everything the pod has made, newest first; null for a pod id we'd never name. */ +export const madeHere = (root: string, podId: string): Made | null => { + if (!ID_PATTERN.test(podId)) { + return null; + } + + const podDir = path.join(root, podId); + + return { + prototypes: listPrototypes(podDir), + pictures: [...pngsUnder(podDir, 'designs'), ...pngsUnder(podDir, 'shots')] + .sort((a, b) => b.at.localeCompare(a.at) || a.path.localeCompare(b.path)) + .slice(0, MAX_PICTURES), + }; +}; diff --git a/src/app/server/state.ts b/src/app/server/state.ts new file mode 100644 index 0000000..1b5519a --- /dev/null +++ b/src/app/server/state.ts @@ -0,0 +1,312 @@ +import { + type Pod, + type PodStore, + type Question, + type RenderMachine, + type ThreadMessage, + isIdle, + orderInbox, + podKindOf, +} from '../../pod-store'; +import { + buildingReplyItems, + buildingStateOf, + withAlsoHeld, +} from '../shared/building'; +import { chatHold, chatTimes, pendingReply } from '../shared/conversation'; +import { awaitingAgent, waitingOnSpawned } from '../shared/items'; +import type { + AppState, + InboxEntry, + PodDetail, + PodStatus, + PodSummary, + TimeSummary, +} from '../shared/types'; +import { buildWorkflowView } from './workflowView'; + +/** + * A question the owner has already answered stays `open` until the dev resolves + * it — answers are async — so it must not come back to them meanwhile: an item + * whose latest chat message is THEIRS is hidden until Claude replies (progress + * notes don't count). Shared with the card, in ../shared/items. + */ +export { awaitingAgent } from '../shared/items'; + +/** Out of the inbox: Claude owes a reply, or decisions it spawned are still open. */ +const stepsAside = ( + question: Question, + questions: Question[], + thread: ThreadMessage[] +): boolean => + awaitingAgent(question, thread) || + waitingOnSpawned(question, questions, thread); + +const statusOf = (pod: Pod, needsYou: number, stalled: boolean): PodStatus => { + if (pod.status === 'done' || pod.status === 'paused') { + return pod.status; + } + + if (needsYou > 0) { + return 'needs-you'; + } + + if (pod.waitingOnHuman !== undefined) { + return 'waiting-step0'; + } + + return stalled ? 'stalled' : 'working'; +}; + +/** + * `escalated`: pods the watchdog (./watchdog.ts) could not get moving, with + * why — they are the only "Stalled" items. A pod that is merely idle is the + * watchdog's to nudge first, not the owner's. + */ +export const buildState = ( + store: PodStore, + windowsFor: (podId: string) => string[], + escalated: ReadonlyMap = new Map(), + now: number = Date.now() +): AppState => { + const pods = store.listPodIds().map((podId) => store.requirePod(podId)); + const questions = Object.fromEntries( + pods.map((pod) => [pod.id, store.listQuestions(pod.id)]) + ); + // Read once per build: its Blocked items and its summary both need it. + const buildings = new Map( + pods + .filter((pod) => podKindOf(pod) === 'building' && pod.talk === undefined) + .map((pod) => [pod.id, store.readBuilding(pod.id)]) + ); + const threads = Object.fromEntries( + pods.map((pod) => [ + pod.id, + withAlsoHeld(store.readThread(pod.id), buildings.get(pod.id) ?? null), + ]) + ); + const { priority } = store.readInbox(); + const active = pods.filter((pod) => pod.status === 'active'); + const rank = (podId: string): number => { + const index = priority.indexOf(podId); + + return index === -1 ? priority.length : index; + }; + // A pod running a work request is being reworked: its items step aside + // until the dev has re-checked them (see lambda pod request). + const requests = Object.fromEntries( + pods.map((pod) => [pod.id, store.activeRequest(pod.id)]) + ); + // ⌘⏎ in the Chat panel: the pod waits for the chat's answer out of the way. + const holding = new Set( + pods + .filter((pod) => chatHold(threads[pod.id] ?? [], now)) + .map((pod) => pod.id) + ); + const reworking = (podId: string): boolean => + requests[podId] !== undefined || holding.has(podId); + const byPriority = [...active].sort( + (a, b) => rank(a.id) - rank(b.id) || a.createdAt.localeCompare(b.createdAt) + ); + + // 1. "Your turn" — an exchange the owner is already in comes first. + const replies = byPriority.flatMap((pod): InboxEntry[] => { + const thread = threads[pod.id] ?? []; + + // A building pod's asks are Blocked items: each its own, in order, until + // its ticket is back in the queue. + if (podKindOf(pod) === 'building') { + return holding.has(pod.id) + ? [] + : buildingReplyItems(thread, buildings.get(pod.id) ?? null).map( + (message): InboxEntry => { + const blockedRun = (buildings.get(pod.id)?.runs ?? []).find( + (run) => run.blocked?.messageId === message.id + ); + const ticketId = + blockedRun?.id ?? message.review?.ticketId ?? undefined; + + return { + podId: pod.id, + kind: 'reply', + key: `${pod.id}/reply/${message.id}`, + messageId: message.id, + ...(ticketId === undefined ? {} : { ticketId }), + ...(blockedRun !== undefined + ? { tag: 'blocked' as const } + : message.review !== undefined + ? { tag: 'review' as const } + : {}), + }; + } + ); + } + + // The pitch to start structured planning is their turn — the dev's message + // offering it is not a second item. Starting it answers that message, and + // so does closing a checkpoint ("Anything else before you close it?" → + // they closed it). + const answeredAt = thread + .filter( + (message) => + message.wire?.startsWith('/pod-structured') === true || + message.wire?.startsWith('/checkpoint-complete') === true + ) + .at(-1)?.createdAt; + // A freeform chat: whatever its dev said last is their turn — once the + // turn is over, so a "checking…" note mid-turn doesn't pull them in. + const asked = pendingReply( + thread, + pod.mode === 'freeform' && isIdle(store.readTurn(pod.id, 'dev')) + ); + const pending = + pod.structureProposal !== undefined || + (asked !== null && + answeredAt !== undefined && + asked.createdAt < answeredAt) + ? null + : asked; + const request = requests[pod.id]; + // A request hides the pod's items while it reworks them — but not a + // question the request itself asks them (asked after it started): hiding + // that would leave the pod waiting on them with nothing in their inbox. + const reply = holding.has(pod.id) + ? null + : request === undefined + ? pending + : pending !== null && pending.createdAt > request.createdAt + ? pending + : null; + + return reply === null + ? [] + : [ + { + podId: pod.id, + kind: 'reply', + key: `${pod.id}/reply/${reply.id}`, + messageId: reply.id, + }, + ]; + }); + + // 2. Decisions and checkpoints. + const answerable = active + .filter((pod) => !reworking(pod.id)) + .flatMap((pod) => + (questions[pod.id] ?? []).filter( + (question) => + !stepsAside(question, questions[pod.id] ?? [], threads[pod.id] ?? []) + ) + ); + const questionItems = orderInbox(active, answerable, priority).map( + ({ pod, question }): InboxEntry => ({ + podId: pod.id, + kind: question.kind === 'checkpoint' ? 'checkpoint' : 'question', + key: `${pod.id}/q/${question.id}`, + qid: question.id, + }) + ); + + // A freeform pod's dev proposes the structured flow: theirs to start. + const structureItems = byPriority.flatMap((pod): InboxEntry[] => + pod.mode === 'freeform' && + pod.structureProposal !== undefined && + !holding.has(pod.id) + ? [ + { + podId: pod.id, + kind: 'structure', + key: `${pod.id}/structure/${pod.structureProposal.at}`, + }, + ] + : [] + ); + + const running = Object.fromEntries( + pods.map((pod) => [pod.id, windowsFor(pod.id)]) + ); + // 3. Stalled pods — the watchdog tried and could not get them moving. + const stalledIds = new Set( + byPriority.filter((pod) => escalated.has(pod.id)).map((pod) => pod.id) + ); + const stalledItems = byPriority + .filter((pod) => stalledIds.has(pod.id)) + .map( + (pod): InboxEntry => ({ + podId: pod.id, + kind: 'stalled', + key: `${pod.id}/stalled`, + reason: escalated.get(pod.id), + }) + ); + + // The sidebar order decides: pod by pod, each pod's "Your turn" before its + // questions, so a lower pod's "Your turn" never jumps the questions of + // the pods above it. Stalled nudges come last. + const inbox = [ + ...byPriority.flatMap((pod) => [ + ...replies.filter((item) => item.podId === pod.id), + ...questionItems.filter((item) => item.podId === pod.id), + ...structureItems.filter((item) => item.podId === pod.id), + ]), + ...stalledItems, + ]; + + const summaries: PodSummary[] = pods.map((pod) => { + const windows = running[pod.id] ?? []; + const chat = chatTimes(threads[pod.id] ?? []); + const needsYou = inbox.filter( + (item) => item.podId === pod.id && item.kind !== 'stalled' + ).length; + + return { + pod, + running: windows.length > 0, + status: statusOf(pod, needsYou, stalledIds.has(pod.id)), + // Mid-turn, whatever the status says ("Needs you" wins while an item + // waits — the dev can be working on what they asked all the same). + devBusy: + windows.includes('dev') && !isIdle(store.readTurn(pod.id, 'dev')), + needsYou, + liveForks: windows.filter((window) => window.startsWith('fork-')), + request: requests[pod.id], + chatHold: holding.has(pod.id), + ...(chat.sentAt === undefined ? {} : { chatSentAt: chat.sentAt }), + ...(chat.repliedAt === undefined + ? {} + : { chatRepliedAt: chat.repliedAt }), + ...(() => { + const building = buildings.get(pod.id); + + return building === undefined + ? {} + : { building: buildingStateOf(pod, building) }; + })(), + }; + }); + + return { pods: summaries, inbox, questions, priority }; +}; + +export const buildPodDetail = ( + store: PodStore, + podId: string, + windows: string[], + time: TimeSummary, + machine: RenderMachine +): PodDetail => ({ + pod: store.requirePod(podId), + questions: store.listQuestions(podId), + thread: withAlsoHeld( + store.readThread(podId), + podKindOf(store.requirePod(podId)) === 'building' + ? store.readBuilding(podId) + : null + ), + autonomous: store.readAutonomousDecisions(podId), + human: store.readHumanDecisions(podId), + forks: store.listForks(podId), + windows, + workflow: buildWorkflowView(store, podId, time, machine), +}); diff --git a/src/app/server/ticketSummary.ts b/src/app/server/ticketSummary.ts new file mode 100644 index 0000000..058d280 --- /dev/null +++ b/src/app/server/ticketSummary.ts @@ -0,0 +1,444 @@ +import { spawn } from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + type TicketSummary, + TicketSummarySchema, + writeJsonAtomic, +} from '../../pod-store'; +import { oneShotLaunch } from '../../pod/launch'; +import { assertTicketId } from '../../tickets/source'; +import type { SummaryState } from '../shared/building'; + +/** + * The Tickets screen's plain summary of a ticket (Today / After). A ticket a + * pod planned uses its checkpoint's name, today and after. Any other is + * written once by a one-shot, non-interactive Product manager run on the + * runner holding the project (read-only, by its settings) and stored under + * `/_summaries//.json` (the project as a hash of + * its folder), keyed by the SHA-256 of the description it was written from: + * a changed description is written again. + */ + +export const SUMMARY_TIMEOUT_MS = 2 * 60_000; + +/** How much of a failed run's stderr its log line keeps: the end says why. */ +export const STDERR_TAIL_CHARS = 400; + +const tailOf = (text: string): string => + text.trim().slice(-STDERR_TAIL_CHARS).trim(); + +export interface SummaryTicket { + id: string; + title: string; + description: string; +} + +export interface PlannedSummary { + name: string; + today: string; + after: string; + podId: string; +} + +/** One run: its exit code and what it printed. Abort it on `signal`. */ +export type SummaryRun = ( + ticket: SummaryTicket, + signal: AbortSignal +) => Promise<{ exitCode: number; stdout: string; stderr?: string }>; + +const sha256 = (text: string): string => + crypto.createHash('sha256').update(text).digest('hex'); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const text = (value: unknown): string | null => + typeof value === 'string' && value.trim() !== '' ? value.trim() : null; + +/** `text` as a JSON object, or null. */ +const objectOf = (text: string): Record | null => { + try { + const parsed: unknown = JSON.parse(text); + + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +}; + +/** Only the end of what a run printed is read: its answer is its last line. */ +const ANSWER_TAIL_CHARS = 64 * 1024; + +/** + * The top-level JSON objects in `text`, in one pass: a `{` outside any object + * opens a span, braces inside a string value don't count, and a balanced span + * is kept when it parses (skipped whole when it doesn't). Linear, so a run + * that echoes a brace-heavy description can't stall the runner. + */ +const objectsIn = (text: string): Record[] => { + const scan = { start: -1, depth: 0, inString: false, escaped: false }; + const found: Record[] = []; + + Array.from(text).forEach((char, index) => { + if (scan.inString) { + const wasEscaped = scan.escaped; + scan.escaped = !wasEscaped && char === '\\'; + scan.inString = wasEscaped || char !== '"'; + + return; + } + + if (char === '"' && scan.depth > 0) { + scan.inString = true; + } else if (char === '{') { + scan.start = scan.depth === 0 ? index : scan.start; + scan.depth += 1; + } else if (char === '}' && scan.depth > 0) { + scan.depth -= 1; + + const object = + scan.depth === 0 ? objectOf(text.slice(scan.start, index + 1)) : null; + + if (object !== null) { + found.splice(found.length, 0, object); + } + } + }); + + return found; +}; + +/** The last line of `text` that is a JSON object on its own, or null. */ +const lastObjectLine = (text: string): Record | null => + text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('{') && line.endsWith('}')) + .map(objectOf) + .filter((object): object is Record => object !== null) + .at(-1) ?? null; + +/** The last JSON object in what the run printed, or null. */ +export const answerIn = ( + stdout: string +): { name?: string; today: string; after: string } | null => { + const tail = stdout.slice(-ANSWER_TAIL_CHARS); + const found = lastObjectLine(tail) ?? objectsIn(tail).at(-1); + const today = text(found?.today); + const after = text(found?.after); + + if (today === null || after === null) { + return null; + } + + const name = text(found?.name); + + return name === null ? { today, after } : { name, today, after }; +}; + +/** Where a ticket's summary comes from, before anything is run. */ +export type SummarySource = + | { kind: 'planned' | 'stored'; name: string; today: string; after: string } + /** No checkpoint planned it, and nothing stored matches its description. */ + | { kind: 'needs-summary'; hash: string }; + +/** + * A planned ticket uses its checkpoint's name, today and after. An unplanned + * one uses what was stored for it while its description (by SHA-256) is the + * one it was written from; otherwise it needs a summary written. + */ +export const summarySource = ( + ticket: SummaryTicket, + planned: PlannedSummary | null, + stored: TicketSummary | null +): SummarySource => { + if (planned !== null) { + return { + kind: 'planned', + name: planned.name, + today: planned.today, + after: planned.after, + }; + } + + const hash = sha256(ticket.description); + + return stored !== null && stored.sourceHash === hash + ? { + kind: 'stored', + name: stored.name, + today: stored.today, + after: stored.after, + } + : { kind: 'needs-summary', hash }; +}; + +export const createTicketSummaries = (deps: { + /** The pods root: summaries live in its `_summaries/`. */ + root: string; + /** + * The project's folder. A pods root can be shared by several projects, + * each with its own ticket "3", so each project's summaries are kept apart. + */ + project: string; + run: SummaryRun; + timeoutMs?: number; + /** Why a summary couldn't be written: the screen only says "No summary yet." */ + log: (line: string) => void; + /** For tests: how a summary is stored. */ + write?: (file: string, summary: TicketSummary) => void; +}) => { + const writeSummary = deps.write ?? writeJsonAtomic; + const timeoutMs = deps.timeoutMs ?? SUMMARY_TIMEOUT_MS; + /** Ticket id → the description hash its run is writing from. */ + const running = new Map(); + /** Ticket id → how to stop the run writing its summary. */ + const controllers = new Map(); + /** + * Ticket id → the description hash its last run failed on: "No summary + * yet." for that description only, so a changed one gets a fresh try. + */ + const failed = new Map(); + + const projectDir = sha256(deps.project).slice(0, 16); + + const file = (ticketId: string): string => + path.join( + deps.root, + '_summaries', + projectDir, + `${assertTicketId(ticketId)}.json` + ); + + const stored = (ticketId: string): TicketSummary | null => { + try { + const parsed = TicketSummarySchema.safeParse( + JSON.parse(fs.readFileSync(file(ticketId), 'utf8')) + ); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } + }; + + const write = (ticket: SummaryTicket, hash: string): void => { + const controller = new AbortController(); + running.set(ticket.id, hash); + // A run for an older description is stopped, not left to its timeout: + // set after `running`, so its finish sees it superseded and stores nothing. + controllers.get(ticket.id)?.abort(); + controllers.set(ticket.id, controller); + const timer = setTimeout(() => controller.abort(), timeoutMs); + // A run that hangs never keeps the runner (or a test) from exiting. + timer.unref?.(); + const finish = (summary: TicketSummary | null, why?: string): void => { + clearTimeout(timer); + + if (controllers.get(ticket.id) === controller) { + controllers.delete(ticket.id); + } + + if (running.get(ticket.id) !== hash) { + return; + } + + running.delete(ticket.id); + const fail = (reason: string): void => { + failed.set(ticket.id, hash); + deps.log(`No summary for ${ticket.id}: ${reason}`); + }; + + if (summary === null) { + fail(why ?? 'unknown'); + + return; + } + + // Run inside the run's promise handler: a throw here (a full disk, a + // bad answer) would be an unhandled rejection that ends the runner. + try { + writeSummary(file(ticket.id), TicketSummarySchema.parse(summary)); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + }; + + controller.signal.addEventListener('abort', () => + finish(null, `it took longer than ${timeoutMs / 1000} seconds`) + ); + deps.run(ticket, controller.signal).then( + (result) => { + if (controller.signal.aborted) { + return; + } + + // Read inside a try: a throw here would be an unhandled rejection, + // and the ticket would stay "Writing a summary…" until the abort. + const read = (() => { + try { + return { + answer: result.exitCode === 0 ? answerIn(result.stdout) : null, + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } + })(); + + if ('error' in read) { + finish(null, read.error); + + return; + } + + const { answer } = read; + const stderr = tailOf(result.stderr ?? ''); + + finish( + answer === null + ? null + : { + ticketId: ticket.id, + name: answer.name ?? ticket.title, + today: answer.today, + after: answer.after, + sourceHash: hash, + by: 'product-manager', + }, + result.exitCode === 0 + ? 'it returned no today or after' + : `it exited ${result.exitCode}${ + stderr === '' ? '' : `: ${stderr}` + }` + ); + }, + (error: unknown) => finish(null, String(error)) + ); + }; + + const stateOf = ( + ticket: SummaryTicket, + planned: PlannedSummary | null, + /** The page was just opened (not one of its refreshes). */ + options: { opened?: boolean } = {} + ): SummaryState => { + const source = summarySource(ticket, planned, stored(ticket.id)); + + if (source.kind !== 'needs-summary') { + return { + state: 'ready', + name: source.name, + today: source.today, + after: source.after, + }; + } + + // Needs a summary: one run writes it, however many open the page. + const { hash } = source; + + if (running.get(ticket.id) === hash) { + return { state: 'writing' }; + } + + // The last try failed: "No summary yet." while the page refreshes, and + // one new try only when it's opened again. + if (failed.get(ticket.id) === hash && options.opened !== true) { + return { state: 'none' }; + } + + failed.delete(ticket.id); + + write(ticket, hash); + + return { state: 'writing' }; + }; + + return { stateOf, file }; +}; + +/** + * The real run: `claude -p` in the project folder, with the Product + * manager's instructions and its read-only settings file. + */ +/** What the summary run needs of a started process. */ +export type SpawnLike = ( + command: string, + args: string[], + options: { + cwd: string; + env: NodeJS.ProcessEnv; + signal: AbortSignal; + stdio: ['ignore', 'pipe', 'pipe']; + } +) => { + stdout: { on: (event: 'data', listener: (chunk: Buffer) => void) => unknown }; + stderr: { on: (event: 'data', listener: (chunk: Buffer) => void) => unknown }; + on: (event: string, listener: (value?: unknown) => void) => unknown; +}; + +export const claudeSummaryRun = + (options: { + cwd: string; + instructionsFile: string; + settingsFile: string; + /** For tests: how the process is started. */ + spawn?: SpawnLike; + }): SummaryRun => + (ticket, signal) => + new Promise((resolve) => { + const prompt = [ + `Read ${options.instructionsFile}: you are the Product manager.`, + `Write the plain summary of ticket ${ticket.id} ("${ticket.title}") for the Tickets screen: what a person sees today, and what changes once it ships.`, + 'Answer with one line of JSON and nothing else: {"today": "…", "after": "…"}.', + '', + 'The ticket:', + ticket.description, + ].join('\n'); + // Started as pod agents are, through the pod launcher. + const launch = oneShotLaunch({ + prompt, + settingsFile: options.settingsFile, + }); + const child = (options.spawn ?? spawn)(launch.command, launch.args, { + cwd: options.cwd, + env: launch.env, + signal, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const out = { text: '', err: '' }; + // Only the tail its answer is read from is kept. + child.stdout.on('data', (chunk: Buffer) => { + out.text = (out.text + chunk.toString('utf8')).slice( + -ANSWER_TAIL_CHARS + ); + }); + // Only its end is kept: that's where a failed run says why. + child.stderr.on('data', (chunk: Buffer) => { + out.err = (out.err + chunk.toString('utf8')).slice( + -STDERR_TAIL_CHARS * 2 + ); + }); + child.on('error', (error) => + resolve({ + exitCode: 1, + stdout: out.text, + stderr: tailOf( + `${out.err}\ncouldn't start: ${ + error instanceof Error ? error.message : String(error) + }` + ), + }) + ); + child.on('close', (code) => + resolve({ + exitCode: typeof code === 'number' ? code : 1, + stdout: out.text, + stderr: tailOf(out.err), + }) + ); + }); diff --git a/src/app/server/timeLog.ts b/src/app/server/timeLog.ts new file mode 100644 index 0000000..a3f3531 --- /dev/null +++ b/src/app/server/timeLog.ts @@ -0,0 +1,244 @@ +import * as fs from 'fs'; + +import { ID_PATTERN } from '../../pod-store'; +import type { PodTime, TimeSummary } from '../shared/types'; + +/** + * How long the owner actively spends planning — counted by the page (scrolling, + * typing, listening; never just having it open) and reported here in small + * batches. Kept per day, per pod, per item and per version of a card in + * `/_time.json`. + */ + +interface TimeLog { + days: Record; + pods: Record; +} + +export interface TimeEntry { + podId?: string; + qid?: string; + /** The version of the card on screen (only while the Plan rail shows it). */ + version?: number; + /** The card inside it that had their attention (see cardHistory.ts). */ + card?: string; + ms: number; +} + +const plus = ( + totals: Record | undefined, + key: string, + ms: number +): Record => ({ ...totals, [key]: (totals?.[key] ?? 0) + ms }); + +/** A card key is a title; anything longer than this isn't one. */ +const MAX_CARD_KEY = 300; + +/** + * A batch can't credit more than this, all its entries together (the page + * reports every ~15 s). + */ +const MAX_BATCH_MS = 5 * 60_000; + +/** A pod or item id: what the store names them, never a path or a guess. */ +const MAX_ID = 100; +const isId = (value: string | undefined): boolean => + value === undefined || (value.length <= MAX_ID && ID_PATTERN.test(value)); + +/** Told where an unreadable log was moved; the count starts again. */ +export type OnUnreadable = (movedTo: string, error: string) => void; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const numbers = (value: unknown): Record => + isRecord(value) + ? Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number' + ) + ) + : {}; + +const EMPTY: TimeLog = { days: {}, pods: {} }; + +/** Throws on anything that isn't a time log (so it's moved aside, not lost). */ +const parseLog = (parsed: unknown): TimeLog => { + if (!isRecord(parsed)) { + throw new Error('it is not a time log'); + } + + const pods = isRecord(parsed.pods) ? parsed.pods : {}; + + return { + days: numbers(parsed.days), + pods: Object.fromEntries( + Object.entries(pods).map(([podId, pod]) => [ + podId, + { + totalMs: + isRecord(pod) && typeof pod.totalMs === 'number' ? pod.totalMs : 0, + items: isRecord(pod) ? numbers(pod.items) : {}, + cards: + isRecord(pod) && isRecord(pod.cards) + ? Object.fromEntries( + Object.entries(pod.cards).map(([qid, byCard]) => [ + qid, + isRecord(byCard) + ? Object.fromEntries( + Object.entries(byCard).map(([card, byVersion]) => [ + card, + numbers(byVersion), + ]) + ) + : {}, + ]) + ) + : {}, + versions: + isRecord(pod) && isRecord(pod.versions) + ? Object.fromEntries( + Object.entries(pod.versions).map(([qid, byVersion]) => [ + qid, + numbers(byVersion), + ]) + ) + : {}, + }, + ]) + ), + }; +}; + +/** + * The log, or empty when there's none yet. One that can't be read is moved + * aside (kept, not written over) and `onUnreadable` told where. + */ +const read = (file: string, onUnreadable?: OnUnreadable): TimeLog => { + const text = (() => { + try { + return fs.readFileSync(file, 'utf8'); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + + throw error; + } + })(); + + if (text === null) { + return EMPTY; + } + + try { + return parseLog(JSON.parse(text)); + } catch (error) { + const movedTo = `${file}.unreadable-${Date.now()}`; + fs.renameSync(file, movedTo); + onUnreadable?.( + movedTo, + error instanceof Error ? error.message : String(error) + ); + + return EMPTY; + } +}; + +/** Today in the server's (= the owner's machine's) local time. */ +export const localDay = (now: Date = new Date()): string => + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String( + now.getDate() + ).padStart(2, '0')}`; + +/** The entries a batch may credit: real ids, and 5 minutes in all. */ +const creditable = (entries: TimeEntry[]): TimeEntry[] => + entries + .filter((entry) => entry.ms > 0 && isId(entry.podId) && isId(entry.qid)) + .map((entry) => + entry.card !== undefined && entry.card.length > MAX_CARD_KEY + ? { ...entry, card: undefined } + : entry + ) + .reduce<{ left: number; kept: TimeEntry[] }>( + ({ left, kept }, entry) => { + const ms = Math.min(left, entry.ms); + + return ms > 0 + ? { left: left - ms, kept: [...kept, { ...entry, ms }] } + : { left, kept }; + }, + { left: MAX_BATCH_MS, kept: [] } + ).kept; + +export const addTime = ( + file: string, + entries: TimeEntry[], + day: string = localDay(), + onUnreadable?: OnUnreadable +): void => { + const valid = creditable(entries); + + if (valid.length === 0) { + return; + } + + const log = valid.reduce((current, entry) => { + const days = plus(current.days, day, entry.ms); + + if (entry.podId === undefined) { + return { ...current, days }; + } + + const pod = current.pods[entry.podId] ?? { totalMs: 0, items: {} }; + const { qid, version, card, ms } = entry; + const shown = version ?? 1; + + return { + days, + pods: { + ...current.pods, + [entry.podId]: { + totalMs: pod.totalMs + ms, + items: qid === undefined ? pod.items : plus(pod.items, qid, ms), + versions: + qid === undefined || version === undefined + ? pod.versions ?? {} + : { + ...pod.versions, + [qid]: plus(pod.versions?.[qid], String(version), ms), + }, + cards: + qid === undefined || card === undefined + ? pod.cards ?? {} + : { + ...pod.cards, + [qid]: { + ...pod.cards?.[qid], + [card]: plus(pod.cards?.[qid]?.[card], String(shown), ms), + }, + }, + }, + }, + }; + }, read(file, onUnreadable)); + + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(log, null, 2)}\n`); + fs.renameSync(tmp, file); +}; + +export const timeSummary = ( + file: string, + day: string = localDay(), + onUnreadable?: OnUnreadable +): TimeSummary => { + const log = read(file, onUnreadable); + + return { day, todayMs: log.days[day] ?? 0, days: log.days, pods: log.pods }; +}; diff --git a/src/app/server/tts/__tests__/config.test.ts b/src/app/server/tts/__tests__/config.test.ts new file mode 100644 index 0000000..f87d894 --- /dev/null +++ b/src/app/server/tts/__tests__/config.test.ts @@ -0,0 +1,106 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { loadTtsConfig } from '../config'; +import { kokoroInstalled } from '../nodePath'; + +describe('the audio settings', () => { + const saved = { ...process.env }; + const fixture: { home: string } = { home: '' }; + + beforeEach(() => { + fixture.home = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-tts-')); + process.env.LAMBDA_HOME = fixture.home; + delete process.env.ELEVENLABS_TTS_API_KEY; + delete process.env.ELEVENLABS_API_KEY; + delete process.env.POD_TTS_PROVIDER; + // Set in every window the Mac app opens; it turns Kokoro on. + delete process.env.LAMBDA_APP_BUNDLE; + }); + + afterEach(() => { + process.env = { ...saved }; + fs.rmSync(fixture.home, { recursive: true, force: true }); + }); + + it('treats an empty key as no key: audio stays off', () => { + process.env.ELEVENLABS_TTS_API_KEY = ''; + process.env.ELEVENLABS_API_KEY = ''; + + expect(loadTtsConfig(fixture.home)).toMatchObject({ + provider: 'off', + apiKey: undefined, + }); + }); + + it('says so when config.json is broken, instead of turning audio off', () => { + fs.writeFileSync(path.join(fixture.home, 'config.json'), '{"owner": 7}'); + + expect(() => loadTtsConfig(fixture.home)).toThrow( + path.join(fixture.home, 'config.json') + ); + }); + + it('is off with nothing set', () => { + expect(loadTtsConfig(fixture.home).provider).toBe('off'); + }); + + it('turns ElevenLabs on with a key alone', () => { + process.env.ELEVENLABS_API_KEY = 'xi-pawnee'; + + expect(loadTtsConfig(fixture.home)).toMatchObject({ + provider: 'elevenlabs', + apiKey: 'xi-pawnee', + }); + }); + + it('reads the key from ~/.lambda/config.json', () => { + fs.writeFileSync( + path.join(fixture.home, 'config.json'), + JSON.stringify({ tokens: { elevenlabs: 'xi-from-config' } }) + ); + + expect(loadTtsConfig(fixture.home)).toMatchObject({ + provider: 'elevenlabs', + apiKey: 'xi-from-config', + }); + }); + + it('lets the switch file turn it off over the environment and a key', () => { + process.env.ELEVENLABS_API_KEY = 'xi-pawnee'; + process.env.POD_TTS_PROVIDER = 'elevenlabs'; + fs.writeFileSync( + path.join(fixture.home, '_tts.json'), + JSON.stringify({ provider: 'off' }) + ); + + expect(loadTtsConfig(fixture.home).provider).toBe('off'); + }); + + it('reads Kokoro as off where kokoro-js isn’t installed', () => { + process.env.ELEVENLABS_API_KEY = 'xi-pawnee'; + process.env.POD_TTS_PROVIDER = 'kokoro'; + + expect(loadTtsConfig(fixture.home, () => false).provider).toBe('off'); + }); + + it('runs Kokoro when the switch file names it and kokoro-js is installed, over a key', () => { + process.env.ELEVENLABS_API_KEY = 'xi-pawnee'; + fs.writeFileSync( + path.join(fixture.home, '_tts.json'), + JSON.stringify({ provider: 'kokoro' }) + ); + + expect(loadTtsConfig(fixture.home, () => true).provider).toBe('kokoro'); + }); + + it('never turns Kokoro on by itself outside the app, installed or not', () => { + expect(loadTtsConfig(fixture.home, () => true).provider).toBe('off'); + }); + + it('finds kokoro-js in a checkout of Lambda', () => { + expect(kokoroInstalled()).toBe(true); + expect(kokoroInstalled({ NODE_PATH: '' })).toBe(true); + }); +}); diff --git a/src/app/server/tts/__tests__/kokoro.test.ts b/src/app/server/tts/__tests__/kokoro.test.ts new file mode 100644 index 0000000..ce8313f --- /dev/null +++ b/src/app/server/tts/__tests__/kokoro.test.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { modelsDir } from '../../../../desktop/appPaths'; +import { loadTtsConfig, providerOf } from '../config'; +import { encodeClip, modelCacheDir, workerEnv, workerPath } from '../kokoro'; +import { WAV_TYPE, audioTypeOf, isWav } from '../wav'; + +describe('Kokoro in the Mac app', () => { + it('returns WAV when ffmpeg is not installed', async () => { + const samples = new Float32Array([0, 0.5, -0.5, 1, -1]); + + const clip = await encodeClip(samples, 'no-such-ffmpeg-on-this-machine'); + + expect(clip.type).toBe(WAV_TYPE); + expect(isWav(clip.audio)).toBe(true); + expect(audioTypeOf(clip.audio)).toBe('audio/wav'); + // 44-byte header, then 16-bit samples. + expect(clip.audio.length).toBe(44 + samples.length * 2); + expect(clip.audio.readInt16LE(44 + 3 * 2)).toBe(0x7fff); + }); + + it('loads the worker from app.asar.unpacked inside the bundle', () => { + expect( + workerPath('/Applications/Lambda.app/Contents/Resources/app.asar/dist') + ).toBe( + '/Applications/Lambda.app/Contents/Resources/app.asar.unpacked/dist/kokoroWorker.js' + ); + expect(workerPath('/usr/local/lib/node_modules/lambda-cli/dist')).toBe( + '/usr/local/lib/node_modules/lambda-cli/dist/kokoroWorker.js' + ); + }); + + it("caches the model in the app's folder only inside the app", () => { + const inApp = { LAMBDA_APP_BUNDLE: '/Applications/Lambda.app' }; + + expect(modelCacheDir(inApp, '/home/leslie')).toBe( + modelsDir('/home/leslie') + ); + expect(modelsDir('/home/leslie')).toBe( + '/home/leslie/Library/Application Support/Lambda/models' + ); + expect(modelCacheDir({}, '/home/leslie')).toBeNull(); + expect(workerEnv(inApp)).toMatchObject({ + KOKORO_CACHE_DIR: modelsDir(), + NODE_PATH: + '/Applications/Lambda.app/Contents/Resources/kokoro/node_modules', + }); + expect(workerEnv({ PATH: '/bin' })).toEqual({ PATH: '/bin' }); + }); + + it('is the free default in the app, and off elsewhere unless a key is set', () => { + expect(providerOf(undefined, undefined, true)).toBe('kokoro'); + expect(providerOf(undefined, undefined, false)).toBe('off'); + expect(providerOf('kokoro', undefined, false)).toBe('off'); + expect(providerOf(undefined, 'xi-key', true)).toBe('elevenlabs'); + expect(providerOf('off', undefined, true)).toBe('off'); + }); +}); + +describe('the ElevenLabs key', () => { + const saved = { ...process.env }; + + afterEach(() => { + process.env = { ...saved }; + }); + + it('never comes from a repo .env, only tokens.elevenlabs', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-tts-env-')); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'pawnee-project-')); + fs.writeFileSync( + path.join(project, '.env'), + 'ELEVENLABS_API_KEY=xi-from-dotenv\n' + ); + process.env.LAMBDA_HOME = home; + delete process.env.ELEVENLABS_API_KEY; + delete process.env.ELEVENLABS_TTS_API_KEY; + delete process.env.POD_TTS_PROVIDER; + delete process.env.LAMBDA_APP_BUNDLE; + const cwd = process.cwd(); + process.chdir(project); + + try { + expect(loadTtsConfig(project)).toMatchObject({ + apiKey: undefined, + provider: 'off', + }); + } finally { + process.chdir(cwd); + [home, project].forEach((dir) => + fs.rmSync(dir, { recursive: true, force: true }) + ); + } + }); +}); diff --git a/src/app/server/tts/__tests__/nodePath.test.ts b/src/app/server/tts/__tests__/nodePath.test.ts new file mode 100644 index 0000000..d0f02f3 --- /dev/null +++ b/src/app/server/tts/__tests__/nodePath.test.ts @@ -0,0 +1,54 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { requireFromNodePath } from '../nodePath'; + +/** A Kokoro folder like the app's: `/node_modules/`, outside any repo. */ +const appKokoroFolder = (name: string): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lambda-kokoro-')); + const pkg = path.join(dir, 'node_modules', name); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ name, main: 'index.js' }) + ); + fs.writeFileSync( + path.join(pkg, 'index.js'), + 'module.exports = { KokoroTTS: "fake" };\n' + ); + + return path.join(dir, 'node_modules'); +}; + +describe("Kokoro's libraries in the Mac app", () => { + it('load from the folder NODE_PATH names', () => { + const nodePath = appKokoroFolder('pawnee-kokoro'); + + expect( + requireFromNodePath('pawnee-kokoro', { NODE_PATH: nodePath }) + ).toEqual({ + KokoroTTS: 'fake', + }); + }); + + it("aren't found without it", () => { + appKokoroFolder('pawnee-kokoro-elsewhere'); + + expect(() => requireFromNodePath('pawnee-kokoro-elsewhere', {})).toThrow( + /Cannot (find|resolve) module/ + ); + }); + + it('are never loaded with a native import(), which ignores NODE_PATH', () => { + const source = fs + .readFileSync(path.join(__dirname, '..', 'kokoroWorker.ts'), 'utf8') + // Comments and type positions don't load anything. + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, '') + .replace(/typeof import\(/g, ''); + + expect(source).not.toMatch(/\bimport\(/); + expect(source).toContain("requireFromNodePath('kokoro-js')"); + }); +}); diff --git a/src/app/server/tts/budget.ts b/src/app/server/tts/budget.ts new file mode 100644 index 0000000..f9ebaa1 --- /dev/null +++ b/src/app/server/tts/budget.ts @@ -0,0 +1,158 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The monthly ElevenLabs cap. Every render and every stream asks first; past + * the cap there is no audio until the 1st. Usage is + * an ESTIMATE (characters × configured price) kept in `~/.lambda/tts-usage.json`, one per computer. + */ +export interface Usage { + month: string; + characters: number; + usd: number; +} + +export interface BudgetStatus extends Usage { + capUsd: number; + overBudget: boolean; + /** Why nothing can be spent besides the cap (the file can't be read). */ + problem?: string; +} + +/** The usage file exists but can't be read: spending stops (fails closed). */ +class UnreadableBudget extends Error {} + +const monthOf = (now: Date): string => now.toISOString().slice(0, 7); + +export const createBudget = (args: { + file: string; + usdPer1kChars: number; + capUsd: number; + now?: () => Date; +}) => { + const now = args.now ?? (() => new Date()); + + const text = (): string | null => { + try { + return fs.readFileSync(args.file, 'utf8'); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + + throw error; + } + }; + + /** + * This month's spend. No file is a fresh start; one that can't be read + * stops all spending (read as $0, it would lift the cap). + */ + const read = (): Usage => { + const month = monthOf(now()); + const raw = text(); + + if (raw === null) { + return { month, characters: 0, usd: 0 }; + } + + const parsed: unknown = (() => { + try { + return JSON.parse(raw); + } catch { + return null; + } + })(); + + if ( + typeof parsed !== 'object' || + parsed === null || + !('month' in parsed) || + typeof parsed.month !== 'string' || + !('characters' in parsed) || + typeof parsed.characters !== 'number' || + !('usd' in parsed) || + typeof parsed.usd !== 'number' + ) { + throw new UnreadableBudget( + `The audio budget in ${args.file} can't be read, so nothing is spent until it's fixed or deleted.` + ); + } + + return parsed.month === month + ? { month, characters: parsed.characters, usd: parsed.usd } + : { month, characters: 0, usd: 0 }; + }; + + const costOf = (characters: number): number => + (characters / 1000) * args.usdPer1kChars; + + /** This month's spend, or the reason there's none to go on. */ + const usage = (): Usage | UnreadableBudget => { + try { + return read(); + } catch (error) { + if (error instanceof UnreadableBudget) { + return error; + } + + throw error; + } + }; + + /** Would spending this many characters stay within the cap? */ + const allows = (characters: number): boolean => { + const spent = usage(); + + return ( + !(spent instanceof UnreadableBudget) && + spent.usd + costOf(characters) <= args.capUsd + ); + }; + + const record = (characters: number): Usage => { + const current = read(); + const next = { + month: current.month, + characters: current.characters + characters, + usd: current.usd + costOf(characters), + }; + // Whole or not at all: a half-written file would stop all audio. + fs.mkdirSync(path.dirname(args.file), { recursive: true }); + const tmp = `${args.file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`); + fs.renameSync(tmp, args.file); + + return next; + }; + + const status = (): BudgetStatus => { + const spent = usage(); + + if (spent instanceof UnreadableBudget) { + return { + month: monthOf(now()), + characters: 0, + usd: 0, + capUsd: args.capUsd, + overBudget: true, + problem: spent.message, + }; + } + + return { + ...spent, + capUsd: args.capUsd, + overBudget: spent.usd >= args.capUsd, + }; + }; + + return { allows, record, status }; +}; + +export type Budget = ReturnType; diff --git a/src/app/server/tts/config.ts b/src/app/server/tts/config.ts new file mode 100644 index 0000000..b1a6d18 --- /dev/null +++ b/src/app/server/tts/config.ts @@ -0,0 +1,142 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { loadMachineConfig } from '../../../config/machineConfig'; +import { appBundle } from '../../../desktop/appPaths'; +import { kokoroInstalled } from './nodePath'; + +/** + * The inbox's audio settings. Lambda reads its ElevenLabs key from + * `~/.lambda/config.json` (`tokens.elevenlabs`) or the environment — never a + * file in the project (never a repo `.env`). With no key and no provider + * set, audio is off, except in the Mac app, which packages Kokoro (free, on + * this machine). Named (`{"provider": "kokoro"}` in the switch file), Kokoro + * also runs from a checkout that installed kokoro-js. + */ +export interface TtsConfig { + /** + * Who renders the audio. `elevenlabs`: the paid API. `off`: no rendering; + * the page reads items with the browser's own voice. `kokoro`: local, in + * the Mac app or wherever kokoro-js is installed (the switch file + * `/_tts.json`, `{"provider": "off"}`, wins over the + * environment). + */ + provider: 'kokoro' | 'elevenlabs' | 'off'; + kokoroVoice: string; + /** fp32 is both faster and more accurate than q8 on Apple CPUs (measured). */ + kokoroDtype: 'fp32' | 'fp16' | 'q8' | 'q4'; + /** Pieces rendered at once (POD_TTS_KOKORO_WORKERS). */ + kokoroWorkers: number; + apiKey: string | undefined; + voiceId: string; + modelId: string; + /** Estimated cost, for the monthly cap. Errs high. */ + usdPer1kChars: number; + monthlyCapUsd: number; + /** Pre-render only the next N inbox items — the rest may never be reached. */ + prefetch: number; +} + +/** "Rachel" — a clear ElevenLabs stock voice that holds up at 2.5×. */ +const DEFAULT_VOICE = '21m00Tcm4TlvDq8ikWAM'; + +const numberFrom = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + + return value !== undefined && Number.isFinite(parsed) ? parsed : fallback; +}; + +const KOKORO_DTYPES = ['fp32', 'fp16', 'q8', 'q4'] as const; + +const kokoroDtype = (value: string | undefined): TtsConfig['kokoroDtype'] => + KOKORO_DTYPES.find((dtype) => dtype === value) ?? 'fp32'; + +/** `/_tts.json` → its provider, if it names one. */ +const switchedProvider = (podsRoot: string): string | undefined => { + const file = path.join(podsRoot, '_tts.json'); + + if (!fs.existsSync(file)) { + return undefined; + } + + const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + + return typeof parsed === 'object' && + parsed !== null && + 'provider' in parsed && + typeof parsed.provider === 'string' + ? parsed.provider + : undefined; +}; + +/** + * A provider named (switch file, then POD_TTS_PROVIDER) wins; else + * ElevenLabs when there's a key. Kokoro is the free default only in the Mac + * app, which packages it. Elsewhere it runs when named and kokoro-js is + * installed (a checkout of Lambda has it), and reads as off when it isn't: + * never on by itself, since it downloads a model and works the CPU. + */ +export const providerOf = ( + named: string | undefined, + apiKey: string | undefined, + inApp: boolean, + installed = false +): TtsConfig['provider'] => { + if (named === 'elevenlabs') { + return 'elevenlabs'; + } + + if (named === 'kokoro') { + return inApp || installed ? 'kokoro' : 'off'; + } + + if (named !== undefined) { + return 'off'; + } + + if (apiKey !== undefined) { + return 'elevenlabs'; + } + + return inApp ? 'kokoro' : 'off'; +}; + +export const loadTtsConfig = ( + podsRoot: string, + /** For tests: whether kokoro-js can be loaded here. */ + installed: () => boolean = kokoroInstalled +): TtsConfig => { + // An empty value counts as unset. A broken config.json throws: audio + // silently off would hide it. + const apiKey = + process.env.ELEVENLABS_TTS_API_KEY || + process.env.ELEVENLABS_API_KEY || + loadMachineConfig()?.tokens?.elevenlabs || + undefined; + + return { + provider: providerOf( + switchedProvider(podsRoot) ?? process.env.POD_TTS_PROVIDER, + apiKey, + appBundle() !== null, + installed() + ), + kokoroVoice: process.env.POD_TTS_KOKORO_VOICE ?? 'af_heart', + kokoroDtype: kokoroDtype(process.env.POD_TTS_KOKORO_DTYPE), + kokoroWorkers: Math.max( + 1, + Math.floor(numberFrom(process.env.POD_TTS_KOKORO_WORKERS, 1)) + ), + apiKey, + voiceId: process.env.POD_TTS_VOICE_ID ?? DEFAULT_VOICE, + modelId: process.env.POD_TTS_MODEL_ID ?? 'eleven_flash_v2_5', + usdPer1kChars: numberFrom(process.env.POD_TTS_USD_PER_1K_CHARS, 0.11), + // No app-side cap by default: the ElevenLabs account's own credit limit + // is the limit. Set POD_TTS_MONTHLY_CAP_USD to add one. + monthlyCapUsd: numberFrom( + process.env.POD_TTS_MONTHLY_CAP_USD, + Number.POSITIVE_INFINITY + ), + prefetch: numberFrom(process.env.POD_TTS_PREFETCH, 3), + }; +}; diff --git a/src/app/server/tts/elevenlabs.ts b/src/app/server/tts/elevenlabs.ts new file mode 100644 index 0000000..171adb8 --- /dev/null +++ b/src/app/server/tts/elevenlabs.ts @@ -0,0 +1,109 @@ +import { type TtsConfig } from './config'; + +const API = 'https://api.elevenlabs.io/v1/text-to-speech'; + +export interface Alignment { + /** Start time (s, at 1×) of each character of the input text. */ + starts: number[]; + ends: number[]; +} + +export interface Rendered { + /** MP3 bytes. */ + audio: Buffer; + alignment: Alignment; + /** Length at 1× when known directly (Kokoro); else the alignment's end. */ + duration?: number; +} + +const numbers = (value: unknown): number[] => + Array.isArray(value) + ? value.filter((item): item is number => typeof item === 'number') + : []; + +/** + * Render the whole item at 1× WITH per-character timestamps — which is how + * the section dots land at exact times. Speed is the player's job (pitch-held + * playbackRate), so one file serves every speed and the slider never re-bills. + */ +export const renderWithTimestamps = async ( + config: TtsConfig, + text: string +): Promise => { + if (config.apiKey === undefined) { + throw new Error('No ELEVENLABS_API_KEY'); + } + + const response = await fetch( + `${API}/${config.voiceId}/with-timestamps?output_format=mp3_44100_128`, + { + method: 'POST', + headers: { + 'xi-api-key': config.apiKey, + 'content-type': 'application/json', + }, + body: JSON.stringify({ text, model_id: config.modelId }), + } + ); + + if (!response.ok) { + throw new Error( + `ElevenLabs ${response.status}: ${(await response.text()).slice(0, 300)}` + ); + } + + const body: unknown = await response.json(); + + if (typeof body !== 'object' || body === null || !('audio_base64' in body)) { + throw new Error('ElevenLabs returned no audio'); + } + + const alignment = + 'alignment' in body && + typeof body.alignment === 'object' && + body.alignment !== null + ? body.alignment + : {}; + + return { + audio: Buffer.from(String(body.audio_base64), 'base64'), + alignment: { + starts: + 'character_start_times_seconds' in alignment + ? numbers(alignment.character_start_times_seconds) + : [], + ends: + 'character_end_times_seconds' in alignment + ? numbers(alignment.character_end_times_seconds) + : [], + }, + }; +}; + +/** Low-latency streaming (conversation replies). The caller pipes the body. */ +export const streamSpeech = async ( + config: TtsConfig, + text: string +): Promise => { + if (config.apiKey === undefined) { + throw new Error('No ELEVENLABS_API_KEY'); + } + + const response = await fetch( + `${API}/${config.voiceId}/stream?output_format=mp3_44100_128&optimize_streaming_latency=3`, + { + method: 'POST', + headers: { + 'xi-api-key': config.apiKey, + 'content-type': 'application/json', + }, + body: JSON.stringify({ text, model_id: config.modelId }), + } + ); + + if (!response.ok || response.body === null) { + throw new Error(`ElevenLabs stream ${response.status}`); + } + + return response; +}; diff --git a/src/app/server/tts/kokoro.ts b/src/app/server/tts/kokoro.ts new file mode 100644 index 0000000..fb88a46 --- /dev/null +++ b/src/app/server/tts/kokoro.ts @@ -0,0 +1,451 @@ +import { execFileSync, fork, spawn } from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; + +import { + appBundle, + kokoroModulesDir, + modelsDir, +} from '../../../desktop/appPaths'; +import type { TtsConfig } from './config'; +import type { Rendered } from './elevenlabs'; +import { type Priority, createPieceQueue } from './pieceQueue'; +import { MP3_TYPE, WAV_TYPE, encodeWav } from './wav'; + +/** + * Kokoro-82M, on this machine: free, no key, several times faster than real + * time on a desktop CPU (fp32). The model (~330 MB, cached by + * transformers.js after the first download) loads once and stays in memory + * (~1–1.5 GB while rendering). Renders run one at a time — CPU work on a box + * that also runs the dev environment — and are encoded to MP3 with ffmpeg so + * clips stay small over Tailscale. + * + * The model runs in its OWN process (kokoroWorker.ts): in the server it + * would block the whole process, and the inbox page would stop loading + * whenever the box was busy. + */ + +/** + * Kokoro reads at most 512 phoneme tokens per call and SILENTLY drops the rest + * (its tokenizer truncates), so a long section was cut off mid-way. Each clip + * is rendered in pieces well under that — whole sentences packed together, + * an over-long sentence broken at a comma (or, failing that, a space) — and + * the pieces are joined. 300 characters of English is ~300 tokens. + */ +export const MAX_CHUNK_CHARS = 300; + +/** Split `text` at `pattern` (kept on the left piece), then pack pieces up to `max`. */ +const pack = (text: string, pattern: RegExp, max: number): string[] => + text + .split(pattern) + .map((piece) => piece.trim()) + .filter((piece) => piece !== '') + .reduce((chunks, piece) => { + const last = chunks.at(-1); + + if (last !== undefined && last.length + 1 + piece.length <= max) { + return [...chunks.slice(0, -1), `${last} ${piece}`]; + } + + return [...chunks, piece]; + }, []); + +export const chunkForKokoro = ( + text: string, + max: number = MAX_CHUNK_CHARS +): string[] => + pack(text, /(?<=[.!?…])\s+/, max).flatMap((sentence) => + sentence.length <= max + ? [sentence] + : pack(sentence, /(?<=[,;:—])\s+/, max).flatMap((clause) => + clause.length <= max ? [clause] : pack(clause, /\s+/, max) + ) + ); + +const SAMPLE_RATE = 24_000; +/** A breath between pieces, so a joined sentence boundary doesn't run on. */ +const GAP = new Float32Array(Math.round(SAMPLE_RATE * 0.12)); + +const join = (parts: Float32Array[]): Float32Array => { + const withGaps = parts.flatMap((part, index) => + index === 0 ? [part] : [GAP, part] + ); + const out = new Float32Array( + withGaps.reduce((sum, part) => sum + part.length, 0) + ); + withGaps.reduce((offset, part) => { + out.set(part, offset); + + return offset + part.length; + }, 0); + + return out; +}; + +/** Raw 24 kHz float samples in → 64 kb/s mono MP3 out (plenty for speech). */ +const toMp3 = (samples: Float32Array, command: string): Promise => + new Promise((resolve, reject) => { + const ffmpeg = spawn( + command, + [ + '-loglevel', + 'error', + '-f', + 'f32le', + '-ar', + String(SAMPLE_RATE), + '-ac', + '1', + '-i', + 'pipe:0', + '-ac', + '1', + '-b:a', + '64k', + '-f', + 'mp3', + 'pipe:1', + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ); + const chunks: Buffer[] = []; + const errors: Buffer[] = []; + ffmpeg.stdout.on('data', (chunk: Buffer) => + chunks.splice(chunks.length, 0, chunk) + ); + ffmpeg.stderr.on('data', (chunk: Buffer) => + errors.splice(errors.length, 0, chunk) + ); + ffmpeg.on('error', reject); + ffmpeg.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(chunks)); + + return; + } + + reject( + new Error( + `ffmpeg ${code}: ${Buffer.concat(errors).toString().slice(0, 200)}` + ) + ); + }); + ffmpeg.stdin.end( + Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength) + ); + }); + +const isMissing = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT'; + +/** + * A clip's bytes: MP3 through ffmpeg, or WAV where ffmpeg isn't installed + * (the Mac app doesn't carry it). The audio route serves each by its bytes. + */ +export const encodeClip = async ( + samples: Float32Array, + ffmpeg = 'ffmpeg' +): Promise<{ audio: Buffer; type: string }> => { + try { + return { audio: await toMp3(samples, ffmpeg), type: MP3_TYPE }; + } catch (error) { + if (!isMissing(error)) { + throw error; + } + + return { audio: encodeWav(samples, SAMPLE_RATE), type: WAV_TYPE }; + } +}; + +/** + * Built next to the runner by tsup (dist/kokoroWorker.js). Inside the Mac + * app the bundle is `app.asar`, which a forked process can't run from: the + * worker is unpacked (`asarUnpack`) and loaded from `app.asar.unpacked`. + */ +export const workerPath = (dir: string = __dirname): string => + path.join( + dir.replace(/([/\\])app\.asar(?=[/\\]|$)/, '$1app.asar.unpacked'), + 'kokoroWorker.js' + ); + +type Env = Record; + +/** Where the model is cached: the app's folder inside the Mac app, else transformers.js's own. */ +export const modelCacheDir = ( + env: Env = process.env, + home: string = os.homedir() +): string | null => (appBundle(env) === null ? null : modelsDir(home)); + +/** The worker's env: inside the app, its model cache and Kokoro's modules. */ +export const workerEnv = (env: Env = process.env): Env => { + const bundle = appBundle(env); + const cache = modelCacheDir(env); + + return bundle === null || cache === null + ? env + : { + ...env, + KOKORO_CACHE_DIR: cache, + NODE_PATH: [kokoroModulesDir(bundle), env.NODE_PATH] + .filter((dir) => dir !== undefined && dir !== '') + .join(path.delimiter), + }; +}; + +/** + * CPU priority, decided per piece. A clip nobody is waiting for renders in + * macOS's background tier (efficiency cores, lowest priority), below the + * server and the test runs: speech can wait, the page and the tests can't. The clip + * the owner IS waiting for (renderer priority [0, …]) renders at normal priority. + * + * Not nice(1): an unprivileged process can raise its nice value but never + * lower it again, so a niced worker could not be lifted for an urgent clip. + * At nice 10 on a fully loaded machine it rendered slower than real time, and + * the player gave up and used the browser voice. + * Elsewhere than macOS it is niced once, as before. + */ +const NICE = 10; + +export type CpuTier = 'urgent' | 'background'; + +const setTier = (pid: number, tier: CpuTier): boolean => { + try { + if (process.platform === 'darwin') { + execFileSync('taskpolicy', [ + tier === 'urgent' ? '-B' : '-b', + '-p', + String(pid), + ]); + + return true; + } + + if (tier === 'background') { + os.setPriority(pid, NICE); + } + + return true; + } catch { + // Not fatal: it still runs, just at the priority it had. + return false; + } +}; + +export type TtsLog = (event: Record) => void; + +type Reply = { id: number; samples?: Float32Array; error?: string }; + +const isReply = (message: unknown): message is Reply => + typeof message === 'object' && + message !== null && + 'id' in message && + typeof message.id === 'number'; + +/** What the renderer needs of the model's process (a fake one in tests). */ +export interface KokoroChild { + pid?: number; + /** False once its IPC channel has closed, often before `exit`. */ + connected?: boolean; + send: ( + message: Record, + callback?: (error: Error | null) => void + ) => unknown; + kill: () => unknown; + on(event: 'message', listener: (message: unknown) => void): unknown; + on( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void + ): unknown; + on(event: 'error', listener: (error: Error) => void): unknown; +} + +export type ForkKokoro = () => KokoroChild; + +const forkWorker: ForkKokoro = () => + fork(workerPath(), [], { + env: workerEnv(), + serialization: 'advanced', + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }); + +/** The model's process: started on first use, restarted if it dies. */ +const createWorkerProcess = (log: TtsLog, forkChild: ForkKokoro) => { + const state: { + child: KokoroChild | null; + nextId: number; + tier: CpuTier | null; + closed: boolean; + } = { + child: null, + nextId: 1, + tier: null, + closed: false, + }; + const pending = new Map< + number, + { + child: KokoroChild; + resolve: (samples: Float32Array) => void; + reject: (error: Error) => void; + } + >(); + + /** A dead process: forget it, and fail what it still owed. */ + const lost = (child: KokoroChild, error: Error): number => { + if (state.child === child) { + state.child = null; + } + + const owed = [...pending].filter(([, waiting]) => waiting.child === child); + owed.forEach(([id, waiting]) => { + pending.delete(id); + waiting.reject(error); + }); + + return owed.length; + }; + + const start = (): KokoroChild => { + const child = forkChild(); + + state.tier = null; + log({ event: 'worker-start', pid: child.pid }); + + child.on('message', (message: unknown) => { + if (!isReply(message)) { + return; + } + + const waiting = pending.get(message.id); + pending.delete(message.id); + + if (message.error !== undefined) { + waiting?.reject(new Error(message.error)); + + return; + } + + waiting?.resolve(message.samples ?? new Float32Array(0)); + }); + child.on('exit', (code, signal) => { + const dropped = lost( + child, + new Error(`The Kokoro process exited (${String(code)})`) + ); + log({ event: 'worker-exit', code, signal, dropped }); + }); + // A failed fork, or a send on a closed channel: unheard, it would throw + // in the runner. + child.on('error', (error) => { + const dropped = lost(child, error); + log({ event: 'worker-error', error: error.message, dropped }); + }); + state.child = child; + + return child; + }; + + const killChild = (): void => { + state.child?.kill(); + }; + process.on('exit', killChild); + + const ask = ( + message: Record, + tier: CpuTier = 'background' + ): Promise => + new Promise((resolve, reject) => { + if (state.closed) { + reject(new Error('The Kokoro process was closed')); + + return; + } + + const child = + state.child !== null && state.child.connected !== false + ? state.child + : start(); + + if (state.tier !== tier && child.pid !== undefined) { + if (setTier(child.pid, tier)) { + state.tier = tier; + log({ event: 'tier', tier }); + } + } + + const id = state.nextId; + state.nextId += 1; + pending.set(id, { child, resolve, reject }); + child.send({ ...message, id }, (error) => { + if (error !== null && pending.delete(id)) { + reject(error); + } + }); + }); + + const close = (): void => { + state.closed = true; + process.off('exit', killChild); + killChild(); + }; + + return { ask, close }; +}; + +export const createKokoro = ( + config: Pick, + /** The renderer's live priority for a clip (keyed by its text). */ + priorityOf: (text: string) => Priority, + log: TtsLog = () => undefined, + forkChild: ForkKokoro = forkWorker +) => { + const worker = createWorkerProcess(log, forkChild); + + // `kokoroWorkers` CPU-bound pieces at a time, most important clip first — + // re-decided before every piece, which also sets the worker's CPU tier. + const queue = createPieceQueue( + (piece: string, key: string) => + worker.ask( + { + piece, + voice: config.kokoroVoice, + dtype: config.kokoroDtype, + }, + priorityOf(key)[0] === 0 ? 'urgent' : 'background' + ), + { workers: config.kokoroWorkers, priorityOf } + ); + + const render = async (text: string): Promise => { + const samples = join(await queue.add(text, chunkForKokoro(text))); + + return { + audio: (await encodeClip(samples)).audio, + alignment: { starts: [], ends: [] }, + duration: samples.length / SAMPLE_RATE, + }; + }; + + return { + render, + cancel: queue.cancel, + poke: queue.poke, + status: queue.status, + /** Stop the model's process for good (the host is going away). */ + close: worker.close, + /** Load the model now, so the first clip doesn't wait for it. */ + warm: (): void => { + worker + .ask({ warm: true, dtype: config.kokoroDtype }) + .catch((error: unknown) => + log({ + event: 'warm-failed', + reason: error instanceof Error ? error.message : String(error), + }) + ); + }, + }; +}; diff --git a/src/app/server/tts/kokoroWorker.ts b/src/app/server/tts/kokoroWorker.ts new file mode 100644 index 0000000..4ccb4af --- /dev/null +++ b/src/app/server/tts/kokoroWorker.ts @@ -0,0 +1,152 @@ +/** + * Kokoro's model, in its own process (forked by kokoro.ts). Generating speech + * is CPU-bound and partly synchronous (phonemes, the model step); in the + * server it froze the whole process: the page itself stopped loading + * whenever the machine was busy, e.g. during a long test run. + * + * `KOKORO_CACHE_DIR` (set by the Mac app) is where the model is cached: + * `~/Library/Application Support/Lambda/models`. + * + * Protocol (advanced IPC serialization, so samples travel as Float32Array): + * in: { id, piece, voice, dtype } | { id, warm: true, dtype } + * out: { id, samples } | { id, error } + */ +import type { GenerateOptions, KokoroTTS } from 'kokoro-js'; + +import { requireFromNodePath } from './nodePath'; + +type Voice = NonNullable; +type Dtype = 'fp32' | 'fp16' | 'q8' | 'q4'; + +interface Request { + id: number; + piece?: string; + voice?: string; + dtype: Dtype; + warm?: boolean; +} + +const MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX'; + +const TRANSFORMERS = '@huggingface/transformers'; + +type KokoroModule = typeof import('kokoro-js'); + +const isKokoroModule = (value: unknown): value is KokoroModule => + typeof value === 'object' && value !== null && 'KokoroTTS' in value; + +/** kokoro-js, from the app's Kokoro folder (NODE_PATH): never a native import(). */ +const loadKokoro = (): KokoroModule => { + const kokoro = requireFromNodePath('kokoro-js'); + + if (!isKokoroModule(kokoro)) { + throw new Error("kokoro-js didn't load as expected."); + } + + return kokoro; +}; + +/** The model's cache folder, when the app names one. */ +const useCacheDir = (): void => { + const dir = process.env.KOKORO_CACHE_DIR; + + if (dir === undefined || dir === '') { + return; + } + + // Kokoro's own dependency, from the app's Kokoro folder (NODE_PATH). + const transformers = requireFromNodePath(TRANSFORMERS); + const env = + typeof transformers === 'object' && + transformers !== null && + 'env' in transformers + ? transformers.env + : undefined; + + if (typeof env !== 'object' || env === null) { + throw new Error("Kokoro's model settings couldn't be loaded."); + } + + Reflect.set(env, 'cacheDir', dir); +}; +const state: { model: Promise | null } = { model: null }; + +const model = (dtype: Dtype): Promise => { + if (state.model !== null) { + return state.model; + } + + const loading = Promise.resolve().then(() => { + useCacheDir(); + + return loadKokoro().KokoroTTS.from_pretrained(MODEL_ID, { + dtype, + device: 'cpu', + }); + }); + state.model = loading; + // A failed load is retried on the next request, not cached forever. + loading.catch(() => { + state.model = null; + }); + + return loading; +}; + +/** The configured voice, checked against the model's own list (a typo fails loudly). */ +const voiceOf = (tts: KokoroTTS, name: string): Voice => { + const voice = Object.keys(tts.voices).find( + (candidate): candidate is Voice => candidate === name + ); + + if (voice === undefined) { + throw new Error(`Unknown Kokoro voice "${name}"`); + } + + return voice; +}; + +const isRequest = (message: unknown): message is Request => + typeof message === 'object' && + message !== null && + 'id' in message && + typeof message.id === 'number'; + +const reply = (message: Record): void => { + process.send?.(message); +}; + +// One at a time, in arrival order: the parent's piece queue already decides +// what comes next, and hands over one piece per worker slot. +const work = { chain: Promise.resolve() }; + +process.on('message', (message: unknown) => { + if (!isRequest(message)) { + return; + } + + work.chain = work.chain.then(async () => { + try { + const tts = await model(message.dtype); + + if (message.warm === true) { + reply({ id: message.id, samples: new Float32Array(0) }); + + return; + } + + const audio = await tts.generate(message.piece ?? '', { + voice: voiceOf(tts, message.voice ?? ''), + }); + reply({ id: message.id, samples: audio.audio }); + } catch (error) { + reply({ + id: message.id, + error: error instanceof Error ? error.message : String(error), + }); + } + }); +}); + +// The parent went away: nothing left to render for. +process.on('disconnect', () => process.exit(0)); diff --git a/src/app/server/tts/nodePath.ts b/src/app/server/tts/nodePath.ts new file mode 100644 index 0000000..bf5965c --- /dev/null +++ b/src/app/server/tts/nodePath.ts @@ -0,0 +1,43 @@ +import { createRequire } from 'module'; +import * as path from 'path'; + +/** + * Load a package by name, looking in `NODE_PATH` as well as beside this + * file. The Mac app carries Kokoro outside app.asar and names its folder in + * `NODE_PATH`; a native `import()` never reads `NODE_PATH`, so the worker + * loads its libraries through this instead. + */ +export const requireFromNodePath = ( + name: string, + env: Record = process.env +): unknown => createRequire(__filename)(resolveFromNodePath(name, env)); + +/** Where `requireFromNodePath` would load `name` from; throws when nowhere. */ +export const resolveFromNodePath = ( + name: string, + env: Record = process.env +): string => { + const dirs = (env.NODE_PATH ?? '') + .split(path.delimiter) + .filter((dir) => dir !== ''); + + return createRequire(__filename).resolve(name, { + paths: [...dirs, __dirname], + }); +}; + +/** + * kokoro-js can be loaded here: the Mac app's Kokoro folder, or a checkout + * that installed Lambda's own dependencies (kokoro-js is one). + */ +export const kokoroInstalled = ( + env: Record = process.env +): boolean => { + try { + resolveFromNodePath('kokoro-js', env); + + return true; + } catch { + return false; + } +}; diff --git a/src/app/server/tts/pieceQueue.ts b/src/app/server/tts/pieceQueue.ts new file mode 100644 index 0000000..dbe11e7 --- /dev/null +++ b/src/app/server/tts/pieceQueue.ts @@ -0,0 +1,168 @@ +/** + * Rendering scheduled PIECE by piece, by a LIVE priority. + * + * A clip is a list of pieces (Kokoro renders ~300 characters per call). Before + * each piece a worker asks `priorityOf` for every waiting clip and takes the + * next piece of the most important one — so a click in the app (a jump to a + * section, another item, another pod) takes effect within one piece, a few + * seconds at most, and the clip it interrupted resumes later where it left + * off (its finished pieces are kept). A piece already rendering can't be + * stopped: the runtime has no cancel, and restarting it means reloading the + * model (~8 s), which is slower than letting the piece finish. + * + * Priorities compare as tuples, lowest first; ties go to the clip queued first. + */ + +export type Priority = readonly number[]; + +interface Job { + key: string; + seq: number; + /** Pieces not yet claimed by a worker, with their place in the clip. */ + pending: { index: number; text: string }[]; + parts: (T | undefined)[]; + inFlight: number; + failed: boolean; + resolve: (parts: T[]) => void; + reject: (error: unknown) => void; +} + +export const comparePriority = (a: Priority, b: Priority): number => { + const length = Math.max(a.length, b.length); + const diff = Array.from({ length }, (_unused, index) => + Math.sign((a[index] ?? 0) - (b[index] ?? 0)) + ).find((sign) => sign !== 0); + + return diff ?? 0; +}; + +export class CancelledError extends Error { + constructor(key: string) { + super(`Cancelled: ${key.slice(0, 60)}`); + this.name = 'CancelledError'; + } +} + +export const createPieceQueue = ( + /** `key`: the clip this piece belongs to (its priority decides how it runs). */ + renderPiece: (piece: string, key: string) => Promise, + options: { + workers?: number; + /** Asked before every piece: how important is this clip right now? */ + priorityOf?: (key: string) => Priority; + } = {} +) => { + const workers = options.workers ?? 1; + const priorityOf = options.priorityOf ?? (() => [0]); + const jobs: Job[] = []; + const state = { active: 0, seq: 0 }; + + const next = (): Job | undefined => + jobs + .filter((job) => job.pending.length > 0) + .map((job) => ({ job, priority: priorityOf(job.key) })) + .sort( + (a, b) => + comparePriority(a.priority, b.priority) || a.job.seq - b.job.seq + )[0]?.job; + + const remove = (job: Job): void => { + const at = jobs.indexOf(job); + + if (at !== -1) { + jobs.splice(at, 1); + } + }; + + const finish = (job: Job): void => { + if (job.failed || job.pending.length > 0 || job.inFlight > 0) { + return; + } + + remove(job); + job.resolve(job.parts.filter((part): part is T => part !== undefined)); + }; + + const fail = (job: Job, error: unknown): void => { + if (job.failed) { + return; + } + + job.failed = true; + job.pending = []; + remove(job); + job.reject(error); + }; + + const work = async (): Promise => { + const job = next(); + const piece = job?.pending.shift(); + + if (job === undefined || piece === undefined) { + state.active -= 1; + + return; + } + + job.inFlight += 1; + + try { + const part = await renderPiece(piece.text, job.key); + job.parts[piece.index] = part; + job.inFlight -= 1; + finish(job); + } catch (error) { + job.inFlight -= 1; + fail(job, error); + } + + await work(); + }; + + const start = (): void => { + if (state.active >= workers || next() === undefined) { + return; + } + + state.active += 1; + work().catch(() => { + state.active -= 1; + }); + start(); + }; + + return { + /** Queue a clip's pieces; resolves with one result per piece, in order. */ + add: (key: string, pieces: string[]): Promise => + new Promise((resolve, reject) => { + state.seq += 1; + const job: Job = { + key, + seq: state.seq, + pending: pieces.map((text, index) => ({ index, text })), + parts: [], + inFlight: 0, + failed: false, + resolve, + reject, + }; + jobs.splice(jobs.length, 0, job); + // A clip with no pieces (empty text) is done at once. + finish(job); + start(); + }), + /** Drop a clip nobody needs any more (it left the inbox). */ + cancel: (key: string): void => { + jobs + .filter((job) => job.key === key) + .forEach((job) => fail(job, new CancelledError(key))); + }, + /** Priorities changed (a click): wake a worker if one is idle. */ + poke: (): void => start(), + /** The clip a worker is on now, and how many clips are waiting. */ + status: (): { current?: string; waiting: number } => ({ + current: jobs.find((job) => job.inFlight > 0)?.key, + waiting: jobs.filter((job) => job.inFlight === 0).length, + }), + }; +}; diff --git a/src/app/server/tts/renderer.ts b/src/app/server/tts/renderer.ts new file mode 100644 index 0000000..adab7cc --- /dev/null +++ b/src/app/server/tts/renderer.ts @@ -0,0 +1,522 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { ClipStatus } from '../../shared/types'; +import { type Budget } from './budget'; +import { type Rendered } from './elevenlabs'; +import { type Priority, comparePriority } from './pieceQueue'; +import { audioHash } from './timing'; + +/** + * Per-section audio ("clips"). Each section of an item is rendered on its own + * and cached under `//audio/.mp3` (+ `.json` with its + * duration), keyed by voice + model + exact text — so a clip is paid for once, + * however often it is replayed, and an unchanged section of a re-posted item + * is reused. Two ways in: + * - `prefetch`: background, after the text has settled (so a dev amending a + * checkpoint doesn't pay for half-finished versions), retried with backoff; + * - `renderNow`: the player needs it now — no settling, one attempt. + */ + +interface Job { + podId: string; + hash: string; + text: string; + state: 'queued' | 'rendering' | 'waiting' | 'failed'; + attempts: number; + reason?: string; + failedAt?: number; + /** When it was first queued (for the log's queue wait). */ + queuedAt?: number; +} + +/** + * A failure is not forever: after this long the clip is tried again, so fixing + * the key (e.g. granting text_to_speech in the ElevenLabs dashboard) takes + * effect without restarting the app. + */ +const RETRY_FAILED_AFTER_MS = 5 * 60_000; + +/** How long the clip lasts at 1×: the end of its last character. */ +const durationOf = (rendered: Rendered): number => + rendered.duration ?? + rendered.alignment.ends.reduce((max, end) => Math.max(max, end), 0); + +export const createRenderer = (args: { + audioDir: (podId: string) => string; + render: (text: string) => Promise; + /** Drop a clip already handed to `render` (it left the inbox). */ + cancel?: (text: string) => void; + /** Priorities changed: let the provider re-pick (Kokoro, per piece). */ + poke?: () => void; + budget: Budget; + /** Asked each time — a key added to the env takes effect without a restart. */ + enabled: () => boolean; + voiceId: string; + modelId: string; + concurrency?: number; + retryDelaysMs?: number[]; + schedule?: (fn: () => void, ms: number) => void; + now?: () => number; + /** How long a text must stay unchanged before it is pre-rendered. */ + settleMs?: number; + onChange?: () => void; + /** One line per render and per player wait, for `/_tts.log`. */ + log?: (event: Record) => void; +}) => { + const log = args.log ?? (() => undefined); + /** + * The render is paid for and stored: a failure to write it down is logged, + * never a reason to render (and pay) again. + */ + const recordSpend = (characters: number): void => { + try { + args.budget.record(characters); + } catch (error) { + log({ + event: 'budget-not-recorded', + chars: characters, + reason: error instanceof Error ? error.message : String(error), + }); + } + }; + const concurrency = args.concurrency ?? 2; + const retryDelays = args.retryDelaysMs ?? [2_000, 8_000, 30_000]; + const schedule = args.schedule ?? ((fn, ms) => void setTimeout(fn, ms)); + const now = args.now ?? (() => Date.now()); + const settleMs = args.settleMs ?? 15_000; + /** When each text was first seen — pre-rendering waits until it settles. */ + const seen = new Map(); + const jobs = new Map(); + /** Renders in flight, so an on-demand request joins one already running. */ + const inflight = new Map>(); + /** + * Priority, decided live (see priorityOf). `needs`: what the player asked + * for, with when and on which item; `ranks`: the background order from the + * latest prefetch; `focus`: the item (or pod page) on screen. + */ + const needs = new Map< + string, + { level: 0 | 1; at: number; focus: string | null } + >(); + const ranks = new Map(); + const state: { focus: string | null; at: number } = { focus: null, at: 0 }; + /** Strictly increasing, so two requests in the same millisecond still order. */ + const tick = (): number => { + state.at = Math.max(state.at + 1, now()); + + return state.at; + }; + + const hashOf = (text: string): string => + audioHash(args.voiceId, args.modelId, text); + + const files = (podId: string, hash: string) => ({ + mp3: path.join(args.audioDir(podId), `${hash}.mp3`), + json: path.join(args.audioDir(podId), `${hash}.json`), + }); + + const storedDuration = (podId: string, hash: string): number | null => { + const { mp3, json } = files(podId, hash); + + if (!fs.existsSync(mp3) || !fs.existsSync(json)) { + return null; + } + + try { + const parsed: unknown = JSON.parse(fs.readFileSync(json, 'utf8')); + + return typeof parsed === 'object' && + parsed !== null && + 'duration' in parsed && + typeof parsed.duration === 'number' + ? parsed.duration + : null; + } catch { + return null; + } + }; + + const running = (): number => + [...jobs.values()].filter((job) => job.state === 'rendering').length; + + /** + * How much a clip matters right now — lowest first, asked before every + * piece Kokoro renders: + * [0, -at] the section the player is waiting on (newest request wins: + * a jump beats everything asked before it) + * [1, at] the player's look-ahead on the item on screen + * [2, rank] the background order (on-screen item first; see the server) + * [3] anything else + * A request made for another item than the one on screen counts as + * background: clicking away demotes it at once. + */ + const priorityOfHash = (hash: string): Priority => { + const need = needs.get(hash); + + if (need !== undefined && need.focus === state.focus) { + return need.level === 0 ? [0, -need.at] : [1, need.at]; + } + + const rank = ranks.get(hash); + + return rank === undefined ? [3] : [2, rank]; + }; + + const priorityOf = (text: string): Priority => priorityOfHash(hashOf(text)); + + /** One attempt. Background jobs get retries; on-demand ones don't wait for them. */ + const run = (job: Job, retry: boolean): Promise => { + const existing = inflight.get(job.hash); + + // Joining a render already running: its priority was raised by the + // caller (renderNow), which Kokoro reads before its next piece. + if (existing !== undefined) { + return existing; + } + + jobs.set(job.hash, { ...job, state: 'rendering' }); + const startedAt = now(); + const timing = () => ({ + podId: job.podId, + chars: job.text.length, + priority: priorityOfHash(job.hash).join(','), + queuedMs: + job.queuedAt === undefined ? undefined : startedAt - job.queuedAt, + renderMs: now() - startedAt, + attempt: job.attempts + 1, + }); + + const attempt = (async (): Promise => { + try { + const rendered = await args.render(job.text); + const seconds = durationOf(rendered); + const took = timing(); + log({ + event: 'rendered', + ...took, + audioSec: Math.round(seconds * 10) / 10, + // Seconds of speech per second of rendering: under 1, a listener + // catches up with it. + speed: + took.renderMs > 0 + ? Math.round((seconds / (took.renderMs / 1000)) * 100) / 100 + : undefined, + }); + const { mp3, json } = files(job.podId, job.hash); + fs.mkdirSync(path.dirname(mp3), { recursive: true }); + fs.writeFileSync(mp3, rendered.audio); + fs.writeFileSync( + json, + `${JSON.stringify({ duration: durationOf(rendered) })}\n` + ); + jobs.delete(job.hash); + needs.delete(job.hash); + recordSpend(job.text.length); + } catch (error) { + // Dropped on purpose (it left the inbox): not a failure. + if (error instanceof Error && error.name === 'CancelledError') { + log({ event: 'cancelled', ...timing() }); + jobs.delete(job.hash); + inflight.delete(job.hash); + pump(); + + return; + } + + const attempts = job.attempts + 1; + const message = error instanceof Error ? error.message : String(error); + // 4xx (bad key, missing permission, bad request) won't fix itself: fail now. + const permanent = /^ElevenLabs 4\d\d/.test(message); + log({ event: 'failed', ...timing(), reason: message.slice(0, 200) }); + const delay = + permanent || !retry ? undefined : retryDelays[attempts - 1]; + + if (delay === undefined) { + jobs.set(job.hash, { + ...job, + attempts, + state: 'failed', + reason: message, + failedAt: now(), + }); + } else { + jobs.set(job.hash, { ...job, attempts, state: 'waiting' }); + schedule(() => { + const current = jobs.get(job.hash); + + if (current?.state === 'waiting') { + jobs.set(job.hash, { ...current, state: 'queued' }); + pump(); + } + }, delay); + } + } finally { + inflight.delete(job.hash); + } + + args.onChange?.(); + pump(); + })(); + inflight.set(job.hash, attempt); + + return attempt; + }; + + const pump = (): void => { + const [next] = [...jobs.values()] + .filter((job) => job.state === 'queued') + .sort((a, b) => + comparePriority(priorityOfHash(a.hash), priorityOfHash(b.hash)) + ); + + if (next !== undefined && running() < concurrency) { + run(next, true).catch(() => undefined); + pump(); + } + }; + + /** Forget a failure old enough to be worth another try. */ + const expireFailure = (hash: string): void => { + const existing = jobs.get(hash); + + if ( + existing?.state === 'failed' && + now() - (existing.failedAt ?? 0) > RETRY_FAILED_AFTER_MS + ) { + jobs.delete(hash); + } + }; + + const overBudget = (podId: string, hash: string, text: string): boolean => { + if (args.budget.allows(text.length)) { + return false; + } + + jobs.set(hash, { + podId, + hash, + text, + state: 'failed', + attempts: 0, + reason: 'over-budget', + failedAt: now(), + }); + + return true; + }; + + /** + * The whole background order, most important first (the server builds it: + * the item on screen, its pod, then the inbox). Replaces the previous order: + * a queued clip no longer in it — its item left the inbox — is dropped, + * even mid-render. `settle: false` skips the settling wait (the item on + * screen is being looked at now). + */ + const prefetch = ( + items: { podId: string; text: string; settle?: boolean }[] + ): void => { + if (!args.enabled()) { + return; + } + + ranks.clear(); + items.forEach(({ text }, index) => { + const hash = hashOf(text); + + if (!ranks.has(hash)) { + ranks.set(hash, index); + } + }); + + // Gone from the inbox and nobody is listening to it: drop it. + [...jobs.values()] + .filter( + (job) => + !ranks.has(job.hash) && + !needs.has(job.hash) && + (job.state === 'queued' || job.state === 'rendering') + ) + .forEach((job) => { + if (job.state === 'queued') { + jobs.delete(job.hash); + + return; + } + + args.cancel?.(job.text); + }); + + items.forEach(({ podId, text, settle = true }) => { + const hash = hashOf(text); + expireFailure(hash); + + if ( + text === '' || + jobs.has(hash) || + storedDuration(podId, hash) !== null + ) { + return; + } + + const firstSeen = seen.get(hash); + + if (!settle) { + seen.set(hash, firstSeen ?? now()); + } else if (firstSeen === undefined) { + seen.set(hash, now()); + + if (settleMs > 0) { + schedule(() => args.onChange?.(), settleMs + 100); + + return; + } + } else if (now() - firstSeen < settleMs) { + return; + } + + if (overBudget(podId, hash, text)) { + return; + } + + jobs.set(hash, { + podId, + hash, + text, + state: 'queued', + attempts: 0, + queuedAt: now(), + }); + }); + pump(); + args.poke?.(); + }; + + const clip = (podId: string, text: string): ClipStatus => { + if (!args.enabled()) { + return { status: 'unavailable', reason: 'no-key' }; + } + + const hash = hashOf(text); + const duration = storedDuration(podId, hash); + + if (duration !== null) { + return { + status: 'ready', + url: `/api/audio/${encodeURIComponent(podId)}/${hash}.mp3`, + duration, + }; + } + + const job = jobs.get(hash); + + if (job === undefined) { + return seen.has(hash) ? { status: 'pending' } : { status: 'none' }; + } + + return job.state === 'failed' + ? { status: 'unavailable', reason: job.reason ?? 'failed' } + : { status: 'pending' }; + }; + + /** + * The player needs this clip: `now` (it is waiting to play it) or `soon` + * (look-ahead). Renders at once, or joins the render already running — its + * priority rises either way, effective at Kokoro's next piece. + */ + const renderNow = async ( + podId: string, + text: string, + need: 'now' | 'soon' = 'now' + ): Promise => { + const current = clip(podId, text); + + if (current.status === 'ready' || text === '' || !args.enabled()) { + return current; + } + + const askedAt = now(); + + const hash = hashOf(text); + const asked = needs.get(hash); + const level = need === 'now' ? 0 : 1; + + // Never downgrade: a look-ahead request for a clip already wanted "now". + if ( + asked === undefined || + asked.focus !== state.focus || + level <= asked.level + ) { + needs.set(hash, { level, at: tick(), focus: state.focus }); + } + + args.poke?.(); + expireFailure(hash); + const job = jobs.get(hash); + + if (job?.state === 'failed') { + return clip(podId, text); + } + + if (job === undefined && overBudget(podId, hash, text)) { + return clip(podId, text); + } + + // Jumps the queue: the owner is listening. + await run( + job ?? { + podId, + hash, + text, + state: 'queued', + attempts: 0, + queuedAt: askedAt, + }, + false + ); + + const result = clip(podId, text); + // How long the player waited: the number that decides whether audio + // feels instant. `joined`: it was already rendering (look-ahead/prefetch). + log({ + event: 'player-wait', + podId, + need, + chars: text.length, + waitedMs: now() - askedAt, + joined: job?.state === 'rendering', + status: result.status, + }); + + return result; + }; + + /** What's on screen (an item key or a pod page); re-prioritizes at once. */ + const setFocus = (focus: string | null): void => { + if (focus === state.focus) { + return; + } + + state.focus = focus; + pump(); + args.poke?.(); + }; + + /** For the sidebar: what's queued or rendering, by pod. */ + const queued = (): { podId: string; text: string }[] => + [...jobs.values()] + .filter((job) => job.state === 'queued' || job.state === 'rendering') + .map(({ podId, text }) => ({ podId, text })); + + return { + prefetch, + clip, + renderNow, + hashOf, + priorityOf, + setFocus, + queued, + }; +}; + +export type Renderer = ReturnType; diff --git a/src/app/server/tts/timing.ts b/src/app/server/tts/timing.ts new file mode 100644 index 0000000..18bf5e2 --- /dev/null +++ b/src/app/server/tts/timing.ts @@ -0,0 +1,85 @@ +import { createHash } from 'crypto'; + +import type { Script, TimedSection } from '../../shared/speech'; +import type { Alignment } from './elevenlabs'; + +/** + * Section times from ElevenLabs' per-character timestamps. The alignment has + * one entry per input character; if its length ever differs from the script + * (normalisation), offsets are mapped proportionally rather than trusted 1:1. + */ +export const sectionTimes = ( + script: Script, + alignment: Alignment +): { sections: TimedSection[]; duration: number } => { + const count = alignment.starts.length; + const duration = Math.max(0, ...alignment.ends); + + const timeAt = (offset: number): number => { + if (count === 0 || script.text.length === 0) { + return 0; + } + + const index = Math.min( + count - 1, + Math.max(0, Math.round((offset * count) / script.text.length)) + ); + + return alignment.starts[index] ?? 0; + }; + + const starts = script.sections.map((section) => timeAt(section.start)); + + return { + duration, + sections: script.sections.map((section, index) => ({ + key: section.key, + label: section.label, + start: starts[index] ?? 0, + end: starts[index + 1] ?? duration, + })), + }; +}; + +/** Content-addressed: same voice + model + text ⇒ same file, never billed twice. */ +export const audioHash = ( + voiceId: string, + modelId: string, + text: string +): string => + createHash('sha256') + .update(`${voiceId}\n${modelId}\n${text}`) + .digest('hex') + .slice(0, 24); + +/** `Range: bytes=a-b` → inclusive byte range, or null to serve the whole file. */ +export const parseRange = ( + header: string | undefined, + size: number +): { start: number; end: number } | null | 'unsatisfiable' => { + if (header === undefined) { + return null; + } + + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + + if (match === null) { + return null; + } + + const [, rawStart = '', rawEnd = ''] = match; + + if (rawStart === '' && rawEnd === '') { + return null; + } + + // "bytes=-500" = the last 500 bytes. + const start = + rawStart === '' ? Math.max(0, size - Number(rawEnd)) : Number(rawStart); + const end = + rawStart === '' || rawEnd === '' + ? size - 1 + : Math.min(Number(rawEnd), size - 1); + + return start > end || start >= size ? 'unsatisfiable' : { start, end }; +}; diff --git a/src/app/server/tts/wav.ts b/src/app/server/tts/wav.ts new file mode 100644 index 0000000..18b9b61 --- /dev/null +++ b/src/app/server/tts/wav.ts @@ -0,0 +1,46 @@ +/** + * Raw samples as a WAV file: what a Kokoro clip is served as when ffmpeg + * isn't on the machine (the Mac app doesn't carry it). Bigger than MP3, but + * every browser plays it. + */ + +export const WAV_TYPE = 'audio/wav'; +export const MP3_TYPE = 'audio/mpeg'; + +/** 32-bit float mono → 16-bit PCM mono WAV. */ +export const encodeWav = ( + samples: Float32Array, + sampleRate: number +): Buffer => { + const data = Buffer.alloc(samples.length * 2); + samples.forEach((sample, index) => { + const clamped = Math.max(-1, Math.min(1, sample)); + data.writeInt16LE(Math.round(clamped * 0x7fff), index * 2); + }); + + const header = Buffer.alloc(44); + header.write('RIFF', 0, 'ascii'); + header.writeUInt32LE(36 + data.length, 4); + header.write('WAVE', 8, 'ascii'); + header.write('fmt ', 12, 'ascii'); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36, 'ascii'); + header.writeUInt32LE(data.length, 40); + + return Buffer.concat([header, data]); +}; + +export const isWav = (bytes: Buffer): boolean => + bytes.length >= 12 && + bytes.toString('ascii', 0, 4) === 'RIFF' && + bytes.toString('ascii', 8, 12) === 'WAVE'; + +/** A clip's type, by its bytes: clips keep one file name whatever they hold. */ +export const audioTypeOf = (bytes: Buffer): string => + isWav(bytes) ? WAV_TYPE : MP3_TYPE; diff --git a/src/app/server/watchdog.ts b/src/app/server/watchdog.ts new file mode 100644 index 0000000..e163f1c --- /dev/null +++ b/src/app/server/watchdog.ts @@ -0,0 +1,310 @@ +import { + type Pod, + type Question, + type StepEvent, + type ThreadMessage, + type Turn, + isIdle, + pendingResends, +} from '../../pod-store'; +import { chatHold } from '../shared/conversation'; + +/** + * The watchdog: keeps a pod from sitting still with nobody noticing. + * + * Stalled = active pod, nothing in the inbox, every agent's turn has ended, + * and the last turn ended STALL_GRACE_MS ago (the manager picks the dev's + * turn up within seconds, so 90 s is no handoff). It nudges the manager, at + * most MAX_NUDGES times per stall, NUDGE_GAP_MS apart; only when that fails + * does the pod go to the owner's inbox — with what was tried. + * + * A stall is one "episode" until the pod makes PROGRESS — a non-system + * message, a reported step, a question, a phase or checkpoint — not merely a + * turn: a nudge always causes a turn, and a turn that only says "nothing to + * do" must not reset the count (or it would nudge forever). + * + * A message an agent never took in — pasted while it was busy, then lost + * (a restart, a wedged input box) — is re-sent once, by id, after the agent + * has sat idle for LOST_MS. If it still isn't taken, it goes to the inbox: + * otherwise the agent waits on an answer the inbox thinks it already has. + * + * Some things a nudge can't fix, so they go to the inbox at once: + * - an agent on a permission prompt (it is waiting on a human), + * - an agent stopped by the Claude usage limit, and + * - an agent mid-turn whose session has written nothing for HANG_MS. + */ + +export const STALL_GRACE_MS = 90_000; +export const NUDGE_GAP_MS = 2 * 60_000; +export const MAX_NUDGES = 2; +export const HANG_MS = 15 * 60_000; +export const LOST_MS = 3 * 60_000; + +const AGENTS = ['manager', 'dev'] as const; +export type Agent = (typeof AGENTS)[number]; + +export interface PodSignals { + pod: Pod; + running: boolean; + hasInboxItems: boolean; + turns: Record; + thread: ThreadMessage[]; + questions: Question[]; + steps: StepEvent[]; + /** Last write to each agent's session transcript (ms), when known. */ + transcriptAt: Partial>; + /** Each agent's visible pane, for permission prompts. */ + screens: Partial>; +} + +export interface Episode { + /** Progress fingerprint when the stall began. */ + progress: string; + nudges: number; + lastNudgeAt?: number; + /** Lost messages already re-sent once in this episode. */ + resent?: string[]; +} + +export type Verdict = + | { kind: 'ok' } + | { kind: 'waiting' } // stalled, within the grace period or between nudges + | { kind: 'nudge'; attempt: number } + | { kind: 'resend'; msgIds: string[] } + | { kind: 'escalate'; reason: string }; + +const latest = (values: (string | undefined)[]): string => + values.reduce( + (max, value) => (value !== undefined && value > max ? value : max), + '' + ); + +/** Changes only when the pod actually moves forward. */ +export const progressOf = (signals: PodSignals): string => + [ + signals.pod.phase, + signals.pod.completedCheckpoints.join(','), + signals.questions.length, + latest(signals.questions.map((q) => q.answeredAt ?? q.createdAt)), + latest( + signals.thread + .filter((message) => message.from !== 'system') + .map((message) => message.createdAt) + ), + latest( + signals.steps + .filter((event) => event.by !== 'watchdog') + .map((event) => event.at) + ), + ].join('|'); + +const PERMISSION = + /Do you want to (?:proceed|make this edit|create|allow)|❯ 1\. Yes|Allow this|permission to use/i; + +const USAGE_LIMIT = + /Usage limit reached|You've hit your (?:weekly |session )?limit/; + +const minutes = (ms: number): number => Math.round(ms / 60_000); + +/** What, if anything, a human must do now (no nudge can fix it). */ +const blockedOnHuman = (signals: PodSignals, now: number): string | undefined => + AGENTS.flatMap((agent): string[] => { + if (isIdle(signals.turns[agent])) { + return []; + } + + const screen = signals.screens[agent] ?? ''; + + // Looks mid-turn (no Stop hook fires), but no nudge can help: it goes to + // the inbox at once. + if (USAGE_LIMIT.test(screen)) { + const resets = /resets ([^\n·(]+)/.exec(screen)?.[1]?.trim(); + + return [ + `The ${agent} hit the Claude usage limit${ + resets === undefined ? '' : ` (resets ${resets})` + }. Log in to another account and restart the pod, or wait`, + ]; + } + + if (PERMISSION.test(screen)) { + const line = + screen + .split('\n') + .find((candidate) => PERMISSION.test(candidate)) + ?.trim() ?? ''; + + return [ + `The ${agent} is waiting for a permission answer in its pane: "${line}"`, + ]; + } + + const wrote = signals.transcriptAt[agent]; + + if (wrote !== undefined && now - wrote > HANG_MS) { + return [ + `The ${agent} has been mid-turn for ${minutes( + now - wrote + )} minutes without writing anything — it looks hung`, + ]; + } + + return []; + })[0]; + +/** Messages an agent has been idle on for LOST_MS without taking in. */ +const lostMessages = (signals: PodSignals, now: number) => + pendingResends(signals.thread, now).filter((message) => { + const turn = + message.target === 'manager' ? signals.turns.manager : signals.turns.dev; + const idleSince = Date.parse(turn.endedAt ?? ''); + // Not isIdle: that counts a sent-but-never-taken message as the agent + // being busy — exactly the case this looks for. + const midTurn = (turn.startedAt ?? '') > (turn.endedAt ?? ''); + + return ( + !midTurn && + now - Date.parse(message.createdAt) >= LOST_MS && + (Number.isNaN(idleSince) || now - idleSince >= LOST_MS) + ); + }); + +const describeLost = (message: ThreadMessage): string => + `${message.from === 'human' ? 'Your' : 'A'} message to the ${ + message.target ?? 'agent' + }${message.qid === undefined ? '' : ` (${message.qid})`}`; + +const isStalledNow = (signals: PodSignals, now: number): boolean => { + const { pod } = signals; + + // A freeform pod is a conversation: quiet means it's the owner's turn. A pod + // held by ⌘⏎ is waiting on its chat, and its items are only hidden. + if ( + pod.status !== 'active' || + pod.mode === 'freeform' || + pod.waitingOnHuman !== undefined || + signals.hasInboxItems || + chatHold(signals.thread, now) + ) { + return false; + } + + if (!signals.running) { + return true; + } + + if (!AGENTS.every((agent) => isIdle(signals.turns[agent]))) { + return false; + } + + // The grace runs from the last turn's end OR the last message sent to an + // agent: one that has just been handed something (a work request, a + // checkpoint closing) is about to start, not stalled. + const lastActivity = latest( + AGENTS.flatMap((agent) => [ + signals.turns[agent].endedAt, + signals.turns[agent].sentAt, + ]) + ); + + return ( + lastActivity === '' || now - Date.parse(lastActivity) >= STALL_GRACE_MS + ); +}; + +/** One tick for one pod: what to do, and the episode to remember. */ +export const judge = ( + signals: PodSignals, + episode: Episode | undefined, + now: number +): { verdict: Verdict; episode: Episode | undefined } => { + if (signals.pod.status !== 'active') { + return { verdict: { kind: 'ok' }, episode: undefined }; + } + + const human = blockedOnHuman(signals, now); + + if (human !== undefined) { + return { verdict: { kind: 'escalate', reason: human }, episode }; + } + + if (signals.running) { + const lost = lostMessages(signals, now); + const resent = episode?.resent ?? []; + const fresh = lost.filter((message) => !resent.includes(message.id)); + + if (fresh.length > 0) { + return { + verdict: { kind: 'resend', msgIds: fresh.map((message) => message.id) }, + episode: { + progress: episode?.progress ?? progressOf(signals), + nudges: episode?.nudges ?? 0, + lastNudgeAt: episode?.lastNudgeAt, + resent: [...resent, ...fresh.map((message) => message.id)], + }, + }; + } + + const [stillLost] = lost; + + if (stillLost !== undefined) { + return { + verdict: { + kind: 'escalate', + reason: `${describeLost( + stillLost + )} never reached it, even after re-sending it. Send it again from the thread, or restart the pod`, + }, + episode, + }; + } + } + + const progress = progressOf(signals); + + if (!isStalledNow(signals, now)) { + // Working (e.g. the turn a nudge caused) is not progress: the episode + // survives until something actually moves. + return { + verdict: { kind: 'ok' }, + episode: + episode?.progress === progress + ? episode + : episode?.resent === undefined + ? undefined + : { progress, nudges: 0, resent: episode.resent }, + }; + } + + // Progress since the last nudge: a fresh stall, start counting again. + const current: Episode = + episode === undefined || episode.progress !== progress + ? { progress, nudges: 0, resent: episode?.resent } + : episode; + + if ( + current.lastNudgeAt !== undefined && + now - current.lastNudgeAt < NUDGE_GAP_MS + ) { + return { verdict: { kind: 'waiting' }, episode: current }; + } + + if (current.nudges < MAX_NUDGES) { + const attempt = current.nudges + 1; + + return { + verdict: { kind: 'nudge', attempt }, + episode: { ...current, nudges: attempt, lastNudgeAt: now }, + }; + } + + return { + verdict: { + kind: 'escalate', + reason: signals.running + ? `Nothing is running and nothing is waiting on you. I nudged the manager ${MAX_NUDGES} times and it made no progress` + : 'Its tmux session is gone and resuming it did not bring it back', + }, + episode: current, + }; +}; diff --git a/src/app/server/watchdogRunner.ts b/src/app/server/watchdogRunner.ts new file mode 100644 index 0000000..d9d1e13 --- /dev/null +++ b/src/app/server/watchdogRunner.ts @@ -0,0 +1,181 @@ +import * as fs from 'fs'; + +import { type PodStore, nowIso } from '../../pod-store'; +import type { AppState } from '../shared/types'; +import { buildState } from './state'; +import { + type Agent, + type Episode, + MAX_NUDGES, + type PodSignals, + judge, +} from './watchdog'; + +/** + * Runs the watchdog (./watchdog.ts) over every pod: gathers each pod's + * signals, nudges through `lambda pod nudge`, and keeps the escalations the inbox + * shows as Stalled. Every action is logged to the pod's steps.jsonl (by + * `watchdog`), so the workflow panel shows what it did. + */ + +const mtime = (file: string): number | undefined => { + try { + return fs.statSync(file).mtimeMs; + } catch { + return undefined; + } +}; + +/** Escalations that differ only in their counts are the same one. */ +export const sameEscalation = (a: string, b: string): boolean => + a.replace(/\d+/g, '#') === b.replace(/\d+/g, '#'); + +export const createWatchdogRunner = (deps: { + store: PodStore; + windowsFor: (podId: string) => string[]; + /** The agent's visible pane (only read while it is mid-turn). */ + capture: (podId: string, agent: Agent) => string; + nudge: (podId: string) => Promise; + /** Re-send a thread message by id (`lambda pod deliver`). */ + resend: (podId: string, msgId: string) => Promise; + onChange: () => void; +}) => { + const episodes = new Map(); + const escalated = new Map(); + + const note = (podId: string, text: string): void => { + deps.store.appendStep(podId, { + at: nowIso(), + step: 'watchdog', + event: 'note', + by: 'watchdog', + note: text, + }); + }; + + const signalsFor = (state: AppState, podId: string): PodSignals => { + const pod = deps.store.requirePod(podId); + const turns = { + manager: deps.store.readTurn(podId, 'manager'), + dev: deps.store.readTurn(podId, 'dev'), + }; + const busy = (agent: Agent): boolean => { + const turn = turns[agent]; + + return (turn.startedAt ?? '') > (turn.endedAt ?? ''); + }; + + return { + pod, + running: deps.windowsFor(podId).length > 0, + hasInboxItems: state.inbox.some( + (entry) => entry.podId === podId && entry.kind !== 'stalled' + ), + turns, + thread: deps.store.readThread(podId), + questions: deps.store.listQuestions(podId), + steps: deps.store.readSteps(podId), + // The file the agent is really writing, as its hook reports it. After a + // resume or fork that is NOT the pod's original session file — which + // goes quiet and would make a busy dev look hung. Unknown until + // the agent's next hook: no hang check rather than a wrong one. + transcriptAt: { + manager: + turns.manager.transcriptPath === undefined + ? undefined + : mtime(turns.manager.transcriptPath), + dev: + turns.dev.transcriptPath === undefined + ? undefined + : mtime(turns.dev.transcriptPath), + }, + screens: { + manager: busy('manager') ? deps.capture(podId, 'manager') : undefined, + dev: busy('dev') ? deps.capture(podId, 'dev') : undefined, + }, + }; + }; + + const tick = (now: number = Date.now()): void => { + const state = buildState(deps.store, deps.windowsFor); + const changed = deps.store + .listPodIds() + // A building pod has its own stall watch, on its runner. + .filter((podId) => deps.store.readPod(podId)?.kind !== 'building') + .map((podId): boolean => { + const { verdict, episode } = judge( + signalsFor(state, podId), + episodes.get(podId), + now + ); + + if (episode === undefined) { + episodes.delete(podId); + } else { + episodes.set(podId, episode); + } + + if (verdict.kind === 'nudge') { + note( + podId, + `Nudged the manager (${verdict.attempt} of ${MAX_NUDGES}): nothing was running and nothing was waiting on you.` + ); + deps + .nudge(podId) + .catch((error: unknown) => + note(podId, `Nudge failed: ${String(error).slice(0, 200)}`) + ); + } + + if (verdict.kind === 'resend') { + note( + podId, + `Re-sent ${verdict.msgIds.length} message(s) an idle agent never took in.` + ); + verdict.msgIds.forEach((msgId) => + deps + .resend(podId, msgId) + .catch((error: unknown) => + note( + podId, + `Re-send of ${msgId} failed: ${String(error).slice(0, 200)}` + ) + ) + ); + + return false; + } + + if (verdict.kind === 'escalate') { + const previous = escalated.get(podId); + escalated.set(podId, verdict.reason); + + // "…for 17 minutes…" then "…for 18 minutes…" is one escalation, not + // a new one a minute. + if ( + previous !== undefined && + sameEscalation(previous, verdict.reason) + ) { + return false; + } + + note(podId, `Sent to your inbox: ${verdict.reason}.`); + + return true; + } + + // Moving again (or waiting between nudges): no longer the owner's. + return verdict.kind === 'ok' && escalated.delete(podId); + }) + .some(Boolean); + + if (changed) { + deps.onChange(); + } + }; + + return { + tick, + escalated: (): ReadonlyMap => escalated, + }; +}; diff --git a/src/app/server/workflowView.ts b/src/app/server/workflowView.ts new file mode 100644 index 0000000..5695ea5 --- /dev/null +++ b/src/app/server/workflowView.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs'; + +import { + type PodStore, + type Question, + type RenderMachine, + type StageStatus, + type StepEvent, + type Turn, + WRITES_TICKETS, + type WorkflowStage, + isIdle, + podRenderMode, + stagesFor, + stepHistory, + workflowStatus, +} from '../../pod-store'; +import type { + AgentState, + TicketCell, + TicketRow, + TimeSummary, + WorkflowView, +} from '../shared/types'; + +/** + * The sidebar's workflow panel for one pod: every stage and step with where + * the pod is (pod-store's workflowStatus), each step's history across ALL + * pods (does it earn its place?), the owner's own time per stage, what each agent + * is doing, and what the watchdog did. + */ + +const agentState = (turn: Turn): AgentState => + isIdle(turn) + ? { working: false, since: turn.endedAt } + : { working: true, since: turn.startedAt }; + +/** The items the owner spends their time on in a stage. */ +const stageQuestions = ( + stage: WorkflowStage, + questions: Question[] +): Question[] => + questions.filter((question) => + stage.kind === 'checkpoint' + ? question.kind === 'checkpoint' && + question.checkpoint?.cp === stage.checkpoint + : question.kind !== 'checkpoint' && + question.phase === stage.phase && + // The UX review runs in phase 3 after CP3; its questions are split's. + stage.id !== 'ux-review' + ); + +/** One ticket's state in one stage, from the steps reported for it. */ +const ticketCell = (stage: WorkflowStage, events: StepEvent[]): TicketCell => { + const ids = new Set(stage.steps.map((step) => step.id)); + const mine = events.filter( + (event) => ids.has(event.step) && event.event !== 'note' + ); + const last = mine.at(-1); + + if (last === undefined) { + return 'todo'; + } + + return last.event === 'start' ? 'now' : 'done'; +}; + +/** The batch grid: a row per ticket, a cell per stage that has steps. */ +const ticketRows = ( + tickets: string[], + stages: WorkflowStage[], + steps: StepEvent[] +): TicketRow[] => + tickets.map((id) => { + const events = steps.filter((event) => event.ticket === id); + + return { + id, + cells: Object.fromEntries( + stages + .filter((stage) => stage.steps.length > 0) + .map((stage) => [stage.id, ticketCell(stage, events)]) + ), + }; + }); + +const yourMsOn = ( + time: TimeSummary, + podId: string, + questions: Question[] +): number => + questions.reduce( + (sum, question) => sum + (time.pods[podId]?.items[question.id] ?? 0), + 0 + ); + +/** + * `machine`: the `design` the pod's runner reports with its status (the + * runner reads its own `~/.lambda/config.json`; this view never does). + */ +export const buildWorkflowView = ( + store: PodStore, + podId: string, + time: TimeSummary, + machine: RenderMachine +): WorkflowView => { + const pod = store.requirePod(podId); + const steps = store.readSteps(podId); + const questions = store.listQuestions(podId); + const files = new Set( + fs.existsSync(store.podDir(podId)) + ? fs.readdirSync(store.podDir(podId)) + : [] + ); + const statuses: StageStatus[] = workflowStatus({ + pod, + questions, + steps, + files, + }); + const allPods = store.listPodIds(); + const avgYourMs = (stage: WorkflowStage): number | undefined => { + if (stage.kind !== 'checkpoint') { + return undefined; + } + + const spent = allPods + .map((other) => + yourMsOn(time, other, stageQuestions(stage, store.listQuestions(other))) + ) + .filter((ms) => ms > 0); + + return spent.length === 0 + ? undefined + : spent.reduce((sum, ms) => sum + ms, 0) / spent.length; + }; + + return { + stages: stagesFor(pod).map((stage, index) => ({ + stage, + status: statuses[index] ?? { id: stage.id, state: 'todo', steps: [] }, + yourMs: yourMsOn(time, podId, stageQuestions(stage, questions)), + avgYourMs: avgYourMs(stage), + })), + history: stepHistory(allPods.map((other) => store.readSteps(other))), + agents: { + manager: agentState(store.readTurn(podId, 'manager')), + dev: agentState(store.readTurn(podId, 'dev')), + }, + watchdog: steps + .filter((event) => event.by === 'watchdog' && event.note !== undefined) + .slice(-5) + .map((event) => ({ at: event.at, note: event.note ?? '' })), + requests: store.listRequests(podId), + freeform: pod.mode === 'freeform', + renderMode: podRenderMode(pod, machine), + ...(machine.design === undefined + ? {} + : { designAvailable: machine.design.available }), + confirmSkip: [...WRITES_TICKETS], + ...(pod.source === 'batch' + ? { tickets: ticketRows(pod.tickets ?? [], stagesFor(pod), steps) } + : {}), + }; +}; diff --git a/src/app/shared/autosave.ts b/src/app/shared/autosave.ts new file mode 100644 index 0000000..4b696e5 --- /dev/null +++ b/src/app/shared/autosave.ts @@ -0,0 +1,112 @@ +/** + * Saving as you type, Linear's way: a save goes out once typing has paused, + * and once more on leaving the box; never two at once, and never a save of + * what was already saved. Pure: the client bundles this file, and the timer + * is handed in. + */ + +export const AUTOSAVE_PAUSE_MS = 800; + +export interface AutosaveTimer { + set: (run: () => void, ms: number) => unknown; + clear: (handle: unknown) => void; +} + +export interface Autosave { + /** The text now: saved once typing pauses. */ + changed: (text: string) => void; + /** Leaving the box (or the page): save now if anything is waiting. */ + flush: () => void; + /** What the server has: a later `changed` back to it saves nothing. */ + saved: (text: string) => void; + /** Saving or waiting to: the page doesn't overwrite the box meanwhile. */ + busy: () => boolean; +} + +export const createAutosave = ( + initial: string, + save: (text: string) => Promise, + timer: AutosaveTimer +): Autosave => { + const state: { + last: string; + pending: string | null; + handle: unknown; + inFlight: boolean; + } = { last: initial, pending: null, handle: null, inFlight: false }; + + const send = (): void => { + state.handle = null; + const text = state.pending; + + if (text === null || state.inFlight) { + return; + } + + state.pending = null; + + if (text === state.last) { + return; + } + + state.inFlight = true; + save(text) + .then(() => { + state.last = text; + + // Typed while that save was out: send what's newest now. + return true; + }) + .catch(() => { + // Kept for the next pause or leave; never retried in a loop. + state.pending = state.pending ?? text; + + return false; + }) + .then((ok) => { + state.inFlight = false; + + if (ok && state.pending !== null && state.handle === null) { + send(); + } + }); + }; + + return { + changed: (text) => { + state.pending = text; + + if (state.handle !== null) { + timer.clear(state.handle); + } + + state.handle = timer.set(send, AUTOSAVE_PAUSE_MS); + }, + flush: () => { + if (state.handle !== null) { + timer.clear(state.handle); + state.handle = null; + } + + send(); + }, + saved: (text) => { + state.last = text; + }, + busy: () => state.inFlight || state.pending !== null, + }; +}; + +/** + * The editor's Markdown, put back the way a person writes it: a bare link or + * email stays bare (not `[x](x)` or `[x](mailto:x)`), and blocks are one + * blank line apart. + */ +export const tidyMarkdown = (markdown: string): string => + markdown + .replace( + /\[([^\]\n]+)\]\((?:mailto:)?([^)\s]+)\)/g, + (whole, text: string, href: string) => (text === href ? text : whole) + ) + .replace(/\n{3,}/g, '\n\n') + .trimEnd(); diff --git a/src/app/shared/buildTimeline.ts b/src/app/shared/buildTimeline.ts new file mode 100644 index 0000000..8ef537f --- /dev/null +++ b/src/app/shared/buildTimeline.ts @@ -0,0 +1,557 @@ +import type { TimelineEvent } from './building'; + +/** + * The building pod's Workflow rail, from the project's stored events: each + * step of a ticket with its time (retries summed, "Test · attempt 2"), Ship + * opened into the stages the project reported, This ticket against Typical + * ticket, and the Whole run's share per step. Pure: events in, numbers out. + * Time only, never cost. + */ + +const STEP_NAMES: Record = { + 'pick-up': 'Pick up', + build: 'Build', + 'self-review': 'Self-review', + 'check-ticket': 'Check ticket', + 'visual-check': 'Visual check', + 'design-check': 'Design check', + test: 'Test', + ship: 'Ship', +}; + +/** A step's short name ("Test"); an added step's id, spaced and capitalised. */ +export const stepDisplayName = (id: string): string => { + const known = STEP_NAMES[id]; + + if (known !== undefined) { + return known; + } + + const spaced = id.replace(/[-_]+/g, ' ').trim(); + + return `${spaced.charAt(0).toUpperCase()}${spaced.slice(1)}`; +}; + +/** "4m", "1h 52m". */ +export const formatMinutes = (ms: number): string => { + const minutes = Math.round(ms / 60_000); + + if (minutes < 60) { + return `${minutes}m`; + } + + const rest = minutes % 60; + + return `${Math.floor(minutes / 60)}h${rest === 0 ? '' : ` ${rest}m`}`; +}; + +interface Span { + name: string; + ticket: string | null; + attempt: number | null; + from: number; + to: number | null; +} + +/** Each stage.start with the first stage.end of the same name, ticket and attempt after it. */ +const spansOf = (events: TimelineEvent[]): Span[] => { + const ordered = [...events].sort((a, b) => a.at - b.at); + const open = new Map(); + const spans: Span[] = []; + const key = (event: TimelineEvent) => + [event.name ?? '', event.ticket ?? '', event.attempt ?? ''].join('\u0000'); + + ordered.forEach((event) => { + if (event.type === 'stage.start') { + const span: Span = { + name: event.name ?? '', + ticket: event.ticket, + attempt: event.attempt, + from: event.at, + to: null, + }; + spans.splice(spans.length, 0, span); + open.set(key(event), [...(open.get(key(event)) ?? []), span]); + + return; + } + + if (event.type !== 'stage.end') { + return; + } + + const waiting = open.get(key(event)) ?? []; + const match = waiting.at(-1); + + if (match !== undefined) { + match.to = event.at; + open.set(key(event), waiting.slice(0, -1)); + } + }); + + return spans; +}; + +/** + * Each `ship.start` … `ship.end` of a ticket. A run that never reported its + * end (it died) is over once the ticket moved on: at its next `ship.start`, + * when the Ship step ended, or when the Ship step or Pick up started again. + * Only a run with none of those after it is still going. + */ +const shipWindows = ( + events: TimelineEvent[] +): { ticket: string | null; from: number; to: number }[] => { + const ordered = [...events].sort((a, b) => a.at - b.at); + const endsRun = (event: TimelineEvent, start: TimelineEvent): boolean => + event.type === 'ship.end' || + event.type === 'ship.start' || + (event.type === 'stage.end' && event.name === 'ship') || + // The Ship step's own start can share the run's millisecond. + (event.type === 'stage.start' && + event.at > start.at && + (event.name === 'ship' || event.name === 'pick-up')); + + return ordered.flatMap((start, index) => + start.type === 'ship.start' + ? [ + { + ticket: start.ticket, + from: start.at, + to: + ordered + .slice(index + 1) + .find( + (event) => + event.ticket === start.ticket && + event.at >= start.at && + endsRun(event, start) + )?.at ?? Number.POSITIVE_INFINITY, + }, + ] + : [] + ); +}; + +/** + * A ticket's step spans: a stage that starts inside a `lambda ship` run (its + * `test` and `commit` parts, a project's own) is a part of that ship, never + * the step of the same name. The Ship step's own `ship` span is the step. + */ +const stepSpansOf = (events: TimelineEvent[]): Span[] => { + const windows = shipWindows(events); + + return spansOf(events).filter( + (span) => + span.name === 'ship' || + !windows.some( + (window) => + window.ticket === span.ticket && + span.from >= window.from && + span.from <= window.to + ) + ); +}; + +export type StepStatus = 'done' | 'active' | 'todo' | 'skipped'; + +export interface StepTime { + id: string; + /** + * "Test", or "Test · attempt 2" once retried; "Visual check · skipped: no + * design" for a step skipped rather than run. + */ + label: string; + /** Every attempt's time summed; an open attempt counts up to `now`. */ + ms: number; + attempt: number; + status: StepStatus; +} + +export interface TicketTimeline { + ticket: string; + steps: StepTime[]; + /** "Attempt 2", then each ship stage and Fixing, in the order first reported. */ + shipStages: string[]; + /** Pick-up start to ship done; null until it has shipped. */ + totalMs: number | null; +} + +/** + * The latest `lambda ship` run's parts: a failed ship is run again, and the + * rail shows the run that's going (or last went), not the first one. + */ +const shipStagesOf = (events: TimelineEvent[]): string[] => { + const ordered = [...events].sort((a, b) => a.at - b.at); + const window = shipWindows(ordered).at(-1); + + if (window === undefined) { + return []; + } + + const within = ordered.filter( + (event) => event.at >= window.from && event.at <= window.to + ); + // Every stage reported inside the ship run is one of its parts, a + // `test` included; only the Ship step's own `ship` isn't. + const stages = spansOf(within).filter( + (span) => span.name !== '' && span.name !== 'ship' + ); + const firstSeen = new Map(); + stages.forEach((span) => { + const seen = firstSeen.get(span.name); + const ms = span.to === null ? 0 : span.to - span.from; + firstSeen.set(span.name, { + at: seen?.at ?? span.from, + ms: (seen?.ms ?? 0) + ms, + }); + }); + const attemptStarts = within.filter( + (event) => event.type === 'attempt.start' + ); + const attemptEnds = within.filter((event) => event.type === 'attempt.end'); + const fixing = attemptEnds + .filter((event) => event.result === 'fail') + .flatMap((failed) => { + const next = attemptStarts.find((started) => started.at >= failed.at); + + return next === undefined + ? [] + : [{ at: failed.at, ms: next.at - failed.at }]; + }); + const parts = [ + ...[...firstSeen.entries()].map(([name, seen]) => ({ + at: seen.at, + text: `${stepDisplayName(name)} ${formatMinutes(seen.ms)}`, + })), + ...(fixing.length === 0 + ? [] + : [ + { + at: fixing[0]?.at ?? 0, + text: `Fixing ${formatMinutes( + fixing.reduce((sum, one) => sum + one.ms, 0) + )}`, + }, + ]), + ].sort((a, b) => a.at - b.at); + const attempts = Math.max( + 0, + ...[...attemptStarts, ...attemptEnds].map((event) => event.attempt ?? 0) + ); + + return [ + ...(attempts === 0 ? [] : [`Attempt ${attempts}`]), + ...parts.map((part) => part.text), + ]; +}; + +/** Pick-up start to ship done, from one ticket's own events; null until shipped. */ +const ticketSpan = ( + own: TimelineEvent[] +): { from: number; to: number } | null => { + const spans = spansOf(own); + const end = spans + .filter((span) => span.name === 'ship' && span.to !== null) + .at(-1)?.to; + + if (end === undefined || end === null) { + return null; + } + + // The pick-up that led to the ship: a ticket blocked on day 1 and taken + // again on day 3 took the time since day 3, not since day 1. + const pickUp = spans + .filter((span) => span.name === 'pick-up' && span.from <= end) + .at(-1); + + return pickUp === undefined ? null : { from: pickUp.from, to: end }; +}; + +/** A ticket's run, as the pod's building state keeps it. */ +export interface RunWindow { + startedAt: string; + shippedAt?: string; + blocked?: { at: string }; +} + +/** + * Which events count: only `pod`'s (an event naming no pod is the + * project's, and counts), and with `run`, only that run's, from its start. + */ +export interface TimelineScope { + pod?: string; + run?: RunWindow; +} + +const isPods = + (pod: string | undefined) => + (event: TimelineEvent): boolean => + pod === undefined || event.pod === null || event.pod === pod; + +/** When a run stopped (shipped or blocked); null while it's going. */ +const runEnd = (run: RunWindow | undefined): number | null => { + const end = run?.shippedAt ?? run?.blocked?.at; + + return end === undefined ? null : Date.parse(end); +}; + +/** + * Each step span with its time. One that never reported its end is over + * when the ticket's next step started, or when its run stopped; only the + * last one of a run that's still going counts up to `now`. + */ +const timedSpansOf = ( + events: TimelineEvent[], + until: number | null, + now: number +): (Span & { ms: number })[] => { + const spans = stepSpansOf(events); + + return spans.map((span) => { + if (span.to !== null) { + return { ...span, ms: span.to - span.from }; + } + + const end = Math.min( + now, + until ?? Number.POSITIVE_INFINITY, + ...spans + .filter( + (other) => other.ticket === span.ticket && other.from > span.from + ) + .map((other) => other.from) + ); + + return { ...span, ms: Math.max(0, end - span.from) }; + }); +}; + +/** + * One ticket's steps, in the project's order, with their times. A step + * switched off (`off`) never runs: it's shown only when this ticket reported + * it skipped ("Visual check · skipped: switched off"). + */ +export const ticketTimeline = ( + events: TimelineEvent[], + ticket: string, + steps: { id: string; label: string; off?: boolean }[], + now: number = Date.now(), + scope: TimelineScope = {} +): TicketTimeline => { + const from = + scope.run === undefined + ? Number.NEGATIVE_INFINITY + : Date.parse(scope.run.startedAt); + const own = events.filter( + (event) => + event.ticket === ticket && event.at >= from && isPods(scope.pod)(event) + ); + const ended = runEnd(scope.run); + const spans = timedSpansOf(own, ended, now); + const stepIds = new Set(steps.map((one) => one.id)); + const latest = [...spans] + .filter((span) => stepIds.has(span.name)) + .sort((a, b) => a.from - b.from) + .at(-1); + + // A skip opens no span: it's a lone `stage.end` marked skipped. + const skips = own.filter( + (event) => event.type === 'stage.end' && event.skipped === true + ); + + const skipped = new Set(skips.map((event) => event.name)); + // A step that ended without a start the agent reported (Ship, when the + // Engineering manager went straight to `/ship`): done, its time unknown. + const endedAlone = new Set( + own + .filter( + (event) => + event.type === 'stage.end' && + event.skipped !== true && + event.result !== 'fail' + ) + .map((event) => event.name) + ); + + return { + ticket, + steps: steps + .filter((one) => one.off !== true || skipped.has(one.id)) + .map((one) => { + const mine = spans.filter((span) => span.name === one.id); + const skip = skips.filter((event) => event.name === one.id).at(-1); + + if (mine.length === 0 && skip !== undefined) { + const why = skip.skipReason ?? ''; + + return { + id: one.id, + label: `${stepDisplayName(one.id)} · skipped${ + why === '' ? '' : `: ${why}` + }`, + ms: 0, + attempt: 0, + status: 'skipped', + }; + } + const attempt = Math.max( + 1, + mine.length, + ...mine.map((span) => span.attempt ?? 1) + ); + const ms = mine.reduce((sum, span) => sum + span.ms, 0); + const status: StepStatus = + mine.length === 0 + ? endedAlone.has(one.id) + ? 'done' + : 'todo' + : ended === null && + latest !== undefined && + latest.name === one.id && + latest.to === null + ? 'active' + : 'done'; + + return { + id: one.id, + label: `${stepDisplayName(one.id)}${ + attempt > 1 ? ` · attempt ${attempt}` : '' + }`, + ms, + attempt, + status, + }; + }), + shipStages: shipStagesOf(own), + totalMs: (() => { + const span = ticketSpan(own); + + return span === null ? null : span.to - span.from; + })(), + }; +}; + +/** + * The rail's This ticket: pick-up to ship once shipped (`totalMs`), else + * from its start up to when it stopped — shipped, blocked, or still going + * (then now). A blocked ticket's time doesn't keep counting while it waits. + */ +export const thisTicketMs = ( + run: { startedAt: string; shippedAt?: string; blocked?: { at: string } }, + totalMs: number | null, + now: number +): number => { + if (totalMs !== null) { + return totalMs; + } + + const end = run.shippedAt ?? run.blocked?.at; + + return Math.max( + 0, + (end === undefined ? now : Date.parse(end)) - Date.parse(run.startedAt) + ); +}; + +/** Pick-up start to ship done, for every shipped ticket in the events. */ +export const shippedTicketDurations = (events: TimelineEvent[]): number[] => { + // Grouped once: the project's every event is read on each page poll. + const byTicket = new Map(); + events.forEach((event) => { + if (event.ticket !== null) { + byTicket.set(event.ticket, [ + ...(byTicket.get(event.ticket) ?? []), + event, + ]); + } + }); + + return [...byTicket.values()].flatMap((own) => { + const span = ticketSpan(own); + + return span === null ? [] : [span.to - span.from]; + }); +}; + +/** The median; the mean of the middle two for an even count; none under 2. */ +export const typicalTicketMs = (durations: number[]): number | null => { + if (durations.length < 2) { + return null; + } + + const sorted = [...durations].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 1) { + return sorted[middle] ?? null; + } + + return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2; +}; + +export interface WholeRun { + totalMs: number; + steps: { id: string; label: string; ms: number; percent: number }[]; +} + +/** + * Each step's total and share of the time across `tickets` (this pod's). + * With `runs` (the pod's, in the order taken), each run counts its own + * events, from its start up to the next run of the same ticket. + */ +export const wholeRun = ( + events: TimelineEvent[], + tickets: string[], + steps: { id: string; label: string }[], + now: number = Date.now(), + scope: { pod?: string; runs?: (RunWindow & { id: string })[] } = {} +): WholeRun => { + const taken = new Set(tickets); + const mine = events.filter( + (event) => + event.ticket !== null && + taken.has(event.ticket) && + isPods(scope.pod)(event) + ); + const { runs } = scope; + const spans = + runs === undefined + ? timedSpansOf(mine, null, now) + : runs.flatMap((run, index) => { + const from = Date.parse(run.startedAt); + const nextStart = runs + .slice(index + 1) + .find((later) => later.id === run.id)?.startedAt; + const to = + nextStart === undefined + ? Number.POSITIVE_INFINITY + : Date.parse(nextStart); + + return taken.has(run.id) + ? timedSpansOf( + mine.filter( + (event) => + event.ticket === run.id && event.at >= from && event.at < to + ), + runEnd(run), + now + ) + : []; + }); + const totals = steps.map((one) => ({ + id: one.id, + label: stepDisplayName(one.id), + ms: spans + .filter((span) => span.name === one.id) + .reduce((sum, span) => sum + span.ms, 0), + })); + const totalMs = totals.reduce((sum, one) => sum + one.ms, 0); + + return { + totalMs, + steps: totals.map((one) => ({ + ...one, + percent: totalMs === 0 ? 0 : Math.round((one.ms / totalMs) * 100), + })), + }; +}; diff --git a/src/app/shared/building.ts b/src/app/shared/building.ts new file mode 100644 index 0000000..f0b66b5 --- /dev/null +++ b/src/app/shared/building.ts @@ -0,0 +1,668 @@ +import type { + BuildTicketRun, + BuildingState, + Pod, + Screenshot, + ThreadMessage, +} from '../../pod-store'; +import type { TrackerProject } from '../../tickets/source'; +import type { Priority, Ticket, TicketState } from '../../tickets/source'; +import { pendingReplies } from './conversation'; + +/** + * What the app shows of building pods and the Tickets screen, and the shapes + * the mothership's routes answer with. Pure: the client bundles this file. + */ + +// ── a building pod in the sidebar ─────────────────────────────────────────── + +export type BuildingStateName = + | 'building' + | 'testing' + | 'shipping' + | 'blocked' + | 'paused' + | 'idle'; + +export interface BuildingSummary { + state: BuildingStateName; + /** The ticket it's on, and its step and attempt there. */ + current: Pick | null; + /** "ticket N of M": null before it has taken a ticket. */ + position: { n: number; m: number } | null; + /** With nothing current: "Nothing ready to build", or why it's waiting. */ + idleReason: string | null; + /** The last ticket it shipped, and when (ISO); left out by older pods. */ + lastShipped?: { id: string; at: string } | null; + /** + * Every ticket it has taken, oldest first (the newest 60): where each + * Lambda's tickets are, for the sidebar and a Lambda's page. Left out by + * older pods. + */ + runs?: RunView[]; + /** Its queue after the current ticket, in order: "3rd in line". */ + upNext?: Array<{ id: string; title: string }>; +} + +/** One ticket a building pod took, as the sidebar sees it. */ +export interface RunView { + id: string; + title: string; + step: string; + startedAt: string; + shippedAt: string | null; + /** Its Blocked item, while it's blocked: the message the owner answers. */ + blocked: { messageId: string | null } | null; +} + +/** How many runs a summary carries. */ +export const RUNS_KEPT = 60; + +const runView = (run: BuildTicketRun): RunView => ({ + id: run.id, + title: run.title, + step: run.step, + startedAt: run.startedAt, + shippedAt: run.shippedAt ?? null, + blocked: + run.blocked === undefined + ? null + : { messageId: run.blocked.messageId ?? null }, +}); + +/** The run a pod is building now: the last one neither shipped nor blocked. */ +export const currentRun = ( + state: Pick +): BuildTicketRun | null => { + const last = state.runs.at(-1); + + return last === undefined || + last.shippedAt !== undefined || + last.blocked !== undefined + ? null + : last; +}; + +/** + * Pause/Resume is held from its click (`clickedFrom`, the pod's status then; + * null once it failed) until the pod's status is no longer that: the label + * only changes on the next state push, after the request has settled, and a + * second click in that gap would send the same one again. + */ +export const pauseHeld = ( + clickedFrom: Pod['status'] | null, + status: Pod['status'] +): boolean => clickedFrom !== null && clickedFrom === status; + +/** + * The building page header's state line: the state push's, live, over the + * page's own read, which is up to 5 seconds old (after Pause the header + * would say "building" while the sidebar already says "paused"). + */ +export const headerBuilding = ( + live: BuildingSummary | undefined, + polled: BuildingSummary | undefined +): BuildingSummary | undefined => live ?? polled; + +/** + * A run's screenshots of one test attempt. A ticket blocked and taken again + * numbers its attempts from 1 again, into the same manifest, so an earlier + * run's shots are left out: only those taken since this run started. + */ +export const runShots = ( + screenshots: Record, + run: Pick, + attempt: number +): Screenshot[] => + (screenshots[`${run.id}/${attempt}`] ?? []).filter( + (shot) => Date.parse(shot.at) >= Date.parse(run.startedAt) + ); + +/** + * The tickets a pod has taken, each once, in the order it last took them: + * the current ticket is always the last. + */ +export const takenTickets = (state: Pick): string[] => + state.runs + .map((run) => run.id) + .filter((id, index, ids) => ids.lastIndexOf(id) === index); + +/** + * Blocked items whose ticket is no longer blocked: set back to Ready, it's in + * the runner's last poll (Up next) or a later run took it again. Their ids, + * so the inbox leaves them out without the owner having to answer. + */ +export const unblockedAsks = ( + state: Pick +): Set => + new Set( + state.runs.flatMap((run, index) => + run.blocked?.messageId !== undefined && + (state.upNext.some((ticket) => ticket.id === run.id) || + state.runs.slice(index + 1).some((later) => later.id === run.id)) + ? [run.blocked.messageId] + : [] + ) + ); + +/** What a folded block adds to its item (building/breaker.ts). */ +export const alsoHeldText = (held: { id: string; title: string }[]): string => + `**Also held by this, not started and back in Ready:** ${held + .map((one) => `${one.id} ${one.title}`) + .join('; ')}. Lambda paused this builder.`; + +/** + * A building pod's thread with each Blocked item listing the tickets folded + * into it: the thread is append-only, so the item's text grows as served. + */ +export const withAlsoHeld = ( + thread: ThreadMessage[], + state: Pick | null +): ThreadMessage[] => { + const held = new Map( + (state?.runs ?? []).flatMap((run) => + run.blocked?.messageId !== undefined && + (run.blocked.alsoHeld ?? []).length > 0 + ? [[run.blocked.messageId, run.blocked.alsoHeld ?? []] as const] + : [] + ) + ); + + return held.size === 0 + ? thread + : thread.map((message) => { + const list = held.get(message.id); + + return list === undefined + ? message + : { ...message, text: `${message.text}\n\n${alsoHeldText(list)}` }; + }); +}; + +/** + * The items a building pod is waiting on the owner for, oldest first: the + * inbox's "Your turn" entries for it, so its page can answer them too. A + * Blocked item drops out once its ticket is back in the queue; a one-ticket + * talk (`null`) has no queue to check. + */ +export const buildingReplyItems = ( + thread: ThreadMessage[], + state: Pick | null +): ThreadMessage[] => { + const unblocked = state === null ? new Set() : unblockedAsks(state); + + return pendingReplies(thread).filter((message) => !unblocked.has(message.id)); +}; + +/** + * The Tickets rail: Done (shipped, oldest first), Current and Up next. A + * blocked ticket isn't done: it's in the owner's inbox and the Tickets + * screen's Blocked group. Nor is one let go when its pod ended. + */ +export const ticketsRail = ( + state: Pick +): { + done: BuildTicketRun[]; + current: BuildTicketRun | null; + upNext: BuildingState['upNext']; +} => ({ + done: state.runs.filter((run) => run.shippedAt !== undefined), + current: currentRun(state), + upNext: state.upNext, +}); + +/** + * The sidebar's state: `test` → testing, `ship` → shipping, any other step + * (an added one too) → building. Paused wins whatever the step. With no + * current ticket: blocked when the last ticket went Blocked and nothing is + * takeable, else idle. N counts every ticket taken (shipped, blocked and the + * current one); M adds what's still queued. Each ticket counts once: one + * blocked, set back to Ready and taken again is one ticket, not two. + */ +export const buildingStateOf = ( + pod: Pick, + state: BuildingState +): BuildingSummary => { + const current = currentRun(state); + const taken = takenTickets(state); + const n = taken.length; + const queued = state.upNext.filter((ticket) => !taken.includes(ticket.id)); + const position = n === 0 ? null : { n, m: n + queued.length }; + const base = { + current: + current === null + ? null + : { + id: current.id, + title: current.title, + step: current.step, + attempt: current.attempt, + }, + position, + idleReason: current === null ? state.idleReason ?? null : null, + lastShipped: lastShippedOf(state), + runs: state.runs.slice(-RUNS_KEPT).map(runView), + upNext: state.upNext.map(({ id, title }) => ({ id, title })), + }; + + if (pod.status === 'paused') { + return { ...base, state: 'paused' }; + } + + if (current !== null) { + return { + ...base, + state: + current.step === 'test' + ? 'testing' + : current.step === 'ship' + ? 'shipping' + : 'building', + }; + } + + const last = state.runs.at(-1); + + return { + ...base, + state: + last?.blocked !== undefined && state.upNext.length === 0 + ? 'blocked' + : 'idle', + }; +}; + +/** "testing · ticket 3 of 7", "idle · Nothing ready to build". */ +export const buildingLine = (summary: BuildingSummary): string => { + const position = + summary.position === null + ? null + : `ticket ${summary.position.n} of ${summary.position.m}`; + const detail = + summary.current === null ? summary.idleReason ?? position : position; + + return detail === null ? summary.state : `${summary.state} · ${detail}`; +}; + +export const NOTHING_READY_TO_BUILD = 'Nothing ready to build'; + +const lastShippedOf = ( + state: Pick +): { id: string; at: string } | null => { + const shipped = state.runs.filter( + (run): run is BuildTicketRun & { shippedAt: string } => + run.shippedAt !== undefined + ); + const last = shipped.at(-1); + + return last === undefined ? null : { id: last.id, at: last.shippedAt }; +}; + +/** "just now", "4m ago", "2h ago", "3d ago". */ +export const agoShort = (then: number, now: number): string => { + const minutes = Math.floor((now - then) / 60_000); + + if (minutes < 1) { + return 'just now'; + } + + if (minutes < 60) { + return `${minutes}m ago`; + } + + const hours = Math.floor(minutes / 60); + + return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`; +}; + +/** + * The sidebar's second line for a building computer: `testing · PAWNEE-2` + * while on a ticket, `paused`, `idle · shipped PAWNEE-1 4m ago` after a + * ship, else the waiting reason. + */ +export const sidebarBuildingLine = ( + summary: BuildingSummary, + now: number +): string => { + if (summary.state === 'paused') { + return 'paused'; + } + + if (summary.current !== null) { + return `${summary.state} · ${summary.current.id}`; + } + + const shipped = summary.lastShipped ?? null; + + if (summary.state === 'idle' && shipped !== null) { + return `idle · shipped ${shipped.id} ${agoShort( + Date.parse(shipped.at), + now + )}`; + } + + return buildingLine(summary); +}; + +/** + * The sidebar's Conversations: each "Talk about this ticket" still open. The + * Product manager answers them in the pod, never in the inbox, so this is + * the way back to one. + */ +export const openTalks = }>( + summaries: T[] +): T[] => + summaries.filter( + (summary) => summary.status !== 'done' && summary.pod.talk !== undefined + ); + +/** + * A building pod's page re-reads its pod on app events, but at most this + * often: a busy project sends many. A tick inside the wait is read once it + * is up, so the page is never left on a stale read. + */ +export const BUILDING_REFRESH_MS = 5_000; + +/** + * What a rail shows before the page's first answer: the load's error once it + * failed (an offline runner), never "Loading…" for ever. + */ +export const railPlaceholder = (error: string | null): string => + error === null ? 'Loading…' : `Couldn't load this pod: ${error}`; + +/** How long until the page reads again: 0 for now. */ +export const buildingRefreshIn = (lastAt: number | null, now: number): number => + lastAt === null ? 0 : Math.max(0, lastAt + BUILDING_REFRESH_MS - now); + +/** + * Which building page a pod gets: a full building pod (Pause, the Workflow + * and Tickets rails), or a one-ticket "Talk about this ticket" conversation + * with the Product manager and nothing else. A planning pod gets neither. + */ +export const buildingScreenMode = ( + pod: Pick +): 'building' | 'talk' | null => { + if (pod.kind !== 'building') { + return null; + } + + return pod.talk === undefined ? 'building' : 'talk'; +}; + +// ── the visual check's pictures ───────────────────────────────────────────── + +/** One issue the visual check's judge found in a picture. */ +export interface VisualIssueView { + severity: 'high' | 'medium' | 'medium-low' | 'low'; + where: string; + what: string; +} + +/** + * One picture of a visual check round, as its `manifest.json` lists it: + * `expected` and `actual` are file names in the round's folder, + * `//visual///`. + */ +export interface VisualPictureView { + scenarioId: string; + situation: string; + expected: string; + actual: string; + issues: VisualIssueView[]; +} + +/** A recorded round: its pictures, and when it was copied (ISO). */ +export interface VisualRoundView { + round: number; + at: string; + pictures: VisualPictureView[]; +} + +/** `GET /api/pod//visual///`: a round's picture. */ +export const visualPictureUrl = ( + podId: string, + ticketId: string, + round: number, + file: string +): string => + `/api/pod/${encodeURIComponent(podId)}/visual/${encodeURIComponent( + ticketId + )}/${round}/${encodeURIComponent(file)}`; + +/** + * The Workflow rail's Visual check: this run's rounds (a ticket blocked and + * taken again starts over), its label ("Visual check · round 2"), and one + * expected | actual pair per picture per round, `missed` on a picture with + * an issue. + */ +export const visualRail = ( + visual: Record, + run: Pick +): { + label: string | null; + rounds: VisualRoundView[]; +} => { + const rounds = (visual[run.id] ?? []) + .filter((round) => Date.parse(round.at) >= Date.parse(run.startedAt)) + .sort((a, b) => a.round - b.round); + const last = rounds.at(-1); + + return { + label: last === undefined ? null : `Visual check · round ${last.round}`, + rounds, + }; +}; + +// ── GET /api/building/ ───────────────────────────────────────────────── + +/** One event the Workflow rail reads (the mothership's stored events). */ +export interface TimelineEvent { + type: string; + /** A stage's or ship part's name (`build`, `security-review`). */ + name: string | null; + ticket: string | null; + attempt: number | null; + pod: string | null; + /** `pass` / `fail`. */ + result: string | null; + /** Epoch ms. */ + at: number; + /** A `stage.end` for a step that was skipped, never run. */ + skipped?: boolean; + /** Why it was skipped ("no design"). */ + skipReason?: string | null; + /** A `ticket.edit`: what it was changed to ("High"), and who by. */ + note?: string | null; + source?: string | null; +} + +/** A building pod's page. */ +export interface BuildingPodDetail { + pod: Pod; + /** The Product manager conversation, with the Engineering manager's bubbles. */ + thread: ThreadMessage[]; + /** Its tmux windows: one Agents tab each. */ + windows: string[]; + building: BuildingState; + summary: BuildingSummary; + /** + * The project's building steps, in order (its workflow.json applied). One + * switched off is marked `off`: shown only where a ticket skipped it. + */ + steps: { id: string; label: string; off?: boolean }[]; + /** `/` → that test attempt's screenshots. */ + screenshots: Record; + /** `` → its visual check rounds, oldest first. */ + visual: Record; + /** The computer it builds on. */ + computer: string; + /** Added by the mothership: this pod's tickets' events, for the timeline. */ + events: TimelineEvent[]; + /** + * Added by the mothership: Typical ticket, the median of every shipped + * ticket in the project (worked out there, so the page isn't sent them all). + */ + typicalMs: number | null; +} + +// ── the Tickets screen ────────────────────────────────────────────────────── + +/** A ticket as the Tickets screen lists it. */ +export interface TicketListItem + extends Pick< + Ticket, + 'id' | 'title' | 'status' | 'parent' | 'labels' | 'url' + > { + projectId: string; + /** `markdown`, `linear`, `jira`, or `pod` for one a pod made. */ + source: string; + /** A pod-made ticket: the pod. */ + podId?: string; + /** Left out by an older computer: read as none. */ + priority?: Priority; + /** The computer it's assigned to; null or left out: unassigned. */ + assignee?: string | null; + blockReason?: string | null; + /** A shipped ticket's commit (short hash), when the project's git has it. */ + commit?: string | null; + /** Added by the mothership: when anything last happened to it (epoch ms). */ + updatedAt?: number | null; + /** The tracker's own state name, when it says more ("In review"). */ + trackerStatus?: string | null; + /** Its project's tickets are read-only here: nothing can be changed. */ + readOnly?: boolean; + /** The tracker's project it's in ("Lot 48"); null or left out: none. */ + project?: string | null; +} + +/** GET /api/tracker-projects: each codebase's tracker projects. */ +export interface TrackerProjectsResponse { + codebases: Array< + | { projectId: string; name: string; projects: TrackerProject[] } + | { projectId: string; name: string; offline: string } + >; +} + +/** What a ticket's commit changed, for the Merged summary. */ +export interface ShippedCommit { + hash: string; + files: Array<{ path: string; added: number; removed: number }>; +} + +/** One message of a ticket's conversation, on its page's Activity. */ +export interface TicketTalk { + /** ISO time. */ + at: string; + from: 'you' | 'product-manager' | 'dev'; + text: string; + /** The pod it's in: "Open the conversation". */ + podId: string; +} + +/** What a person may change on a ticket from the Tickets screen. */ +export interface TicketChanges { + title?: string; + description?: string; + /** Planning, Ready, Blocked or Canceled: Building and Shipped are the build's. */ + status?: TicketState; + priority?: Priority; + assignee?: string | null; + /** Label keys to add. */ + labels?: string[]; + /** Label keys to take off. */ + removeLabels?: string[]; + /** The epic it sits under; null for none. */ + parent?: string | null; + /** The tracker's project, by name; null takes it out. */ + project?: string | null; +} + +/** A new ticket from the New ticket dialog. */ +export interface NewTicket { + title: string; + description?: string; + status?: TicketState; + priority?: Priority; + assignee?: string | null; + labels?: string[]; + parent?: string; + /** The tracker's project, by name. */ + project?: string; +} + +/** The statuses only a pod sets: never chosen on the Tickets screen. */ +export const BUILD_ONLY_STATES: TicketState[] = ['building', 'done']; + +/** GET /api/tickets: every project's tickets, or why one isn't there. */ +export interface TicketsResponse { + projects: ( + | { projectId: string; name: string; tickets: TicketListItem[] } + | { projectId: string; name: string; offline: string } + )[]; + /** Every project's `group.*` events, for the shipping-together groups. */ + groupEvents: (TimelineEvent & { + groupId: string | null; + reason: string | null; + held: boolean | null; + projectId: string; + })[]; +} + +export type SummaryState = + | { state: 'ready'; name: string; today: string; after: string } + | { state: 'writing' } + | { state: 'none' }; + +/** GET /api/tickets//: the ticket page. */ +export interface TicketPage { + projectId: string; + ticket: TicketListItem & { description: string }; + summary: SummaryState; + /** The planning pod it was planned in, for the link. */ + plannedIn: { podId: string; title: string } | null; + /** Its latest check-ticket verdict, for "checked" in the journey. */ + verdict: 'COMPLETE' | 'INCOMPLETE' | null; + /** + * "Talk about this ticket": what was asked and what the Product manager + * (or the planning dev) said back, oldest first. Left out by older + * computers. + */ + talk?: TicketTalk[]; + /** A shipped ticket's commit and the files it changed. */ + shipped?: ShippedCommit | null; + /** Added by the mothership: this ticket's events, for "built Nm". */ + events: TimelineEvent[]; +} + +/** Ticket status → the All tickets group it's listed under. */ +export const TICKET_GROUPS = [ + 'Building', + 'Shipping together', + 'Ready', + 'Blocked', + 'Planning', + 'Inbox', + 'Shipped', + 'Canceled', +] as const; + +export type TicketGroupName = (typeof TICKET_GROUPS)[number]; + +export const groupOfStatus = (status: TicketState): TicketGroupName => { + switch (status) { + case 'building': + return 'Building'; + case 'ready': + return 'Ready'; + case 'planning': + return 'Planning'; + case 'inbox': + return 'Inbox'; + case 'blocked': + return 'Blocked'; + case 'done': + return 'Shipped'; + default: + return 'Canceled'; + } +}; diff --git a/src/app/shared/cardHistory.ts b/src/app/shared/cardHistory.ts new file mode 100644 index 0000000..3df837a --- /dev/null +++ b/src/app/shared/cardHistory.ts @@ -0,0 +1,132 @@ +import type { ProposedTicket, Question, Screen } from '../../pod-store'; + +/** + * A checkpoint's versions as one timeline, and every card in it (its screens, + * its proposed tickets, its decisions) tracked against that timeline: which + * versions added, changed or removed it. + * + * Screens and tickets are known by their title — renumbering isn't a change, + * a renamed card starts again. Decisions are known by their id, and their + * wording comes from the copy each version keeps (`decisionSnapshot`). + */ + +export const screenCardKey = (screen: Screen): string => + `screen:${screen.name.trim()}`; + +export const ticketCardKey = (ticket: ProposedTicket): string => + `ticket:${ticket.title.trim()}`; + +export const decisionCardKey = (decisionId: string): string => + `decision:${decisionId}`; + +export type CardChange = 'added' | 'changed' | 'removed'; + +export interface VersionMeta { + version: number; + at: string; + kind: 'posted' | 'reposted' | 'edited'; + /** How many cards it added, changed or removed. */ + changes: number; +} + +export interface Timeline { + /** Oldest first; the last is the current one. */ + versions: VersionMeta[]; + /** Card key → the versions that touched it. */ + cards: Map>; +} + +/** Every card in one version of a checkpoint: key → what it says. */ +const cardsOf = (question: Question): Map => + new Map([ + ...(question.checkpoint?.screens ?? []).map((screen): [string, string] => [ + screenCardKey(screen), + JSON.stringify(screen), + ]), + ...(question.checkpoint?.proposal ?? []).map((ticket): [string, string] => [ + ticketCardKey(ticket), + JSON.stringify(ticket), + ]), + ...(question.checkpoint?.decisionSnapshot ?? []).map( + (decision): [string, string] => [ + decisionCardKey(decision.id), + JSON.stringify([ + decision.decision, + decision.before, + decision.means, + decision.kind, + decision.why, + decision.tickets, + decision.details, + decision.ux, + decision.instead, + ]), + ] + ), + ]); + +const changeBetween = ( + before: string | undefined, + after: string | undefined +): CardChange | null => { + if (before === after) { + return null; + } + + if (before === undefined) { + return 'added'; + } + + return after === undefined ? 'removed' : 'changed'; +}; + +/** The timeline of a checkpoint's versions (oldest first, current last). */ +export const buildTimeline = (history: Question[]): Timeline => { + const cards = new Map>(); + const contents = history.map(cardsOf); + const versions = history.map((checkpoint, index): VersionMeta => { + const version = checkpoint.checkpoint?.version ?? 1; + const now = contents[index] ?? new Map(); + const before = contents[index - 1] ?? new Map(); + const touched = [...new Set([...before.keys(), ...now.keys()])].flatMap( + (key) => { + const change = changeBetween(before.get(key), now.get(key)); + + return change === null ? [] : [{ key, change }]; + } + ); + touched.forEach(({ key, change }) => { + cards.set(key, new Map([...(cards.get(key) ?? []), [version, change]])); + }); + + return { + version, + at: checkpoint.checkpoint?.versionAt ?? checkpoint.createdAt, + kind: checkpoint.checkpoint?.versionKind ?? 'posted', + // v1 adds everything: that's the post, not a list of changes. + changes: index === 0 ? 0 : touched.length, + }; + }); + + return { versions, cards }; +}; + +/** + * What to say about a card while version `upTo` is on screen: its latest + * change at or before it ("added v3", "changed v8"). Undefined: not in it. + */ +export const lastChange = ( + timeline: Timeline, + key: string, + upTo: number +): { version: number; change: 'added' | 'changed' } | undefined => { + const [latest] = [...(timeline.cards.get(key) ?? [])] + .filter(([version]) => version <= upTo) + .sort(([a], [b]) => b - a); + + if (latest === undefined || latest[1] === 'removed') { + return undefined; + } + + return { version: latest[0], change: latest[1] }; +}; diff --git a/src/app/shared/caughtUp.ts b/src/app/shared/caughtUp.ts new file mode 100644 index 0000000..27f0964 --- /dev/null +++ b/src/app/shared/caughtUp.ts @@ -0,0 +1,79 @@ +import { computerRows } from './computersFooter'; +import type { Lambda } from './lambdas'; +import type { LambdaInfo, PodSummary } from './types'; + +/** + * All caught up: nothing waits on the owner. One line of where things + * stand, and only optional things they could do: plan something new, plan + * Ready tickets nobody planned, resume a paused computer with work waiting, + * reassign an offline one's tickets. Pure: the client bundles it. + */ + +export type LaunchItem = + | { kind: 'new' } + | { kind: 'unplanned'; tickets: Lambda[] } + | { kind: 'paused'; computer: string; podId: string; waiting: number } + | { kind: 'offline'; computer: string; assigned: number }; + +const count = (n: number, what: string): string => `${n} ${what}`; + +/** "2 planning · 11 building · 4 queued", leaving out the zeros. */ +export const caughtUpLine = (lambdas: Lambda[]): string => { + const of = (phases: Lambda['phase'][]): number => + lambdas.filter((lambda) => phases.includes(lambda.phase)).length; + const parts = [ + count(of(['planning']), 'planning'), + count(of(['building', 'qa']), 'building'), + count(of(['queued']), 'queued'), + ].filter((part) => !part.startsWith('0 ')); + + return parts.length === 0 ? 'Nothing is in flight.' : parts.join(' · '); +}; + +export const launchItems = ( + lambdas: Lambda[], + lambda: LambdaInfo | undefined, + pods: PodSummary[], + now: number +): LaunchItem[] => { + const unplanned = lambdas.filter( + (one) => one.planningPodId === null && one.phase === 'queued' + ); + const rows = computerRows(lambda, pods, now); + // Tickets queued on an offline computer are what it would strand. + const queuedOn = (computer: string): number => + lambdas + .flatMap((one) => one.tickets) + .filter( + (ticket) => + ticket.where.kind === 'queued' && ticket.where.computer === computer + ).length; + + const planThem: LaunchItem[] = + unplanned.length === 0 ? [] : [{ kind: 'unplanned', tickets: unplanned }]; + + return [ + { kind: 'new' }, + ...planThem, + ...rows.flatMap((row): LaunchItem[] => + row.state === 'paused' && row.waiting > 0 && row.podId !== null + ? [ + { + kind: 'paused', + computer: row.name, + podId: row.podId, + waiting: row.waiting, + }, + ] + : row.state === 'offline' && queuedOn(row.name) > 0 + ? [ + { + kind: 'offline', + computer: row.name, + assigned: queuedOn(row.name), + }, + ] + : [] + ), + ]; +}; diff --git a/src/app/shared/computersFooter.ts b/src/app/shared/computersFooter.ts new file mode 100644 index 0000000..c557094 --- /dev/null +++ b/src/app/shared/computersFooter.ts @@ -0,0 +1,147 @@ +import type { LambdaInfo, PodSummary } from './types'; + +/** + * The sidebar's computers line, however many there are: one line of counts + * ("11 building · 2 idle · 1 paused · 1 offline") that opens a popover, + * computers needing attention first. Pure: the client bundles it. + */ + +export type ComputerState = + | 'building' + | 'testing' + | 'shipping' + | 'blocked' + | 'idle' + | 'paused' + | 'offline'; + +export interface ComputerRow { + name: string; + state: ComputerState; + /** Its building pod, when it has one. */ + podId: string | null; + /** "Bench list · test · 12m", "nothing ready for it", "offline 2h". */ + detail: string; + /** Tickets in its queue: a paused one with work waiting needs attention. */ + waiting: number; + /** Paused with work waiting, or offline: shown first. */ + attention: boolean; +} + +const minutes = (from: string, now: number): string => + `${Math.max(0, Math.round((now - Date.parse(from)) / 60_000))}m`; + +const hoursAgo = (then: number, now: number): string => { + const total = Math.max(0, Math.round((now - then) / 60_000)); + + return total < 60 ? `${total}m` : `${Math.floor(total / 60)}h`; +}; + +export const computerRows = ( + lambda: LambdaInfo | undefined, + pods: PodSummary[], + now: number +): ComputerRow[] => { + const buildingPods = pods.filter( + (summary) => + summary.pod.kind === 'building' && + summary.pod.talk === undefined && + summary.pod.status !== 'done' + ); + + return (lambda?.computers ?? []).map((computer): ComputerRow => { + const summary = buildingPods.find( + (one) => lambda?.podComputers?.[one.pod.id] === computer.name + ); + const building = summary?.building; + const waiting = building?.upNext?.length ?? 0; + const podId = summary?.pod.id ?? null; + + if (!computer.connected) { + return { + name: computer.name, + state: 'offline', + podId, + detail: `offline ${hoursAgo(computer.lastSeenAt, now)}`, + waiting, + attention: true, + }; + } + + if (summary?.pod.status === 'paused') { + return { + name: computer.name, + state: 'paused', + podId, + detail: + waiting === 0 + ? 'paused' + : `paused · ${waiting} ${ + waiting === 1 ? 'ticket' : 'tickets' + } waiting`, + waiting, + attention: waiting > 0, + }; + } + + const current = building?.current ?? null; + const run = (building?.runs ?? []).find( + (one) => one.id === current?.id && one.shippedAt === null + ); + + if (current !== null && building !== undefined) { + return { + name: computer.name, + state: building.state === 'paused' ? 'paused' : building.state, + podId, + detail: `${current.title} · ${current.step}${ + run === undefined ? '' : ` · ${minutes(run.startedAt, now)}` + }`, + waiting, + attention: false, + }; + } + + return { + name: computer.name, + state: building?.state === 'blocked' ? 'blocked' : 'idle', + podId, + detail: building === undefined ? 'not building' : 'nothing ready for it', + waiting, + attention: false, + }; + }); +}; + +/** + * A computer's row offers Pause, beside Terminal, while its building pod runs: + * the same place a paused one offers Resume. Offline, it can't be told to. + */ +export const canPause = (row: ComputerRow): boolean => + row.podId !== null && row.state !== 'paused' && row.state !== 'offline'; + +/** "11 building · 2 idle · 1 paused · 1 offline", leaving out the zeros. */ +export const computersCounts = ( + rows: ComputerRow[] +): Array<{ label: string; tone: 'plain' | 'paused' | 'offline' }> => { + const count = (states: ComputerState[]): number => + rows.filter((row) => states.includes(row.state)).length; + const parts: Array<{ + n: number; + what: string; + tone: 'plain' | 'paused' | 'offline'; + }> = [ + { + n: count(['building', 'testing', 'shipping', 'blocked']), + what: 'building', + tone: 'plain', + }, + { n: count(['idle']), what: 'idle', tone: 'plain' }, + { n: count(['paused']), what: 'paused', tone: 'paused' }, + { n: count(['offline']), what: 'offline', tone: 'offline' }, + ]; + + return parts + .filter((part) => part.n > 0) + .map(({ n, what, tone }) => ({ label: `${n} ${what}`, tone })); +}; diff --git a/src/app/shared/conversation.ts b/src/app/shared/conversation.ts new file mode 100644 index 0000000..ef1da7d --- /dev/null +++ b/src/app/shared/conversation.ts @@ -0,0 +1,170 @@ +import type { ThreadMessage } from '../../pod-store'; + +/** Agents who talk to the owner: a planning pod's, and a building pod's. */ +const SPEAKERS = [ + 'dev', + 'manager', + 'engineering-manager', + 'product-manager', + 'qa', +]; + +/** + * The direct conversation between the owner and the pod: their chat messages and + * the agents' `lambda pod reply`s that are not about a specific question. Agent → + * agent traffic (anything with a `target` not from the owner) and system notes + * are excluded. Shared by the server (inbox) and the client (the card). + */ +export const conversation = (thread: ThreadMessage[]): ThreadMessage[] => + thread.filter( + (message) => + message.qid === undefined && + (message.from === 'human' || + (SPEAKERS.includes(message.from) && message.target === undefined)) + ); + +/** + * A building pod's chat: everything the owner wrote, and every agent line + * addressed to them. Agent → agent traffic (the Product manager's relay to + * the Engineering manager has a `target`) and system notes aren't theirs. + */ +export const buildingChat = (thread: ThreadMessage[]): ThreadMessage[] => + thread.filter( + (message) => + message.text.trim() !== '' && + message.from !== 'system' && + (message.from === 'human' || message.target === undefined) + ); + +/** + * "Your turn": an agent ASKED the owner something (`lambda pod reply --ask`) since their + * last message. FYIs and acknowledgements (`--fyi`) never surface on their own + * — the pod only shows up when it truly needs them. Returns the latest + * unanswered ask, even if FYIs came after it. + * + * A freeform chat is their conversation: any reply from its dev is their turn, + * ask or not, so a dev that ends with an `--fyi` never leaves the chat in + * their sidebar with nothing saying it is done or what to do. Progress notes + * still don't count. + * + * Twin of `pendingAsk` in src/pod-store/conversation.ts (what `lambda pod status` shows the + * manager) — kept as a copy because this file runs in the browser. + */ +/** The thread since they last wrote anything that answers the pod. */ +const sinceTheirAnswer = (thread: ThreadMessage[]): Set => { + const lastHuman = thread.reduce( + (index, message, at) => + message.from === 'human' && message.chat !== true ? at : index, + -1 + ); + + return new Set(thread.slice(lastHuman + 1)); +}; + +export const pendingReply = ( + thread: ThreadMessage[], + freeform = false +): ThreadMessage | null => { + // Anything they've written since — in the chat or on one of its items — is + // their answer: an ask answered on the item it names ends their turn too. A + // side-chat message is a question to the chat, not their answer: it never + // ends their turn. + const after = sinceTheirAnswer(thread); + const asks = conversation(thread) + .filter((message) => after.has(message)) + .filter( + (message) => + message.from !== 'human' && + (message.expectsReply === true || + (freeform && + message.from === 'dev' && + message.progress !== true && + message.text.trim() !== '')) + ); + + return asks.at(-1) ?? null; +}; + +/** + * Every ask since their last answer, oldest first: a building pod's Blocked + * items are each their own, so two in a row are two in the inbox. Asking the + * Product manager something ("why did the test fail?") is a question to it, + * not an answer on the item: only what they send the pod itself settles it. + * An answer about one item (`about`) settles that item alone; one about none + * settles every item before it. + */ +export const pendingReplies = (thread: ThreadMessage[]): ThreadMessage[] => { + const answers = thread.filter( + (message) => + message.from === 'human' && + message.chat !== true && + message.target !== 'product-manager' + ); + + return conversation(thread).filter( + (ask) => + ask.from !== 'human' && + ask.expectsReply === true && + !answers.some( + (answer) => + thread.indexOf(answer) > thread.indexOf(ask) && + (answer.about === undefined || answer.about === ask.id) + ) + ); +}; + +/** Past this, a ⌘⏎ hold lets go even with no reply — a pod never vanishes for good. */ +export const CHAT_HOLD_MS = 15 * 60_000; + +const isChatReply = (message: ThreadMessage): boolean => + message.chat === true && + message.from !== 'human' && + message.from !== 'system' && + message.progress !== true; + +/** + * The dev owes the owner a reply: the newest message between them is theirs + * (system notes and progress updates don't count as a reply). + */ +export const owesReply = (thread: ThreadMessage[]): boolean => + thread + .filter( + (message) => + message.from !== 'system' && + message.progress !== true && + message.text.trim() !== '' && + (message.from === 'human' || message.target === undefined) + ) + .at(-1)?.from === 'human'; + +/** + * The sidebar chat's latest exchange: when the owner last sent it something, + * and when it last answered (progress notes aren't answers). + */ +export const chatTimes = ( + thread: ThreadMessage[] +): { sentAt?: string; repliedAt?: string } => ({ + sentAt: thread + .filter((message) => message.chat === true && message.from === 'human') + .at(-1)?.createdAt, + repliedAt: thread.filter(isChatReply).at(-1)?.createdAt, +}); + +/** + * ⌘⏎ in the Chat panel: their latest chat message asked the pod to step aside + * until it answered, and it hasn't yet — so its items stay out of the inbox. + * Lets go on the reply, or after CHAT_HOLD_MS. + */ +export const chatHold = (thread: ThreadMessage[], now: number): boolean => { + const sent = thread + .filter((message) => message.chat === true && message.from === 'human') + .at(-1); + + if (sent?.hold !== true || now - Date.parse(sent.createdAt) > CHAT_HOLD_MS) { + return false; + } + + return !thread.some( + (message) => isChatReply(message) && message.createdAt >= sent.createdAt + ); +}; diff --git a/src/app/shared/designSystem.ts b/src/app/shared/designSystem.ts new file mode 100644 index 0000000..e3cc37d --- /dev/null +++ b/src/app/shared/designSystem.ts @@ -0,0 +1,395 @@ +/** + * Settings → Design system: the page state a runner builds from the design + * system as committed on the project's main branch, and the lines the page + * shows for it. Every computer reads the same commit, so every computer + * shows the same thing. + */ + +export type DesignSeverityLabel = 'high' | 'medium' | 'medium-low' | 'low'; + +export type SetupTicketStatus = + | 'inbox' + | 'planning' + | 'ready' + | 'building' + | 'blocked' + | 'done' + | 'canceled'; + +export interface DesignSystemTicket { + id: string; + title: string; + url: string | null; + status: SetupTicketStatus; + blockReason: string | null; +} + +export interface DesignSystemCheck { + result: 'match' | 'close' | 'not-checked'; + rounds: number; + photos: number; + issues: { severity: DesignSeverityLabel; where: string; what: string }[]; + reason?: string; +} + +/** The newest commit on main that touched the design system. */ +export interface LastChange { + /** The subject's leading `-`, else the whole subject. */ + ticket: string; + /** Short sha. */ + sha: string; + /** The rest of the subject; empty when there was no ticket id. */ + rest: string; +} + +export interface DesignSystemInfo { + /** Null: made by hand, not by Lambda. */ + version: number | null; + madeByHand: boolean; + syncedBy: string | null; + builtFrom: string | null; + source: 'code' | 'photos' | 'code+photos' | null; + check: DesignSystemCheck | null; + components: string[]; + patterns: string[]; + /** Reference screens' titles. */ + screens: string[]; + sampleData: boolean; + /** Null when the newest such commit is the one that made it. */ + lastChanged: LastChange | null; + /** The ticket that made it (`syncedBy`), where its pictures are. */ + picturesUrl: string | null; +} + +export interface DesignSystemPage { + project: string; + /** The default branch, as "main". */ + branch: string; + dir: string; + /** `detect`'s Look line ("tailwind.config.ts", or "none"). */ + look: string; + /** `detect`'s Components line ("12 — Button, …", or "none — …"). */ + components: string; + /** + * Whether Claude Design works with this computer's Claude account; null + * when it was never checked (or the config couldn't be read): only an + * explicit false greys Make it. + */ + designAvailable: boolean | null; + /** The open setup ticket, if any. */ + setupTicket: DesignSystemTicket | null; + /** Null: no manifest on main. */ + system: DesignSystemInfo | null; + /** Why the setup ticket and the pictures' link are missing, when the tickets couldn't be read. */ + ticketsError: string | null; +} + +/** What Make it answers. */ +export interface MadeDesignSystem { + id: string; + title: string; + url: string | null; + existing: boolean; +} + +export const DEFAULT_LOOK = "Designs use Claude Design's default look."; +export const NEEDS_CLAUDE_DESIGN = + "Needs Claude Design, which isn't available with this Claude account."; +export const KEPT_IN_STEP = + 'Kept in step by the build: a ticket that changes how the app looks updates the design system too.'; +export const MAKE_IT_EXPLAINED = + 'Make it creates a ticket, "Make the design system from the app", in Ready. A building pod makes it from the app.'; +export const MADE_BY_HAND = 'Made by hand, not by Lambda.'; +export const PHOTOS_ONLY = + "Made from photos only: Lambda couldn't find this app's theme or components in the code, so designs look like the app but use stand-in parts."; +export const SAMPLE_DATA = + 'Sample data: one set, the one every design draws from'; + +/** + * Whether the heartbeat reads the page again: only after a failed read (the + * computer was offline), never over a read on its way. A failed Make it isn't + * a read: its message stays until the next press or another project. + */ +export const rereadsDesignSystem = (now: { + readError: string | null; + busy: boolean; +}): boolean => now.readError !== null && !now.busy; + +/** Something Make it answered, kept with the project it was pressed on. */ +export interface ForProject { + projectId: string; + value: T; +} + +/** Make it's answer, only on the page of the project it was pressed on. */ +export const forProject = ( + answered: ForProject | null, + projectId: string +): T | null => + answered !== null && answered.projectId === projectId ? answered.value : null; + +/** Make it shows only with no design system on main and no open setup ticket. */ +export const showsMakeIt = (page: DesignSystemPage): boolean => + page.system === null && page.setupTicket === null; + +const SEVERITY_LABEL: Record = { + high: 'High', + medium: 'Medium', + 'medium-low': 'Medium-low', + low: 'Low', +}; + +const withoutStop = (text: string): string => text.trim().replace(/\.+$/, ''); + +const shortList = (names: string[], max = 5): string => + names.length > max + ? `${names.slice(0, max).join(', ')}, …` + : names.join(', '); + +export const issueLine = (issue: DesignSystemCheck['issues'][number]): string => + `${SEVERITY_LABEL[issue.severity]} · ${issue.where}: ${issue.what}`; + +/** "Matches the app, with 1 small difference:" and its issues, or the reason it wasn't checked. */ +export const checkLines = (check: DesignSystemCheck): string[] => { + if (check.result === 'not-checked') { + return [ + check.reason === undefined || check.reason.trim() === '' + ? 'Not checked against the app.' + : `Not checked against the app: ${withoutStop(check.reason)}.`, + ]; + } + + const count = check.issues.length; + + if (count === 0) { + return ['Matches the app.']; + } + + return [ + `Matches the app, with ${count} small ${ + count === 1 ? 'difference' : 'differences' + }:`, + ...check.issues.map(issueLine), + ]; +}; + +export const versionLine = ( + system: DesignSystemInfo, + branch: string +): string => { + const by = system.syncedBy === null ? '' : ` · made by ${system.syncedBy}`; + const from = + system.builtFrom === null + ? '' + : `${by === '' ? ' · made' : ''} from ${branch}@${system.builtFrom.slice( + 0, + 7 + )}`; + + return `Version ${system.version ?? '?'}${by}${from}`; +}; + +export const lastChangedLine = (change: LastChange, branch: string): string => + `Last changed by ${change.ticket} (${branch}@${change.sha})${ + change.rest === '' ? '' : `: ${change.rest}` + }`; + +export const lookText = (look: string): string => + look === 'none' + ? 'Look: none found in the code' + : `Look: colours, fonts and spacing from ${look}`; + +export const componentsText = ( + system: DesignSystemInfo, + detected: string +): string => + system.components.length === 0 + ? `Components: ${detected}` + : `Components (${system.components.length}): ${shortList( + system.components + )}`; + +export const createdLine = (made: MadeDesignSystem): string => + `Created ${made.id}: ${withoutStop( + made.title + )}. It's in Ready. Designs use it once it ships.`; + +export const openTicketLine = (ticket: DesignSystemTicket): string => { + if (ticket.status === 'blocked') { + const reason = + ticket.blockReason === null || ticket.blockReason.trim() === '' + ? '' + : `: ${withoutStop(ticket.blockReason)}`; + + return `${ticket.id} is blocked${reason}. Designs keep using Claude Design's default look.`; + } + + return `${ticket.id} is open: ${withoutStop(ticket.title)} (${ + ticket.status + }). Designs keep using Claude Design's default look until it ships.`; +}; + +/** One line of the page: text, an optional link after it, an indented issue. */ +export interface PageLine { + text: string; + link?: { label: string; url: string | null }; + issue?: boolean; +} + +/** "Checked against 4 photos · pictures on PAWNEE-12 ↗", when the check used any. */ +export const photosLine = (system: DesignSystemInfo): PageLine | null => { + const { check } = system; + + if (check === null || check.result === 'not-checked' || check.photos === 0) { + return null; + } + + const photos = `Checked against ${check.photos} ${ + check.photos === 1 ? 'photo' : 'photos' + }`; + + return system.syncedBy === null + ? { text: photos } + : { + text: `${photos} · `, + link: { + label: `pictures on ${system.syncedBy}`, + url: system.picturesUrl, + }, + }; +}; + +const plain = (text: string): PageLine => ({ text }); + +/** The view-only page's lines, top to bottom, for a project with a design system. */ +export const systemLines = (page: DesignSystemPage): PageLine[] => { + const { system } = page; + + if (system === null) { + return []; + } + + const { check } = system; + const photos = photosLine(system); + const [checkHead, ...issues] = check === null ? [] : checkLines(check); + + return [ + plain(system.madeByHand ? MADE_BY_HAND : versionLine(system, page.branch)), + ...(system.lastChanged === null + ? [] + : [plain(lastChangedLine(system.lastChanged, page.branch))]), + plain(`Kept in the codebase's code: ${page.dir}`), + plain(KEPT_IN_STEP), + ...(system.source === 'photos' ? [plain(PHOTOS_ONLY)] : []), + ...(checkHead === undefined ? [] : [plain(checkHead)]), + ...issues.map((text) => ({ text, issue: true })), + ...(photos === null ? [] : [photos]), + plain(lookText(page.look)), + plain(componentsText(system, page.components)), + ...(system.patterns.length === 0 + ? [] + : [ + plain( + `Copied by hand (${system.patterns.length}): ${system.patterns.join( + ', ' + )}` + ), + ]), + ...(system.screens.length === 0 + ? [] + : [ + plain( + `Reference screens (${ + system.screens.length + }): ${system.screens.join(', ')}` + ), + ]), + ...(system.sampleData ? [plain(SAMPLE_DATA)] : []), + ...(page.ticketsError === null ? [] : [plain(page.ticketsError)]), + ]; +}; + +/** Where Settings → Design system's controls put what they learn. */ +export interface DesignSystemTarget { + page: (page: DesignSystemPage | null) => void; + error: (text: string | null) => void; + made: (made: ForProject | null) => void; + makeError: (text: ForProject | null) => void; + /** Make it is on its way. */ + busy: (busy: boolean) => void; + /** A read is on its way. */ + reading: (reading: boolean) => void; +} + +/** + * Settings → Design system's reads and Make it. `load` is the page's + * newest-answer-wins loader (it sets `page` and `error`); `shown` is the + * project on screen now, so an answer for another project doesn't reload it. + */ +export const designSystemControls = ({ + read, + make, + load, + errorText, + shown, + target, +}: { + read: (projectId: string) => Promise; + make: (projectId: string) => Promise; + load: (request: Promise) => Promise; + errorText: (caught: unknown) => string; + shown: () => string | null; + target: DesignSystemTarget; +}) => { + const reads = { inFlight: 0 }; + // Every read says it's on its way, so the heartbeat never stacks another. + const reread = async (projectId: string): Promise => { + reads.inFlight += 1; + target.reading(true); + + try { + await load(read(projectId)); + } finally { + reads.inFlight -= 1; + target.reading(reads.inFlight > 0); + } + }; + + return { + /** Opening the page, or another project picked: nothing of the last one shows. */ + open: (projectId: string): Promise => { + target.page(null); + target.error(null); + target.made(null); + target.makeError(null); + + return reread(projectId); + }, + read: reread, + /** + * Make it, then the page read again, whether it made the ticket or was + * refused (a 409 means the page is out of date). A failed read after it + * keeps Make it's message: the read only touches the page and its error. + */ + make: async (projectId: string): Promise => { + target.busy(true); + target.makeError(null); + + try { + try { + const answer = await make(projectId); + target.made({ projectId, value: answer }); + } catch (caught) { + target.makeError({ projectId, value: errorText(caught) }); + } + + // Another project's page reads its own; this answer doesn't reload it. + if (shown() === projectId) { + await reread(projectId); + } + } finally { + target.busy(false); + } + }, + }; +}; diff --git a/src/app/shared/emoji.ts b/src/app/shared/emoji.ts new file mode 100644 index 0000000..0ca2127 --- /dev/null +++ b/src/app/shared/emoji.ts @@ -0,0 +1,140 @@ +/** + * A tracker's icon as something to draw: Linear keeps an emoji as its + * shortcode (`:bee:`), or names one of its own icons (`FaceId`). The common + * shortcodes become the character; anything else is null, and the app draws + * the tracker's plain project cube in its colour. Pure. + */ + +const SHORTCODES: Record = { + bee: '🐝', + bug: '🐛', + beetle: '🪲', + relaxed: '☺️', + smile: '😄', + smiley: '😃', + grinning: '😀', + wink: '😉', + sunglasses: '😎', + thinking: '🤔', + mega: '📣', + loudspeaker: '📢', + wave: '👋', + envelope: '✉️', + envelope_with_arrow: '📩', + incoming_envelope: '📨', + mailbox: '📫', + mailbox_with_mail: '📬', + mailbox_closed: '📪', + email: '📧', + ringed_planet: '🪐', + earth_americas: '🌎', + sponge: '🧽', + joystick: '🕹️', + video_game: '🎮', + rocket: '🚀', + fire: '🔥', + sparkles: '✨', + star: '⭐', + star2: '🌟', + zap: '⚡', + bulb: '💡', + wrench: '🔧', + hammer: '🔨', + hammer_and_wrench: '🛠️', + gear: '⚙️', + lock: '🔒', + key: '🔑', + shield: '🛡️', + package: '📦', + books: '📚', + book: '📖', + memo: '📝', + pencil: '✏️', + pencil2: '✏️', + clipboard: '📋', + calendar: '📅', + date: '📅', + chart_with_upwards_trend: '📈', + bar_chart: '📊', + moneybag: '💰', + dollar: '💵', + credit_card: '💳', + money_with_wings: '💸', + shopping_cart: '🛒', + gift: '🎁', + tada: '🎉', + trophy: '🏆', + dart: '🎯', + art: '🎨', + headphones: '🎧', + iphone: '📱', + computer: '💻', + desktop_computer: '🖥️', + globe_with_meridians: '🌐', + link: '🔗', + mag: '🔍', + eyes: '👀', + heart: '❤️', + green_heart: '💚', + blue_heart: '💙', + white_check_mark: '✅', + heavy_check_mark: '✔️', + x: '❌', + warning: '⚠️', + construction: '🚧', + rotating_light: '🚨', + hourglass: '⌛', + alarm_clock: '⏰', + seedling: '🌱', + evergreen_tree: '🌲', + sunny: '☀️', + rainbow: '🌈', + cloud: '☁️', + snowflake: '❄️', + ocean: '🌊', + whale: '🐳', + dog: '🐶', + cat: '🐱', + robot: '🤖', + brain: '🧠', + muscle: '💪', + raised_hands: '🙌', + clap: '👏', + thumbsup: '👍', + '+1': '👍', + pushpin: '📌', + round_pushpin: '📍', + triangular_flag_on_post: '🚩', + checkered_flag: '🏁', + house: '🏠', + school: '🏫', + office: '🏢', + bell: '🔔', + speech_balloon: '💬', + inbox_tray: '📥', + outbox_tray: '📤', + file_folder: '📁', + open_file_folder: '📂', + card_index_dividers: '🗂️', + test_tube: '🧪', + microscope: '🔬', + broom: '🧹', + recycle: '♻️', + lambda: 'λ', +}; + +/** The emoji to draw for an icon, or null for the plain cube. */ +export const iconEmoji = (icon: string | null | undefined): string | null => { + if (icon === null || icon === undefined || icon.trim() === '') { + return null; + } + + const code = /^:([a-z0-9_+-]+):$/i.exec(icon.trim())?.[1]; + + if (code !== undefined) { + return SHORTCODES[code.toLowerCase()] ?? null; + } + + // Already a character (an emoji someone typed), not an icon's name. + return /^[A-Za-z0-9 ]+$/.test(icon) ? null : icon; +}; diff --git a/src/app/shared/folderSlug.ts b/src/app/shared/folderSlug.ts new file mode 100644 index 0000000..21cbe7f --- /dev/null +++ b/src/app/shared/folderSlug.ts @@ -0,0 +1,10 @@ +/** + * New project's folder name: "Sweetums recipe app" → "sweetums-recipe-app" + * (lowercase, spaces and other non-alphanumerics as hyphens). The dialog + * shows it; the runner makes `~/Lambda/`. + */ +export const folderSlug = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); diff --git a/src/app/shared/images.ts b/src/app/shared/images.ts new file mode 100644 index 0000000..d569c75 --- /dev/null +++ b/src/app/shared/images.ts @@ -0,0 +1,98 @@ +/** + * Images the owner drops into a message. The server saves each one under + * `/_uploads/` and the message carries it as its own line, + * `[Image: /abs/path.png]`, so the agent can open it with Read. The inbox + * shows those lines as thumbnails. + */ + +/** Uploaded file names: `img_