commit 3e87147f637f0abe85501048442655722f7b9520 Author: Jeff Emmett Date: Wed Dec 3 17:57:16 2025 -0800 Initial commit - fork of Backlog.md with Docker deployment for backlog.jeffemmett.com πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..975f15d --- /dev/null +++ b/.cursorrules @@ -0,0 +1,234 @@ +# ⚠️ **IMPORTANT** + +Follow the instructions below for instructions on how to work with Backlog.md on tasks in this repository [agent-guidelines.md](src/guidelines/agent-guidelines.md) + +## Commands + +### Development + +- `bun i` - Install dependencies +- `bun test` - Run tests +- `bun run format` - Format code with Biome +- `bun run lint` - Lint and auto-fix with Biome +- `bun run check` - Run all Biome checks (format + lint) +- `bun run build` - Build the CLI tool +- `bun run cli` - Uses the CLI tool directly + +### Testing + +- `bun test` - Run all tests +- `bun test ` - Run specific test file + +### Configuration Management + +- `bun run cli config list` - View all configuration values +- `bun run cli config get ` - Get a specific config value (e.g. defaultEditor) +- `bun run cli config set ` - Set a config value with validation + +## Core Structure + +- **CLI Tool**: Built with Bun and TypeScript as a global npm package (`@backlog.md`) +- **Source Code**: Located in `/src` directory with modular TypeScript structure +- **Task Management**: Uses markdown files in `.backlog/` directory structure +- **Workflow**: Git-integrated with task IDs referenced in commits and PRs + +## Code Standards + +- **Runtime**: Bun with TypeScript 5 +- **Formatting**: Biome with tab indentation and double quotes +- **Linting**: Biome recommended rules +- **Testing**: Bun's built-in test runner +- **Pre-commit**: Husky + lint-staged automatically runs Biome checks before commits + +The pre-commit hook automatically runs `biome check --write` on staged files to ensure code quality. If linting errors +are found, the commit will be blocked until fixed. + +# === BACKLOG.MD GUIDELINES START === +# Instructions for the usage of Backlog.md CLI Tool + +## 1. Source of Truth + +- Tasks live under **`backlog/tasks/`** (drafts under **`backlog/drafts/`**). +- Every implementation decision starts with reading the corresponding Markdown task file. +- Project documentation is in **`backlog/docs/`**. +- Project decisions are in **`backlog/decisions/`**. + +## 2. Defining Tasks + +### **Title** + +Use a clear brief title that summarizes the task. + +### **Description**: (The **"why"**) + +Provide a concise summary of the task purpose and its goal. Do not add implementation details here. It +should explain the purpose and context of the task. Code snippets should be avoided. + +### **Acceptance Criteria**: (The **"what"**) + +List specific, measurable outcomes that define what means to reach the goal from the description. Use checkboxes (`- [ ]`) for tracking. +When defining `## Acceptance Criteria` for a task, focus on **outcomes, behaviors, and verifiable requirements** rather +than step-by-step implementation details. +Acceptance Criteria (AC) define *what* conditions must be met for the task to be considered complete. +They should be testable and confirm that the core purpose of the task is achieved. +**Key Principles for Good ACs:** + +- **Outcome-Oriented:** Focus on the result, not the method. +- **Testable/Verifiable:** Each criterion should be something that can be objectively tested or verified. +- **Clear and Concise:** Unambiguous language. +- **Complete:** Collectively, ACs should cover the scope of the task. +- **User-Focused (where applicable):** Frame ACs from the perspective of the end-user or the system's external behavior. + + - *Good Example:* "- [ ] User can successfully log in with valid credentials." + - *Good Example:* "- [ ] System processes 1000 requests per second without errors." + - *Bad Example (Implementation Step):* "- [ ] Add a new function `handleLogin()` in `auth.ts`." + +### Task file + +Once a task is created it will be stored in `backlog/tasks/` directory as a Markdown file with the format +`task- - .md` (e.g. `task-42 - Add GraphQL resolver.md`). + +### Additional task requirements + +- Tasks must be **atomic** and **testable**. If a task is too large, break it down into smaller subtasks. + Each task should represent a single unit of work that can be completed in a single PR. + +- **Never** reference tasks that are to be done in the future or that are not yet created. You can only reference + previous + tasks (id < current task id). + +- When creating multiple tasks, ensure they are **independent** and they do not depend on future tasks. + Example of wrong tasks splitting: task 1: "Add API endpoint for user data", task 2: "Define the user model and DB + schema". + Example of correct tasks splitting: task 1: "Add system for handling API requests", task 2: "Add user model and DB + schema", task 3: "Add API endpoint for user data". + +## 3. Recommended Task Anatomy + +```markdown +# task‑42 - Add GraphQL resolver + +## Description (the why) + +Short, imperative explanation of the goal of the task and why it is needed. + +## Acceptance Criteria (the what) + +- [ ] Resolver returns correct data for happy path +- [ ] Error response matches REST +- [ ] P95 latency ≀ 50 ms under 100 RPS + +## Implementation Plan (the how) + +1. Research existing GraphQL resolver patterns +2. Implement basic resolver with error handling +3. Add performance monitoring +4. Write unit and integration tests +5. Benchmark performance under load + +## Implementation Notes (only added after working on the task) + +- Approach taken +- Features implemented or modified +- Technical decisions and trade-offs +- Modified or added files +``` + +## 6. Implementing Tasks + +Mandatory sections for every task: + +- **Implementation Plan**: (The **"how"**) Outline the steps to achieve the task. Because the implementation details may + change after the task is created, **the implementation notes must be added only after putting the task in progress** + and before starting working on the task. +- **Implementation Notes**: Document your approach, decisions, challenges, and any deviations from the plan. This + section is added after you are done working on the task. It should summarize what you did and why you did it. Keep it + concise but informative. + +**IMPORTANT**: Do not implement anything else that deviates from the **Acceptance Criteria**. If you need to +implement something that is not in the AC, update the AC first and then implement it or create a new task for it. + +## 2. Typical Workflow + +```bash +# 1 Identify work +backlog task list -s "To Do" --plain + +# 2 Read details & documentation +backlog task 42 --plain +# Read also all documentation files in `backlog/docs/` directory. +# Read also all decision files in `backlog/decisions/` directory. + +# 3 Start work: assign yourself & move column +backlog task edit 42 -a @{yourself} -s "In Progress" + +# 4 Add implementation plan before starting +backlog task edit 42 --plan "1. Analyze current implementation\n2. Identify bottlenecks\n3. Refactor in phases" + +# 5 Break work down if needed by creating subtasks or additional tasks +backlog task create "Refactor DB layer" -p 42 -a @{yourself} -d "Description" --ac "Tests pass,Performance improved" + +# 6 Complete and mark Done +backlog task edit 42 -s Done --notes "Implemented GraphQL resolver with error handling and performance monitoring" +``` + +### 7. Final Steps Before Marking a Task as Done + +Always ensure you have: + +1. βœ… Marked all acceptance criteria as completed (change `- [ ]` to `- [x]`) +2. βœ… Added an `## Implementation Notes` section documenting your approach +3. βœ… Run all tests and linting checks +4. βœ… Updated relevant documentation + +## 8. Definition of Done (DoD) + +A task is **Done** only when **ALL** of the following are complete: + +1. **Acceptance criteria** checklist in the task file is fully checked (all `- [ ]` changed to `- [x]`). +2. **Implementation plan** was followed or deviations were documented in Implementation Notes. +3. **Automated tests** (unit + integration) cover new logic. +4. **Static analysis**: linter & formatter succeed. +5. **Documentation**: + - All relevant docs updated (README, backlog/docs, backlog/decisions, etc.). + - Task file **MUST** have an `## Implementation Notes` section added summarising: + - Approach taken + - Features implemented or modified + - Technical decisions and trade-offs + - Modified or added files +6. **Review**: code reviewed. +7. **Task hygiene**: status set to **Done** via CLI (`backlog task edit <id> -s Done`). +8. **No regressions**: performance, security and licence checks green. + +⚠️ **IMPORTANT**: Never mark a task as Done without completing ALL items above. + +## 9. Handy CLI Commands + +| Purpose | Command | +|------------------|------------------------------------------------------------------------| +| Create task | `backlog task create "Add OAuth"` | +| Create with desc | `backlog task create "Feature" -d "Enables users to use this feature"` | +| Create with AC | `backlog task create "Feature" --ac "Must work,Must be tested"` | +| Create with deps | `backlog task create "Feature" --dep task-1,task-2` | +| Create sub task | `backlog task create -p 14 "Add Google auth"` | +| List tasks | `backlog task list --plain` | +| View detail | `backlog task 7 --plain` | +| Edit | `backlog task edit 7 -a @{yourself} -l auth,backend` | +| Add plan | `backlog task edit 7 --plan "Implementation approach"` | +| Add AC | `backlog task edit 7 --ac "New criterion,Another one"` | +| Add deps | `backlog task edit 7 --dep task-1,task-2` | +| Add notes | `backlog task edit 7 --notes "We added this and that feature because"` | +| Mark as done | `backlog task edit 7 -s "Done"` | +| Archive | `backlog task archive 7` | +| Draft flow | `backlog draft create "Spike GraphQL"` β†’ `backlog draft promote 3.1` | +| Demote to draft | `backlog task demote <task-id>` | +| Config editor | `backlog config set defaultEditor "code --wait"` | +| View config | `backlog config list` | + +## 10. Tips for AI Agents + +- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output instead of using Backlog.md + interactive UI. +- When users mention to creat a task, they mean to create a task using Backlog.md CLI tool. + +# === BACKLOG.MD GUIDELINES END === diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/5-minute-tour-256.png b/.github/5-minute-tour-256.png new file mode 100644 index 0000000..08e56a5 Binary files /dev/null and b/.github/5-minute-tour-256.png differ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3501594 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug Report +about: Create a bug report to help us improve +title: "[Bug]: " +labels: bug +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment** +- OS: [e.g., Windows 11] +- Node version: [e.g., 20] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..90551e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: "[Feature]: " +labels: enhancement +--- + +**Is your feature request related to a problem? Please describe.** +A clear description of what problem you want to solve. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c139244 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary +Briefly explain the purpose of this pull request. + +## Related Tasks +List task IDs this PR closes, e.g. `closes task-29`. + +> **πŸ“‹ Important:** All PRs must have an associated task in the backlog. +> - If no task exists, create one first using: `backlog task create "Your task title"` +> - Follow the [task guidelines](../src/guidelines/agent-guidelines.md) when creating tasks +> - Tasks should be atomic, testable, and well-defined with clear acceptance criteria + +## Task Checklist +- [ ] I have created a corresponding task in `backlog/tasks/` +- [ ] The task has clear acceptance criteria +- [ ] I have added an implementation plan to the task +- [ ] All acceptance criteria in the task are marked as completed + +## Testing +Describe how you tested your changes. diff --git a/.github/backlog-logo.png b/.github/backlog-logo.png new file mode 100644 index 0000000..c8d6aa2 Binary files /dev/null and b/.github/backlog-logo.png differ diff --git a/.github/backlog.gif b/.github/backlog.gif new file mode 100644 index 0000000..fa3bef3 Binary files /dev/null and b/.github/backlog.gif differ diff --git a/.github/backlog.jpg b/.github/backlog.jpg new file mode 100644 index 0000000..c0b75d3 Binary files /dev/null and b/.github/backlog.jpg differ diff --git a/.github/backlog.mp4 b/.github/backlog.mp4 new file mode 100644 index 0000000..88fdf2b Binary files /dev/null and b/.github/backlog.mp4 differ diff --git a/.github/cli-reference-256.png b/.github/cli-reference-256.png new file mode 100644 index 0000000..5621e15 Binary files /dev/null and b/.github/cli-reference-256.png differ diff --git a/.github/configuration-256.png b/.github/configuration-256.png new file mode 100644 index 0000000..a14d926 Binary files /dev/null and b/.github/configuration-256.png differ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3d162ea --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# ⚠️ **IMPORTANT** + +1. Read the [README.md](README.md) +2. Read the [agent-guidelines.md](src/guidelines/agent-guidelines.md) + +## Commands + +### Development + +- `bun i` - Install dependencies +- `bun test` - Run tests +- `bun run format` - Format code with Biome +- `bun run lint` - Lint and auto-fix with Biome +- `bun run check` - Run all Biome checks (format + lint) +- `bun run build` - Build the CLI tool +- `bun run cli` - Uses the CLI tool directly + +### Testing + +- `bun test` - Run all tests +- `bun test <filename>` - Run specific test file + +### Configuration Management + +- `bun run cli config list` - View all configuration values +- `bun run cli config get <key>` - Get a specific config value (e.g. defaultEditor) +- `bun run cli config set <key> <value>` - Set a config value with validation + +## Core Structure + +- **CLI Tool**: Built with Bun and TypeScript as a global npm package (`npm i -g backlog.md`) +- **Source Code**: Located in `/src` directory with modular TypeScript structure +- **Task Management**: Uses markdown files in `backlog/` directory structure +- **Workflow**: Git-integrated with task IDs referenced in commits and PRs + +## Code Standards + +- **Runtime**: Bun with TypeScript 5 +- **Formatting**: Biome with tab indentation and double quotes +- **Linting**: Biome recommended rules +- **Testing**: Bun's built-in test runner +- **Pre-commit**: Husky + lint-staged automatically runs Biome checks before commits + +The pre-commit hook automatically runs `biome check --write` on staged files to ensure code quality. If linting errors +are found, the commit will be blocked until fixed. + +## Git Workflow + +- **Branching**: Use feature branches when working on tasks (e.g. `tasks/task-123-feature-name`) +- **Committing**: Use the following format: `TASK-123 - Title of the task` diff --git a/.github/favicon.png b/.github/favicon.png new file mode 100644 index 0000000..3b4ba65 Binary files /dev/null and b/.github/favicon.png differ diff --git a/.github/sharing-export-256.png b/.github/sharing-export-256.png new file mode 100644 index 0000000..dad026f Binary files /dev/null and b/.github/sharing-export-256.png differ diff --git a/.github/video-presentation.md b/.github/video-presentation.md new file mode 100644 index 0000000..1101bb2 --- /dev/null +++ b/.github/video-presentation.md @@ -0,0 +1,4 @@ + + +https://github.com/user-attachments/assets/a282c648-ffaa-46fc-b3d7-5ab36ca54cbd + diff --git a/.github/web-interface-256.png b/.github/web-interface-256.png new file mode 100644 index 0000000..786c6b0 Binary files /dev/null and b/.github/web-interface-256.png differ diff --git a/.github/web.jpeg b/.github/web.jpeg new file mode 100644 index 0000000..f01b2ef Binary files /dev/null and b/.github/web.jpeg differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b05136 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + test: + name: lint-and-unit-test + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.3 + - uses: actions/cache@v4 + id: cache + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ matrix.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.os }}-bun- + - run: bun install --frozen-lockfile --linker=isolated + - run: bun run lint + - name: Run tests + run: | + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + # Run tests with increased timeout on Windows to handle slower file operations + bun test --timeout=15000 --reporter=junit --reporter-outfile=test-results.xml + else + # Run tests with increased timeout to handle Bun shell operations + bun test --timeout=10000 --reporter=junit --reporter-outfile=test-results.xml + fi + shell: bash + + build-test: + name: compile-and-smoke-test + strategy: + matrix: + include: + - os: ubuntu-latest + target: bun-linux-x64-baseline + - os: macos-latest + target: bun-darwin-x64 + - os: windows-latest + target: bun-windows-x64-baseline + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.3 + - uses: actions/cache@v4 + id: cache + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ matrix.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.os }}-bun- + - run: bun install --frozen-lockfile --linker=isolated + - name: Prime Bun cache for baseline target (Windows workaround) + if: ${{ contains(matrix.target, 'baseline') && matrix.os == 'windows-latest' }} + shell: bash + run: | + # Workaround for https://github.com/oven-sh/bun/issues/13513 + # Build a dummy project from C:\ to prime the baseline binary cache + cd /c + mkdir -p bun-cache-primer + cd bun-cache-primer + echo 'console.log("cache primer")' > index.js + bun build --compile --target=${{ matrix.target }} ./index.js --outfile primer.exe || true + cd $GITHUB_WORKSPACE + - name: Build standalone binary + shell: bash + run: | + VER="$(jq -r .version package.json)" + OUT="backlog-test${{ contains(matrix.target,'windows') && '.exe' || '' }}" + bun build src/cli.ts \ + --compile --minify --sourcemap \ + --target=${{ matrix.target }} \ + --define __EMBEDDED_VERSION__="\"${VER}\"" \ + --outfile="$OUT" + - name: Smoke-test binary + shell: bash + run: | + FILE="backlog-test${{ contains(matrix.target,'windows') && '.exe' || '' }}" + chmod +x "$FILE" + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + powershell -command ".\\$FILE --version" + powershell -command ".\\$FILE --help" + else + "./$FILE" --version + "./$FILE" --help + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4ecd68d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,312 @@ +name: Release multi-platform executables + +on: + push: + tags: ['v*.*.*'] + +permissions: + contents: write + id-token: write + +jobs: + build: + name: build-${{ matrix.target }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: bun-linux-x64-baseline + - os: ubuntu-latest + target: bun-linux-arm64 + - os: macos-latest + target: bun-darwin-x64 + - os: macos-latest + target: bun-darwin-arm64 + - os: windows-latest + target: bun-windows-x64-baseline + runs-on: ${{ matrix.os }} + env: + BIN: backlog-bin${{ contains(matrix.target,'windows') && '.exe' || '' }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.3 + - uses: actions/cache@v4 + id: cache + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ matrix.target }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-bun- + - run: bun install --frozen-lockfile + - name: Sync version to tag + shell: bash + run: | + TAG="${GITHUB_REF##refs/tags/v}" + jq ".version = \"$TAG\"" package.json > tmp.json && mv tmp.json package.json + - name: Prime Bun cache for baseline target (Windows workaround) + if: ${{ contains(matrix.target, 'baseline') && contains(matrix.target, 'windows') }} + shell: bash + run: | + # Workaround for https://github.com/oven-sh/bun/issues/13513 + # Build a dummy project from C:\ to prime the baseline binary cache + cd /c + mkdir -p bun-cache-primer + cd bun-cache-primer + echo 'console.log("cache primer")' > index.js + bun build --compile --target=${{ matrix.target }} ./index.js --outfile primer.exe || true + cd $GITHUB_WORKSPACE + - name: Compile standalone binary + shell: bash + run: | + mkdir -p dist + bun build src/cli.ts --compile --minify --target=${{ matrix.target }} --define __EMBEDDED_VERSION__="\"${GITHUB_REF##refs/tags/v}\"" --outfile=dist/${{ env.BIN }} + - name: Make binary executable (non-Windows) + if: ${{ !contains(matrix.target,'windows') }} + run: chmod +x "dist/${{ env.BIN }}" + - name: Check build output and move binary + shell: bash + run: | + echo "Contents of dist/:" + ls -la dist/ + echo "Moving dist/${{ env.BIN }} to ${{ env.BIN }}" + mv dist/${{ env.BIN }} ${{ env.BIN }} + echo "Final binary size:" + ls -lh ${{ env.BIN }} + - uses: actions/upload-artifact@v4 + with: + name: backlog-${{ matrix.target }} + path: ${{ env.BIN }} + + npm-publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Prepare npm package + shell: bash + run: | + mkdir -p dist + cp scripts/cli.cjs dist/cli.js + cp scripts/resolveBinary.cjs dist/resolveBinary.cjs + cp scripts/postuninstall.cjs dist/postuninstall.cjs + chmod +x dist/cli.js + - name: Create npm-ready package.json + shell: bash + run: | + TAG="${GITHUB_REF##refs/tags/v}" + jq 'del(.devDependencies,.scripts.prepare,.scripts.preinstall,.type) | + .version = "'$TAG'" | + .bin = {backlog:"cli.js"} | + .files = ["cli.js","resolveBinary.cjs","postuninstall.cjs","package.json","README.md","LICENSE"] | + .scripts = {"postuninstall": "node postuninstall.cjs"} | + .repository = {"type":"git","url":"https://github.com/MrLesk/Backlog.md"} | + .optionalDependencies = { + "backlog.md-linux-x64" : "'$TAG'", + "backlog.md-linux-arm64": "'$TAG'", + "backlog.md-darwin-x64" : "'$TAG'", + "backlog.md-darwin-arm64": "'$TAG'", + "backlog.md-windows-x64": "'$TAG'" + }' package.json > dist/package.json + cp LICENSE README.md dist/ 2>/dev/null || true + - uses: actions/setup-node@v5 + with: + node-version: 20 + - name: Configure npm for trusted publishing + shell: bash + run: | + set -euo pipefail + npm install -g npm@11.6.0 + npm --version + - name: Dry run trusted publish + run: | + cd dist + npm publish --access public --dry-run + - name: Publish to npm + run: | + cd dist + npm publish --access public + + publish-binaries: + needs: [build, npm-publish] + strategy: + matrix: + include: + - target: bun-linux-x64-baseline + package: backlog.md-linux-x64 + os: linux + cpu: x64 + - target: bun-linux-arm64 + package: backlog.md-linux-arm64 + os: linux + cpu: arm64 + - target: bun-darwin-x64 + package: backlog.md-darwin-x64 + os: darwin + cpu: x64 + - target: bun-darwin-arm64 + package: backlog.md-darwin-arm64 + os: darwin + cpu: arm64 + - target: bun-windows-x64-baseline + package: backlog.md-windows-x64 + os: win32 + cpu: x64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: backlog-${{ matrix.target }} + timeout-minutes: 15 + - name: Prepare package + shell: bash + run: | + TAG="${GITHUB_REF##refs/tags/v}" + mkdir -p pkg + # Rename the binary to the expected name + if [[ -f backlog-bin.exe ]]; then + mv backlog-bin.exe pkg/backlog.exe + elif [[ -f backlog-bin ]]; then + mv backlog-bin pkg/backlog + else + echo "Error: No binary found" + ls -la + exit 1 + fi + cp LICENSE README.md pkg/ 2>/dev/null || true + cat <<EOF > pkg/package.json + { + "name": "${{ matrix.package }}", + "version": "${TAG}", + "os": ["${{ matrix.os }}"], + "cpu": ["${{ matrix.cpu }}"], + "files": ["backlog${{ contains(matrix.target,'windows') && '.exe' || '' }}","package.json","LICENSE"], + "repository": { + "type": "git", + "url": "https://github.com/MrLesk/Backlog.md" + } + } + EOF + - name: Ensure executable permission (non-Windows) + if: ${{ !contains(matrix.target,'windows') }} + shell: bash + run: | + chmod +x pkg/backlog + - uses: actions/setup-node@v5 + with: + node-version: 20 + - name: Configure npm for trusted publishing + shell: bash + run: | + set -euo pipefail + npm install -g npm@11.6.0 + npm --version + - name: Dry run platform publish + run: | + cd pkg + npm publish --access public --dry-run + - name: Publish platform package + run: | + cd pkg + npm publish --access public + + install-sanity: + name: install-sanity-${{ matrix.os }} + needs: [publish-binaries, npm-publish] + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-node@v5 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + - name: Install and run backlog -v (Unix) + if: ${{ matrix.os != 'windows-latest' }} + shell: bash + run: | + set -euxo pipefail + VERSION="${GITHUB_REF##refs/tags/v}" + mkdir sanity && cd sanity + npm init -y >/dev/null 2>&1 + npm i "backlog.md@${VERSION}" + npx backlog -v + - name: Install and run backlog -v (Windows) + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $Version = $env:GITHUB_REF_NAME.TrimStart('v') + mkdir sanity | Out-Null + Set-Location sanity + npm init -y | Out-Null + npm i "backlog.md@$Version" + npx backlog -v + + github-release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: release-assets + timeout-minutes: 15 + - name: Rename binaries for release + run: | + echo "=== Debug: Downloaded artifacts ===" + find release-assets -type f -exec ls -lh {} \; + echo "=== Processing artifacts ===" + mkdir -p binaries + for dir in release-assets/*/; do + if [ -d "$dir" ]; then + target=$(basename "$dir" | sed 's/backlog-//') + echo "Processing target: $target" + echo "Directory contents:" + ls -la "$dir" + binary=$(find "$dir" -name "backlog-bin*" -type f) + if [ -n "$binary" ]; then + echo "Found binary: $binary ($(ls -lh "$binary" | awk '{print $5}'))" + if [[ "$target" == *"windows"* ]] && [[ "$binary" == *".exe" ]]; then + cp "$binary" "binaries/backlog-${target}.exe" + echo "Copied to binaries/backlog-${target}.exe ($(ls -lh "binaries/backlog-${target}.exe" | awk '{print $5}'))" + else + cp "$binary" "binaries/backlog-${target}" + echo "Copied to binaries/backlog-${target} ($(ls -lh "binaries/backlog-${target}" | awk '{print $5}'))" + fi + fi + fi + done + echo "=== Final binaries ===" + ls -lh binaries/ + - uses: softprops/action-gh-release@v1 + with: + files: binaries/* + + update-readme: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.3 + - run: bun install --frozen-lockfile + - name: Sync version to tag + shell: bash + run: | + TAG="${GITHUB_REF##refs/tags/v}" + jq ".version = \"$TAG\"" package.json > tmp.json && mv tmp.json package.json + - name: Export board to README with version + shell: bash + run: | + TAG="${GITHUB_REF##refs/tags/v}" + bun run cli board export --readme --export-version "v$TAG" + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: "docs: update README with latest board status and version [skip ci]" + branch: main + file_pattern: README.md package.json diff --git a/.github/workflows/shai-hulud-check.yml b/.github/workflows/shai-hulud-check.yml new file mode 100644 index 0000000..0c3ece7 --- /dev/null +++ b/.github/workflows/shai-hulud-check.yml @@ -0,0 +1,18 @@ +name: Shai-Hulud 2.0 Security Check +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + security-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: gensecaihq/Shai-Hulud-2.0-Detector@v1 + with: + fail-on-critical: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55866cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +cli +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# bun build artifacts +*.bun-build +.*.bun-build + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Claude Code session data +.claude +# But include the agents directory +!.claude/agents +!.claude/agents/** + +# Gemini CLI session data +.gemini + +# Example project folder +example-project/ + +# blessed resources + +# Test temp directories created by bun tests +tmp/ +.tmp-test-* + +# Local files +*.local.* + +# Performance benchmark artifacts +test-benchmark-report.json + +.conductor diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..93e4383 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +# Add Bun to PATH if not present +export BUN_INSTALL="$HOME/.bun" +export PATH="$BUN_INSTALL/bin:$PATH" + +bun lint-staged diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..dd289ef --- /dev/null +++ b/.npmignore @@ -0,0 +1,33 @@ +# Source files +src/ +scripts/ +test-npm-package/ + +# Build artifacts +backlog +*.bun-build +.*.bun-build + +# Development files +.github/ +.backlog/ +.claude/ +.git/ +.gitignore +.npmignore +biome.json +bun.lock +tsconfig.json + +# Tests +*.test.ts +*.test.js + +# Other +.DS_Store +*.log +node_modules/ +.env* + +# Keep only essential files for npm package +# package.json, README.md, LICENSE, and cli.js are included by default \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ff93d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ + +<!-- BACKLOG.MD MCP GUIDELINES START --> + +<CRITICAL_INSTRUCTION> + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management activities. + +**CRITICAL GUIDANCE** + +- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project. +- If your client only supports tools or the above request fails, call `backlog.get_workflow_overview()` tool to load the tool-oriented overview (it lists the matching guide tools). + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +These guides cover: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and completion +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + +</CRITICAL_INSTRUCTION> + +<!-- BACKLOG.MD MCP GUIDELINES END --> + +When you're working on a task, you should assign it yourself: -a @codex + +In addition to the rules above, please consider the following: +At the end of every task implementation, try to take a moment to see if you can simplify it. +When you are done implementing, you know much more about a task than when you started. +At this point you can better judge retrospectively what can be the simplest architecture to solve the problem. +If you can simplify the code, do it. + +## Commands + +### Development + +- `bun i` - Install dependencies +- `bun test` - Run all tests +- `bunx tsc --noEmit` - Type-check code +- `bun run check .` - Run all Biome checks (format + lint) +- `bun run build` - Build the CLI tool +- `bun run cli` - Uses the CLI tool directly + +### Testing + +- `bun test` - Run all tests +- `bun test <filename>` - Run specific test file + +### Configuration Management + +- `bun run cli config list` - View all configuration values +- `bun run cli config get <key>` - Get a specific config value (e.g. defaultEditor) +- `bun run cli config set <key> <value>` - Set a config value with validation + +## Core Structure + +- **CLI Tool**: Built with Bun and TypeScript as a global npm package (`npm i -g backlog.md`) +- **Source Code**: Located in `/src` directory with modular TypeScript structure +- **Task Management**: Uses markdown files in `backlog/` directory structure +- **Workflow**: Git-integrated with task IDs referenced in commits and PRs + +## Code Standards + +- **Runtime**: Bun with TypeScript 5 +- **Formatting**: Biome with tab indentation and double quotes +- **Linting**: Biome recommended rules +- **Testing**: Bun's built-in test runner +- **Pre-commit**: Husky + lint-staged automatically runs Biome checks before commits + +The pre-commit hook automatically runs `biome check --write` on staged files to ensure code quality. If linting errors +are found, the commit will be blocked until fixed. + +## Git Workflow + +- **Branching**: Use feature branches when working on tasks (e.g. `tasks/task-123-feature-name`) +- **Committing**: Use the following format: `TASK-123 - Title of the task` +- **Github CLI**: Use `gh` whenever possible for PRs and issues + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c55cfb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,216 @@ +<!-- BACKLOG.MD MCP GUIDELINES START --> + +<CRITICAL_INSTRUCTION> + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management. + +**CRITICAL RESOURCE**: Read `backlog://workflow/overview` to understand when and how to use Backlog for this project. + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +The overview resource contains: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and completion +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + +</CRITICAL_INSTRUCTION> + +<!-- BACKLOG.MD MCP GUIDELINES END --> + +## Commands + +### Development +- `bun i` - Install dependencies +- `bun test` - Run all tests +- `bun run build` - Build the CLI tool +- `bun run cli` - Use the CLI tool directly + +### Testing & Quality +- `CLAUDECODE=1 bun test` - Run all tests with failures-only output (RECOMMENDED - full output is too long for Claude) +- `bun test <filename>` - Run specific test file +- `bun test src/**/*.test.ts` - Unit tests only +- `bun test src/mcp/**/*.test.ts` - MCP tests only +- `bun test --watch` - Run tests in watch mode +- `bunx tsc --noEmit` - Type-check code +- `bun run check .` - Run all Biome checks (format + lint) + +**Development Strategy**: Test specific files during development, run full suite before commits. +**Important**: Always use `CLAUDECODE=1` when running full test suite - the default verbose output exceeds Claude's consumption limits. + +### Performance Benchmarking +- `bun run benchmark` - Run performance benchmark on all test files + - Runs each test file individually and measures execution time + - Groups results by test prefix (mcp-, cli-, board-, etc.) + - Generates `test-benchmark-report.json` with detailed timing data + - Shows top 10 slowest tests and performance breakdown by category + +### Pre-Commit Validation (REQUIRED) +**Claude MUST verify all pass before committing:** +```bash +bunx tsc --noEmit # TypeScript compilation +bun run check . # Lint/format +CLAUDECODE=1 bun test --timeout 180000 # Full test suite (failures-only output) +``` + + +### Configuration +- `bun run cli config list` - View all configuration values +- `bun run cli config get <key>` - Get specific value (e.g. defaultEditor) +- `bun run cli config set <key> <value>` - Set with validation + +## Core Structure +- **CLI Tool**: Built with Bun and TypeScript as a global npm package (`npm i -g backlog.md`) +- **Source Code**: Located in `/src` directory with modular TypeScript structure +- **Task Management**: Uses markdown files in `backlog/` directory structure +- **Git Workflow**: Task IDs referenced in commits and PRs (`TASK-123 - Title`) + - **Branching**: Use feature branches when working on tasks (e.g. `tasks/task-123-feature-name`) + +## Code Standards +- **Runtime**: Bun with TypeScript 5 +- **Formatting**: Biome with tab indentation and double quotes +- **Linting**: Biome recommended rules +- **Testing**: Bun's built-in test runner +- **Pre-commit**: Husky + lint-staged automatically runs Biome checks before commits + +The pre-commit hook automatically runs `biome check --write` on staged files to ensure code quality. If linting errors are found, the commit will be blocked until fixed. + +## Architecture Guidelines +- **Separation of Concerns**: CLI logic and utility functions are kept separate to avoid side effects during testing +- **Utility Functions**: Reusable utility functions (like ID generators) are placed in `src/utils/` directory +- **No Side Effects on Import**: Modules should not execute CLI code when imported by other modules or tests +- **Branching**: Use feature branches when working on tasks (e.g. `tasks/task-123-feature-name`) +- **Committing**: Use the following format: `TASK-123 - Title of the task` +- **Github CLI**: Use `gh` whenever possible for PRs and issues + +## MCP Architecture Principles +- **MCP is a Pure Protocol Wrapper**: Protocol translation ONLY - no business logic, no feature extensions +- **CLI Feature Parity**: MCP = strict subset of CLI capabilities +- **Core API Usage**: All operations MUST use Core APIs (never direct filesystem/git) +- **Shared Utilities**: Reuse exact same utilities as CLI (`src/utils/task-builders.ts`) +- **πŸ”’ Local Development Only**: stdio transport only (see [/backlog/docs/mcp/README.md](backlog/docs/mcp/README.md)) + +**Violations to Avoid**: +- Custom business logic in MCP handlers +- Direct filesystem or git operations +- Features beyond CLI capabilities + +See MCP implementation in `/src/mcp/` for development details. + +## CLI Multi-line Input (description/plan/notes) +The CLI preserves input literally; `\n` sequences in normal quotes are not converted. Use one of the following when you need real newlines: + +- **Bash/Zsh (ANSI‑C quoting)**: + - `backlog task edit 42 --notes $'Line1\nLine2'` + - `backlog task edit 42 --plan $'1. A\n2. B'` +- **POSIX (printf)**: + - `backlog task edit 42 --desc "$(printf 'Line1\nLine2')"` +- **PowerShell (backtick)**: + - `backlog task edit 42 --desc "Line1\`nLine2"` + +*Note: `"...\n..."` passes literal backslash+n, not newline* + +## Using Bun +Default to using Bun instead of Node.js: + +- Use `bun <file>` instead of `node <file>` or `ts-node <file>` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` +- Bun automatically loads .env, so don't use dotenv +- Run `bunx tsc --noEmit` to perform TypeScript compilation checks as often as convenient + +### Key APIs +- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express` +- `bun:sqlite` for SQLite. Don't use `better-sqlite3` +- `Bun.redis` for Redis. Don't use `ioredis` +- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js` +- `WebSocket` is built-in. Don't use `ws` +- Prefer `Bun.file` over `node:fs`'s readFile/writeFile +- Bun.$`ls` instead of execa + +## Frontend Development +Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. + +### Build Commands (/src/web/) +- `bun run build:css` - Build Tailwind CSS +- `bun run build` - Build CSS + compile CLI binary + +### Architecture +- **HTML Imports**: Use `Bun.serve()` with direct .tsx/.jsx imports (no bundler needed) +- **CSS**: Tailwind CSS processed via `@tailwindcss/cli` +- **React**: Components in `/src/web/components/`, contexts in `/src/web/contexts/` +- **Bundling**: Bun handles .tsx/.jsx transpilation automatically + +### Server Example +```ts +import index from "./index.html" + +Bun.serve({ + routes: { + "/": index, + "/api/users/:id": { + GET: (req) => { + return new Response(JSON.stringify({ id: req.params.id })); + }, + }, + }, + // optional websocket support + websocket: { + open: (ws) => { ws.send("Hello, world!"); }, + message: (ws, message) => { ws.send(message); }, + close: (ws) => { /* handle close */ } + }, + development: { hmr: true, console: true } +}) +``` + +### Frontend Component Example +HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically: + +```html +<!-- index.html --> +<html> + <body> + <h1>Hello, world!</h1> + <script type="module" src="./frontend.tsx"></script> + </body> +</html> +``` + +```tsx +// frontend.tsx +import React from "react"; +import './index.css'; // CSS imports work directly +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return <h1>Hello, world!</h1>; +} + +root.render(<Frontend />); +``` + +Run with: `bun --hot ./index.ts` + +## Testing +Use `bun test` to run tests: + +```ts +import { test, expect } from "bun:test"; + +test("hello world", () => { + expect(1).toBe(1); +}); +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..33bc961 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# Contributing to Backlog.md + +Thank you for your interest in contributing to Backlog.md. This project is managed using the Backlog.md workflow and we welcome community involvement. + +## Opening Issues + +- Search existing issues before creating a new one. +- Provide a clear description of the problem or proposal. +- Reference the related task ID when applicable. + +## Pull Requests + +1. Fork the repository and create a branch named after the task ID and a short description (e.g. `task-27-contributing-guidelines`). +2. Make your changes and commit them with the task ID in the message. +3. Run tests with `bun test` and ensure they pass. +4. Format and lint the code using `npx biome check .`. +5. Open a pull request referencing the issue or task it addresses. + +Please read [AGENTS.md](AGENTS.md) for detailed rules that apply to contributors and AI agents. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..168b71d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,196 @@ +## Local Development + +> **Runtime requirement:** Use Bun 1.2.23. Later Bun 1.3.x builds currently trigger a websocket CPU regression ([oven-sh/bun#23536](https://github.com/oven-sh/bun/issues/23536)), which also affects `backlog browser`. Our CI is pinned to 1.2.23 until the upstream fix lands. + +Run these commands to bootstrap the project: + +```bash +bun install +``` + +Run tests: + +```bash +bun test +``` + +Format and lint: + +```bash +npx biome check . +``` + +For contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md). + +## MCP Development Setup + +This project supports MCP (Model Context Protocol) integration. To develop and test MCP features: + +### Prerequisites + +Install at least one AI coding assistant: +- [Claude Code](https://claude.ai/download) +- [OpenAI Codex CLI](https://openai.com/codex) +- [Google Gemini CLI](https://cloud.google.com/gemini/docs/codeassist/gemini-cli) + +### Local MCP Testing + +#### 1. Start MCP Server in Development Mode + +```bash +# Terminal 1: Start the MCP server +bun run mcp + +# Optional: include debug logs +bun run mcp -- --debug +``` + +The server will start and listen on stdio. You should see log messages confirming the stdio transport is active. + +#### 2. Configure Your Agent + +Choose one of the methods below based on your agent: + +**Claude Code (Recommended for Development):** +```bash +# Add to project (creates .mcp.json) +claude mcp add backlog-dev -- bun run mcp +``` + +**Codex CLI:** +```bash +# Edit ~/.codex/config.toml +[mcp_servers.backlog-dev] +command = "bun" +args = ["run", "mcp"] +``` + +**Gemini CLI:** +```bash +gemini mcp add backlog-dev bun run mcp +``` + +#### 3. Test the Connection + +Open your agent and test: +- "Show me all tasks in this project" +- "Create a test task called 'Test MCP Integration'" +- "Display the current board" + +#### 4. Development Workflow + +1. Make changes to MCP tools in `src/mcp/tools/` +2. Restart the MCP server (Ctrl+C, then re-run) +3. Restart your AI agent +4. Test your changes + +### Testing Individual Agents + +Each AI agent has different configuration requirements. Start the server from your project root and follow the assistant's instructions to register it: + +```bash +backlog mcp start +``` + +### Testing with MCP Inspector + +Use the Inspector tooling when you want to exercise the stdio server outside an AI agent. + +#### GUI workflow (`npx @modelcontextprotocol/inspector`) + +1. Launch the Inspector UI in a terminal: `npx @modelcontextprotocol/inspector` +2. Choose **STDIO** transport. +3. Fill the connection fields exactly as follows: + - **Command**: `bun` + - **Arguments** (enter each item separately): `--cwd`, `/Users/<you>/Projects/Backlog.md`, `src/cli.ts`, `mcp`, `start` + - Remove any proxy token; it is not needed for local stdio. +4. Connect and use the tools/resources panes to issue MCP requests. + +> Replace `/Users/<you>/Projects/Backlog.md` with the absolute path to your local Backlog.md checkout. + +`bun run mcp` by itself prints Bun's `$ bun …` preamble, which breaks the Inspector’s JSON parser. If you prefer using the package script here, add `--silent` so the startup log disappears: + +``` +Command: bun +Arguments: run, --silent, mcp +``` + +> Remember to substitute your own project directory for `/Users/<you>/Projects/Backlog.md`. + +#### CLI workflow (`npx @modelcontextprotocol/inspector-cli`) + +Run the CLI helper when you want to script quick checks: + +```bash +npx @modelcontextprotocol/inspector-cli \ + --cli \ + --transport stdio \ + --method tools/list \ + -- bun --cwd /Users/<you>/Projects/Backlog.md src/cli.ts mcp start +``` + +The key detail in both flows is to call `src/cli.ts mcp start` directly (or `bun run --silent mcp`) so stdout stays pure JSON for the MCP handshake. + +### Adding New MCP Agents + + +### Project Structure + +``` +backlog.md/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ mcp/ +β”‚ β”‚ β”œβ”€β”€ errors/ # MCP error helpers +β”‚ β”‚ β”œβ”€β”€ resources/ # Read-only resource adapters +β”‚ β”‚ β”œβ”€β”€ tools/ # MCP tool implementations +β”‚ β”‚ β”œβ”€β”€ utils/ # Shared utilities +β”‚ β”‚ β”œβ”€β”€ validation/ # Input validators +β”‚ β”‚ └── server.ts # createMcpServer entry point +└── docs/ + β”œβ”€β”€ mcp/ # User-facing MCP docs + └── development/ # Developer docs +``` + +## Release + +Backlog.md now relies on npm Trusted Publishing with GitHub Actions OIDC. The +release workflow builds binaries, publishes all npm packages, and records +provenance automatically. Follow the steps below to keep the setup healthy. + +### Prerequisites + +- Choose the release version and ensure your git tag follows the + `v<major.minor.patch>` pattern. The workflow automatically rewrites + `package.json` files to match the tag, so you do **not** need to edit the + version field manually. +- In npm's **Trusted publishers** settings, link the + `MrLesk/Backlog.md` repository and the `Release multi-platform executables` + workflow for each package: `backlog.md`, + `backlog.md-linux-{x64,arm64}`, `backlog.md-darwin-{x64,arm64}`, and + `backlog.md-windows-x64`. +- Remove the legacy `NODE_AUTH_TOKEN` repository secret. Publishing now uses + the GitHub-issued OIDC token, so no long-lived npm tokens should remain. +- The workflow activates `npm@latest` (currently 11.6.0 as of 2025-09-18) via + Corepack to satisfy npm's trusted publishing requirement of version 11.5.1 or + newer. If npm raises the minimum version again, the latest tag will pick it + up automatically. + +### Publishing steps + +1. Commit the version bump and create a matching tag. You can either push the + tag from your terminal + ```bash + git tag v<major.minor.patch> + git push origin main v<major.minor.patch> + ``` + or create a GitHub Release in the UI (which creates the tag automatically). + Both paths trigger the same `Release multi-platform executables` workflow. +2. Monitor the workflow run: + - `Dry run trusted publish` and `Dry run platform publish` confirm that + npm accepts the trusted publisher token before any real publish. + - Publishing uses trusted publishing (no tokens) so npm automatically records + provenance; no additional CLI flags are required. +3. After the workflow completes, verify provenance on npm by opening each + package's **Provenance** tab or by running `npm view <package> --json | jq '.dist.provenance'`. + +[← Back to README](README.md) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8d7d1f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Use Bun base image +FROM oven/bun:1 AS base +WORKDIR /app + +# Install dependencies (including dev for build) +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock* bunfig.toml /temp/dev/ +# Install all dependencies (needed for build:css) +RUN cd /temp/dev && bun install --frozen-lockfile --ignore-scripts + +# Copy application code +FROM base AS release +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# Build CSS +RUN bun run build:css + +# Initialize a default backlog project +RUN bun src/cli.ts init "Backlog Server" || true + +# Expose port +EXPOSE 6420 + +# Set environment +ENV NODE_ENV=production +ENV PORT=6420 + +# Run the web server +CMD ["bun", "src/cli.ts", "browser", "--port", "6420"] diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..16ee676 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,72 @@ +<!-- BACKLOG.MD MCP GUIDELINES START --> + +<CRITICAL_INSTRUCTION> + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management. + +**CRITICAL RESOURCE**: Read `backlog://workflow/overview` to understand when and how to use Backlog for this project. + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +The overview resource contains: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and completion +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + +</CRITICAL_INSTRUCTION> + +<!-- BACKLOG.MD MCP GUIDELINES END --> + +## Commands + +### Development + +- `bun i` - Install dependencies +- `bun test` - Run tests +- `bun run format` - Format code with Biome +- `bun run lint` - Lint and auto-fix with Biome +- `bun run check` - Run all Biome checks (format + lint) +- `bun run build` - Build the CLI tool +- `bun run cli` - Uses the CLI tool directly + +### Testing + +- `bun test` - Run all tests +- `bun test <filename>` - Run specific test file + +### Configuration Management + +- `bun run cli config list` - View all configuration values +- `bun run cli config get <key>` - Get a specific config value (e.g. defaultEditor) +- `bun run cli config set <key> <value>` - Set a config value with validation + +## Core Structure + +- **CLI Tool**: Built with Bun and TypeScript as a global npm package (`npm i -g backlog.md`) +- **Source Code**: Located in `/src` directory with modular TypeScript structure +- **Task Management**: Uses markdown files in `backlog/` directory structure +- **Workflow**: Git-integrated with task IDs referenced in commits and PRs + +## Code Standards + +- **Runtime**: Bun with TypeScript 5 +- **Formatting**: Biome with tab indentation and double quotes +- **Linting**: Biome recommended rules +- **Testing**: Bun's built-in test runner +- **Pre-commit**: Husky + lint-staged automatically runs Biome checks before commits + +The pre-commit hook automatically runs `biome check --write` on staged files to ensure code quality. If linting errors +are found, the commit will be blocked until fixed. + +## Git Workflow + +- **Branching**: Use feature branches when working on tasks (e.g. `tasks/task-123-feature-name`) +- **Committing**: Use the following format: `TASK-123 - Title of the task` +- **Github CLI**: Use `gh` whenever possible for PRs and issues diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..171f0d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Backlog.md + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b36dcba --- /dev/null +++ b/README.md @@ -0,0 +1,493 @@ +<h1 align="center">Backlog.md</h1> +<p align="center">Markdown‑native Task Manager & Kanban visualizer for any Git repository</p> + +<p align="center"> +<code>npm i -g backlog.md</code> or <code>bun add -g backlog.md</code> or <code>brew install backlog-md</code> or <code>nix run github:MrLesk/Backlog.md</code> +</p> + +![Backlog demo GIF using: backlog board](./.github/backlog.gif) + + +--- + +> **Backlog.md** turns any folder with a Git repo into a **self‑contained project board** +> powered by plain Markdown files and a zero‑config CLI. + +## Features + +* πŸ“ **Markdown-native tasks** -- manage every issue as a plain `.md` file + +* πŸ€– **AI-Ready** -- Works with Claude Code, Gemini CLI, Codex & any other MCP or CLI compatible AI assistants + +* πŸ“Š **Instant terminal Kanban** -- `backlog board` paints a live board in your shell + +* 🌐 **Modern web interface** -- `backlog browser` launches a sleek web UI for visual task management + +* πŸ” **Powerful search** -- fuzzy search across tasks, docs & decisions with `backlog search` + +* πŸ“‹ **Rich query commands** -- view, list, filter, or archive tasks with ease + +* πŸ“€ **Board export** -- `backlog board export` creates shareable markdown reports + +* πŸ”’ **100 % private & offline** -- backlog lives entirely inside your repo and you can manage everything locally + +* πŸ’» **Cross-platform** -- runs on macOS, Linux, and Windows + +* πŸ†“ **MIT-licensed & open-source** -- free for personal or commercial use + + +--- + +## <img src="./.github/5-minute-tour-256.png" alt="5-minute tour" width="28" height="28" align="center"> Five‑minute tour +```bash +# 1. Make sure you have Backlog.md installed (global installation recommended) +bun i -g backlog.md +or +npm i -g backlog.md +or +brew install backlog-md + +# 2. Bootstrap a repo + backlog and choose the AI Agent integration mode (MCP, CLI, or skip) +backlog init "My Awesome Project" + +# 3. Create tasks manually +backlog task create "Render markdown as kanban" + +# 4. Or ask AI to create them: Claude Code, Gemini CLI, or Codex (Agents automatically use Backlog.md via MCP or CLI) +Claude I would like to build a search functionality in the web view that searches for: +* tasks +* docs +* decisions + Please create relevant tasks to tackle this request. + +# 5. See where you stand +backlog board view or backlog browser + +# 6. Assign tasks to AI (Backlog.md instructions tell agents how to work with tasks) +Claude please implement all tasks related to the web search functionality (task-10, task-11, task-12) +* before starting to write code use 'ultrathink mode' to prepare and add an implementation plan to the task +* use multiple sub-agents when possible and dependencies allow +``` + +All data is saved under `backlog` folder as human‑readable Markdown with the following format `task-<task-id> - <task-title>.md` (e.g. `task-10 - Add core search functionality.md`). + +--- + +## <img src="./.github/web-interface-256.png" alt="Web Interface" width="28" height="28" align="center"> Web Interface + +Launch a modern, responsive web interface for visual task management: + +```bash +# Start the web server (opens browser automatically) +backlog browser + +# Custom port +backlog browser --port 8080 + +# Don't open browser automatically +backlog browser --no-open +``` + +**Features:** +- Interactive Kanban board with drag-and-drop +- Task creation and editing with rich forms +- Interactive acceptance criteria editor with checklists +- Real-time updates across all views +- Responsive design for desktop and mobile +- Task archiving with confirmation dialogs +- Seamless CLI integration - all changes sync with markdown files + +![Web Interface Screenshot](./.github/web.jpeg) + +--- + +## πŸ”§ MCP Integration (Model Context Protocol) + +The easiest way to connect Backlog.md to AI coding assistants like Claude Code, Codex, and Gemini CLI is via the MCP protocol. +You can run `backlog init` (even if you already initialized Backlog.md) to set up MCP integration automatically, or follow the manual steps below. + +### Client guides + +> [!IMPORTANT] +> When adding the MCP server manually, you should add some extra instructions in your CLAUDE.md/AGENTS.md files to inform the agent about Backlog.md. +> This step is not required when using `backlog init` as it adds these instructions automatically. +> Backlog.md's instructions for agents are available at [`/src/guidelines/mcp/agent-nudge.md`](/src/guidelines/mcp/agent-nudge.md). + +<details> + <summary><strong>Claude Code</strong></summary> + + ```bash + claude mcp add backlog --scope user -- backlog mcp start + ``` + +</details> + +<details> + <summary><strong>Codex</strong></summary> + + ```bash + codex mcp add backlog backlog mcp start + ``` + +</details> + +<details> + <summary><strong>Gemini CLI</strong></summary> + + ```bash + gemini mcp add backlog -s user backlog mcp start + ``` + +</details> + +Use the shared `backlog` server name everywhere – the MCP server auto-detects whether the current directory is initialized and falls back to `backlog://init-required` when needed. + +### Manual config + +```json +{ + "mcpServers": { + "backlog": { + "command": "backlog", + "args": ["mcp", "start"] + } + } +} +``` + +Once connected, agents can read the Backlog.md workflow instructions via the resource `backlog://docs/task-workflow`. +Use `/mcp` command in your AI tool (Claude Code, Codex) to verify if the connection is working. + +--- + +## <img src="./.github/cli-reference-256.png" alt="CLI Reference" width="28" height="28" align="center"> CLI reference + +### Project Setup + +| Action | Example | +|-------------|------------------------------------------------------| +| Initialize project | `backlog init [project-name]` (creates backlog structure with a minimal interactive flow) | +| Re-initialize | `backlog init` (preserves existing config, allows updates) | +| Advanced settings wizard | `backlog config` (no args) β€” launches the full interactive configuration flow | + +`backlog init` keeps first-run setup focused on the essentials: +- **Project name** – identifier for your backlog (defaults to the current directory on re-run). +- **Integration choice** – decide whether your AI tools connect through the **MCP connector** (recommended) or stick with **CLI commands (legacy)**. +- **Instruction files (CLI path only)** – when you choose the legacy CLI flow, pick which instruction files to create (CLAUDE.md, AGENTS.md, GEMINI.md, Copilot, or skip). +- **Advanced settings prompt** – default answer β€œNo” finishes init immediately; choosing β€œYes” jumps straight into the advanced wizard documented in [Configuration](#configuration). + +You can rerun the wizard anytime with `backlog config`. All existing CLI flags (for example `--defaults`, `--agent-instructions`, or `--install-claude-agent true`) continue to provide fully non-interactive setups, so existing scripts keep working without change. + +### Documentation + +- Document IDs are global across all subdirectories under `backlog/docs`. You can organize files in nested folders (e.g., `backlog/docs/guides/`), and `backlog doc list` and `backlog doc view <id>` work across the entire tree. Example: `backlog doc create -p guides "New Guide"`. + +### Task Management + +| Action | Example | +|-------------|------------------------------------------------------| +| Create task | `backlog task create "Add OAuth System"` | +| Create with description | `backlog task create "Feature" -d "Add authentication system"` | +| Create with assignee | `backlog task create "Feature" -a @sara` | +| Create with status | `backlog task create "Feature" -s "In Progress"` | +| Create with labels | `backlog task create "Feature" -l auth,backend` | +| Create with priority | `backlog task create "Feature" --priority high` | +| Create with plan | `backlog task create "Feature" --plan "1. Research\n2. Implement"` | +| Create with AC | `backlog task create "Feature" --ac "Must work,Must be tested"` | +| Create with notes | `backlog task create "Feature" --notes "Started initial research"` | +| Create with deps | `backlog task create "Feature" --dep task-1,task-2` | +| Create sub task | `backlog task create -p 14 "Add Login with Google"`| +| Create (all options) | `backlog task create "Feature" -d "Description" -a @sara -s "To Do" -l auth --priority high --ac "Must work" --notes "Initial setup done" --dep task-1 -p 14` | +| List tasks | `backlog task list [-s <status>] [-a <assignee>] [-p <parent>]` | +| List by parent | `backlog task list --parent 42` or `backlog task list -p task-42` | +| View detail | `backlog task 7` (interactive UI, press 'E' to edit in editor) | +| View (AI mode) | `backlog task 7 --plain` | +| Edit | `backlog task edit 7 -a @sara -l auth,backend` | +| Add plan | `backlog task edit 7 --plan "Implementation approach"` | +| Add AC | `backlog task edit 7 --ac "New criterion" --ac "Another one"` | +| Remove AC | `backlog task edit 7 --remove-ac 2` (removes AC #2) | +| Remove multiple ACs | `backlog task edit 7 --remove-ac 2 --remove-ac 4` (removes AC #2 and #4) | +| Check AC | `backlog task edit 7 --check-ac 1` (marks AC #1 as done) | +| Check multiple ACs | `backlog task edit 7 --check-ac 1 --check-ac 3` (marks AC #1 and #3 as done) | +| Uncheck AC | `backlog task edit 7 --uncheck-ac 3` (marks AC #3 as not done) | +| Mixed AC operations | `backlog task edit 7 --check-ac 1 --uncheck-ac 2 --remove-ac 4` | +| Add notes | `backlog task edit 7 --notes "Completed X, working on Y"` (replaces existing) | +| Append notes | `backlog task edit 7 --append-notes "New findings"` | +| Add deps | `backlog task edit 7 --dep task-1 --dep task-2` | +| Archive | `backlog task archive 7` | + +#### Multi‑line input (description/plan/notes) + +The CLI preserves input literally; `\n` sequences are not auto‑converted. Use one of the following to insert real newlines: + +- **Bash/Zsh (ANSI‑C quoting)** + - Description: `backlog task create "Feature" --desc $'Line1\nLine2\n\nFinal paragraph'` + - Plan: `backlog task edit 7 --plan $'1. Research\n2. Implement'` + - Notes: `backlog task edit 7 --notes $'Completed A\nWorking on B'` + - Append notes: `backlog task edit 7 --append-notes $'Added X\nAdded Y'` +- **POSIX sh (printf)** + - `backlog task create "Feature" --desc "$(printf 'Line1\nLine2\n\nFinal paragraph')"` +- **PowerShell (backtick)** + - `backlog task create "Feature" --desc "Line1`nLine2`n`nFinal paragraph"` + +Tip: Help text shows Bash examples with escaped `\\n` for readability; when typing, `$'\n'` expands to a newline. + +### Search + +Find tasks, documents, and decisions across your entire backlog with fuzzy search: + +| Action | Example | +|--------------------|------------------------------------------------------| +| Search tasks | `backlog search "auth"` | +| Filter by status | `backlog search "api" --status "In Progress"` | +| Filter by priority | `backlog search "bug" --priority high` | +| Combine filters | `backlog search "web" --status "To Do" --priority medium` | +| Plain text output | `backlog search "feature" --plain` (for scripts/AI) | + +**Search features:** +- **Fuzzy matching** -- finds "authentication" when searching for "auth" +- **Interactive filters** -- refine your search in real-time with the TUI +- **Live filtering** -- see results update as you type (no Enter needed) + +### Draft Workflow + +| Action | Example | +|-------------|------------------------------------------------------| +| Create draft | `backlog task create "Feature" --draft` | +| Draft flow | `backlog draft create "Spike GraphQL"` β†’ `backlog draft promote 3.1` | +| Demote to draft| `backlog task demote <id>` | + +### Dependency Management + +Manage task dependencies to create execution sequences and prevent circular relationships: + +| Action | Example | +|-------------|------------------------------------------------------| +| Add dependencies | `backlog task edit 7 --dep task-1 --dep task-2` | +| Add multiple deps | `backlog task edit 7 --dep task-1,task-5,task-9` | +| Create with deps | `backlog task create "Feature" --dep task-1,task-2` | +| View dependencies | `backlog task 7` (shows dependencies in task view) | +| Validate dependencies | Use task commands to automatically validate dependencies | + +**Dependency Features:** +- **Automatic validation**: Prevents circular dependencies and validates task existence +- **Flexible formats**: Use `task-1`, `1`, or comma-separated lists like `1,2,3` +- **Visual sequences**: Dependencies create visual execution sequences in board view +- **Completion tracking**: See which dependencies are blocking task progress + +### Board Operations + +| Action | Example | +|-------------|------------------------------------------------------| +| Kanban board | `backlog board` (interactive UI, press 'E' to edit in editor) | +| Export board | `backlog board export [file]` (exports Kanban board to markdown) | +| Export with version | `backlog board export --export-version "v1.0.0"` (includes version in export) | + +### Statistics & Overview + +| Action | Example | +|-------------|------------------------------------------------------| +| Project overview | `backlog overview` (interactive TUI showing project statistics) | + +### Web Interface + +| Action | Example | +|-------------|------------------------------------------------------| +| Web interface | `backlog browser` (launches web UI on port 6420) | +| Web custom port | `backlog browser --port 8080 --no-open` | + +### Documentation + +| Action | Example | +|-------------|------------------------------------------------------| +| Create doc | `backlog doc create "API Guidelines"` | +| Create with path | `backlog doc create "Setup Guide" -p guides/setup` | +| Create with type | `backlog doc create "Architecture" -t technical` | +| List docs | `backlog doc list` | +| View doc | `backlog doc view doc-1` | + +### Decisions + +| Action | Example | +|-------------|------------------------------------------------------| +| Create decision | `backlog decision create "Use PostgreSQL for primary database"` | +| Create with status | `backlog decision create "Migrate to TypeScript" -s proposed` | + +### Agent Instructions + +| Action | Example | +|-------------|------------------------------------------------------| +| Update agent files | `backlog agents --update-instructions` (updates CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md) | + +### Maintenance + +| Action | Example | +|-------------|------------------------------------------------------| +| Cleanup done tasks | `backlog cleanup` (move old completed tasks to completed folder) | + +Full help: `backlog --help` + +--- + +## <img src="./.github/configuration-256.png" alt="Configuration" width="28" height="28" align="center"> Configuration + +Backlog.md merges the following layers (highestβ€―β†’β€―lowest): + +1. CLI flags +2. `backlog/config.yml` (per‑project) +3. `~/backlog/user` (per‑user) +4. Built‑ins + +### Configuration Commands + +| Action | Example | +|-------------|------------------------------------------------------| +| View all configs | `backlog config list` | +| Get specific config | `backlog config get defaultEditor` | +| Set config value | `backlog config set defaultEditor "code --wait"` | +| Enable auto-commit | `backlog config set autoCommit true` | +| Bypass git hooks | `backlog config set bypassGitHooks true` | +| Enable cross-branch check | `backlog config set checkActiveBranches true` | +| Set active branch days | `backlog config set activeBranchDays 30` | + +### Interactive wizard (`backlog config`) + +Run `backlog config` with no arguments to launch the full interactive wizard. This is the same experience triggered from `backlog init` when you opt into advanced settings, and it walks through the complete configuration surface: +- Cross-branch accuracy: `checkActiveBranches`, `remoteOperations`, and `activeBranchDays`. +- Git workflow: `autoCommit` and `bypassGitHooks`. +- ID formatting: enable or size `zeroPaddedIds`. +- Editor integration: pick a `defaultEditor` with availability checks. +- Web UI defaults: choose `defaultPort` and whether `autoOpenBrowser` should run. + +Skipping the wizard (answering β€œNo” during init) applies the safe defaults that ship with Backlog.md: +- `checkActiveBranches=true`, `remoteOperations=true`, `activeBranchDays=30`. +- `autoCommit=false`, `bypassGitHooks=false`. +- `zeroPaddedIds` disabled. +- `defaultEditor` unset (falls back to your environment). +- `defaultPort=6420`, `autoOpenBrowser=true`. + +Whenever you revisit `backlog init` or rerun `backlog config`, the wizard pre-populates prompts with your current values so you can adjust only what changed. + +### Available Configuration Options + +| Key | Purpose | Default | +|-------------------|--------------------|-------------------------------| +| `defaultAssignee` | Pre‑fill assignee | `[]` | +| `defaultStatus` | First column | `To Do` | +| `statuses` | Board columns | `[To Do, In Progress, Done]` | +| `dateFormat` | Date/time format | `yyyy-mm-dd hh:mm` | +| `timezonePreference` | Timezone for dates | `UTC` | +| `includeDatetimeInDates` | Add time to new dates | `true` | +| `defaultEditor` | Editor for 'E' key | Platform default (nano/notepad) | +| `defaultPort` | Web UI port | `6420` | +| `autoOpenBrowser` | Open browser automatically | `true` | +| `remoteOperations`| Enable remote git operations | `true` | +| `autoCommit` | Automatically commit task changes | `false` | +| `bypassGitHooks` | Skip git hooks when committing (uses --no-verify) | `false` | +| `zeroPaddedIds` | Pad all IDs (tasks, docs, etc.) with leading zeros | `(disabled)` | +| `checkActiveBranches` | Check task states across active branches for accuracy | `true` | +| `activeBranchDays` | How many days a branch is considered active | `30` | +| `onStatusChange` | Shell command to run on status change | `(disabled)` | + +> Editor setup guide: See [Configuring VIM and Neovim as Default Editor](backlog/docs/doc-002%20-%20Configuring-VIM-and-Neovim-as-Default-Editor.md) for configuration tips and troubleshooting interactive editors. + +> **Note**: Set `remoteOperations: false` to work offline. This disables git fetch operations and loads tasks from local branches only, useful when working without network connectivity. + +> **Git Control**: By default, `autoCommit` is set to `false`, giving you full control over your git history. Task operations will modify files but won't automatically commit changes. Set `autoCommit: true` if you prefer automatic commits for each task operation. + +> **Git Hooks**: If you have pre-commit hooks (like conventional commits or linters) that interfere with backlog.md's automated commits, set `bypassGitHooks: true` to skip them using the `--no-verify` flag. + +> **Performance**: Cross-branch checking ensures accurate task tracking across all active branches but may impact performance on large repositories. You can disable it by setting `checkActiveBranches: false` for maximum speed, or adjust `activeBranchDays` to control how far back to look for branch activity (lower values = better performance). + +> **Status Change Callbacks**: Set `onStatusChange` to run a shell command whenever a task's status changes. Available variables: `$TASK_ID`, `$OLD_STATUS`, `$NEW_STATUS`, `$TASK_TITLE`. Per-task override via `onStatusChange` in task frontmatter. Example: `'if [ "$NEW_STATUS" = "In Progress" ]; then claude "Task $TASK_ID ($TASK_TITLE) has been assigned to you. Please implement it." & fi'` + +> **Date/Time Support**: Backlog.md now supports datetime precision for all dates. New items automatically include time (YYYY-MM-DD HH:mm format in UTC), while existing date-only entries remain unchanged for backward compatibility. Use the migration script `bun src/scripts/migrate-dates.ts` to optionally add time to existing items. + +--- + +## πŸ’‘ Shell Tab Completion + +Backlog.md includes built-in intelligent tab completion for bash, zsh, and fish shells. Completion scripts are embedded in the binaryβ€”no external files needed. + +**Quick Installation:** +```bash +# Auto-detect and install for your current shell +backlog completion install + +# Or specify shell explicitly +backlog completion install --shell bash +backlog completion install --shell zsh +backlog completion install --shell fish +``` + +**What you get:** +- Command completion: `backlog <TAB>` β†’ shows all commands +- Dynamic task IDs: `backlog task edit <TAB>` β†’ shows actual task IDs from your backlog +- Smart flags: `--status <TAB>` β†’ shows configured status values +- Context-aware suggestions for priorities, labels, and assignees + +πŸ“– **Full documentation**: See [completions/README.md](completions/README.md) for detailed installation instructions, troubleshooting, and examples. + +--- + +## <img src="./.github/sharing-export-256.png" alt="Sharing & Export" width="28" height="28" align="center"> Sharing & Export + +### Board Export + +Export your Kanban board to a clean, shareable markdown file: + +```bash +# Export to default Backlog.md file +backlog board export + +# Export to custom file +backlog board export project-status.md + +# Force overwrite existing file +backlog board export --force + +# Export to README.md with board markers +backlog board export --readme + +# Include a custom version string in the export +backlog board export --export-version "v1.2.3" +backlog board export --readme --export-version "Release 2024.12.1-beta" +``` + +Perfect for sharing project status, creating reports, or storing snapshots in version control. + +--- + +<!-- BOARD_START --> + +## πŸ“Š Backlog.md Project Status (v1.26.0) + +This board was automatically generated by [Backlog.md](https://backlog.md) + +Generated on: 2025-12-03 22:22:53 + +| To Do | In Progress | Done | +| --- | --- | --- | +| **TASK-310** - Strengthen Backlog workflow overview emphasis on reading detailed guides [@codex] | └─ **TASK-24.1** - CLI: Kanban board milestone view [@codex] | **TASK-309** - Improve TUI empty state when task filters return no results [@codex] | +| **TASK-270** - Prevent command substitution in task creation inputs [@codex] | | **TASK-333** - Keep cross-branch tasks out of plain CLI/MCP listings [@codex]<br>*#cli #mcp #bug* | +| **TASK-268** - Show agent instruction version status [@codex] | | **TASK-332** - Unify CLI task list/board loading and view switching UX [@codex]<br>*#cli #ux #loading* | +| **TASK-267** - Add agent instruction version metadata [@codex] | | **TASK-331** - Fix content store refresh dropping cross-branch tasks [@codex]<br>*#bug #content-store* | +| **TASK-260** - Web UI: Add filtering to All Tasks view [@codex]<br>*#web-ui #filters #ui* | | **TASK-330** - Fix browser/CLI sync issue when reordering cross-branch tasks<br>*#bug #browser* | +| **TASK-259** - Add task list filters for Status and Priority<br>*#tui #filters #ui* | | **TASK-328** - Make filename sanitization stricter by default [@codex]<br>*#feature* | +| **TASK-257** - Deep link URLs for tasks in board and list views | | **TASK-327** - Fix loadTaskById to search remote branches<br>*#bug #task-loading #cross-branch* | +| **TASK-200** - Add Claude Code integration with workflow commands during init<br>*#enhancement #developer-experience* | | **TASK-326** - Add local branch task discovery to board loading<br>*#bug #task-loading #cross-branch* | +| **TASK-218** - Update documentation and tests for sequences<br>*#sequences #documentation #testing* | | **TASK-324** - Add browser UI initialization flow for uninitialized projects<br>*#enhancement #browser #ux* | +| **TASK-217** - Create web UI for sequences with drag-and-drop<br>*#sequences #web-ui #frontend* | | **TASK-289** - Implement resource templates list handler to return empty list instead of error [@codex]<br>*#mcp #enhancement* | +| └─ **TASK-217.03** - Sequences web UI: move tasks and update dependencies<br>*#sequences* | | **TASK-280** - Fix TUI task list selection and detail pane synchronization bug [@codex]<br>*#bug #tui* | +| └─ **TASK-217.04** - Sequences web UI: tests<br>*#sequences* | | **TASK-273** - Refactor search [@codex]<br>*#core #search* | +| └─ **TASK-217.02** - Sequences web UI: list sequences<br>*#sequences* | | **TASK-322** - Fix flake.nix for devenv compatibility<br>*#nix #bug-fix* | +| **TASK-240** - Improve binary resolution on Apple Silicon (Rosetta/arch mismatch) [@codex]<br>*#packaging #bug #macos* | | **TASK-321** - Status change callbacks in task frontmatter [@codex] | +| **TASK-239** - Feature: Auto-link tasks to documents/decisions + backlinks [@codex]<br>*#web #enhancement #docs* | | **TASK-320** - Refactor and fix move mode implementation [@claude]<br>*#bug #tui #high-priority* | +| **TASK-222** - Improve task and subtask visualization in web UI | | **TASK-318** - Fix editor stdio inheritance for interactive editors (vim/neovim) [@samvincent]<br>*#bug #editor #vim* | +| **TASK-208** - Add paste-as-markdown support in Web UI<br>*#web-ui #enhancement #markdown* | | | + +<!-- BOARD_END --> + +### License + +Backlog.md is released under the **MIT License** – do anything, just give credit. See [LICENSE](LICENSE). diff --git a/backlog.md b/backlog.md new file mode 100644 index 0000000..aca4452 --- /dev/null +++ b/backlog.md @@ -0,0 +1,139 @@ +# Kanban Board Export (powered by Backlog.md) +Generated on: 2025-07-12 18:27:55 +Project: Backlog.md + +| To Do | In Progress | Done | +| --- | --- | --- | +| **task-172** - Order tasks by status and ID in both web and CLI lists (Assignees: none, Labels: none) | **└─ task-24.1** - CLI: Kanban board milestone view (Assignees: @codex, Labels: none) | **task-173** - Add CLI command to export Kanban board to markdown (Assignees: @claude, Labels: none) | +| **task-171** - Implement drafts list functionality in CLI and web UI (Assignees: none, Labels: none) | | **task-169** - Fix browser and board crashes (Assignees: @claude, Labels: none) | +| **task-116** - Add dark mode toggle to web UI (Assignees: none, Labels: none) | | **task-168** - Fix editor integration issues with vim/nano (Assignees: @claude, Labels: none) | +| | | **task-167** - Add --notes option to task create command (Assignees: @claude, Labels: none) | +| | | **task-166** - Audit and fix autoCommit behavior across all commands (Assignees: none, Labels: bug, config) | +| | | **task-165** - Fix BUN_OPTIONS environment variable conflict (Assignees: none, Labels: bug) | +| | | **task-164** - Add auto_commit config option with default false (Assignees: none, Labels: enhancement, config) | +| | | **task-163** - Fix intermittent git failure in task edit (Assignees: none, Labels: bug) | +| | | **task-120** - Add offline mode configuration for remote operations (Assignees: none, Labels: enhancement, offline, config) | +| | | **task-119** - Add documentation and decisions pages to web UI (Assignees: none, Labels: none) | +| | | **└─ task-119.1** - Fix comprehensive test suite for data model consistency (Assignees: none, Labels: none) | +| | | **└─ task-119.2** - Core architecture improvements and ID generation enhancements (Assignees: none, Labels: none) | +| | | **task-118** - Add side navigation menu to web UI (Assignees: none, Labels: none) | +| | | **└─ task-118.1** - UI/UX improvements and responsive design enhancements (Assignees: none, Labels: none) | +| | | **└─ task-118.2** - Implement health check API endpoint for web UI monitoring (Assignees: none, Labels: none) | +| | | **└─ task-118.3** - Advanced search and navigation features beyond basic requirements (Assignees: none, Labels: none) | +| | | **task-115** - Add live health check system to web UI (Assignees: none, Labels: none) | +| | | **task-114** - cli: filter task list by parent task (Assignees: none, Labels: none) | +| | | **task-112** - Add Tab key switching between task and kanban views with background loading (Assignees: none, Labels: none) | +| | | **task-111** - Add editor shortcut (E) to kanban and task views (Assignees: none, Labels: none) | +| | | **task-108** - Fix bug: Acceptance criteria removed when updating description (Assignees: none, Labels: none) | +| | | **task-107** - Add agents --update-instructions command (Assignees: none, Labels: none) | +| | | **task-106** - Add --desc alias for description flag (Assignees: none, Labels: none) | +| | | **task-105** - Remove dot from .backlog folder name (Assignees: none, Labels: none) | +| | | **task-104** - Add --notes flag to task edit command for implementation notes (Assignees: @claude, Labels: none) | +| | | **task-101** - Show task file path in plain view (Assignees: none, Labels: none) | +| | | **task-100** - Add embedded web server to Backlog CLI (Assignees: none, Labels: none) | +| | | **└─ task-100.1** - Setup React project structure with shadcn/ui (Assignees: none, Labels: none) | +| | | **└─ task-100.2** - Create HTTP server module (Assignees: none, Labels: none) | +| | | **└─ task-100.3** - Implement API endpoints (Assignees: none, Labels: none) | +| | | **└─ task-100.4** - Build Kanban board component (Assignees: none, Labels: none) | +| | | **└─ task-100.5** - Create task management components (Assignees: none, Labels: none) | +| | | **└─ task-100.6** - Add CLI browser command (Assignees: none, Labels: none) | +| | | **└─ task-100.7** - Bundle web assets into executable (Assignees: none, Labels: none) | +| | | **└─ task-100.8** - Add documentation and examples (Assignees: none, Labels: none) | +| | | **task-99** - Fix loading screen border rendering and improve UX (Assignees: none, Labels: none) | +| | | **task-98** - Invert task order in Done column only (Assignees: @Cursor, Labels: ui, enhancement) | +| | | **task-97** - Cross-branch task ID checking and branch info (Assignees: @Cursor, Labels: none) | +| | | **task-96** - Fix demoted task board visibility - check status across archive and drafts (Assignees: @Cursor, Labels: none) | +| | | **task-95** - Add priority field to tasks (Assignees: @claude, Labels: enhancement) | +| | | **task-94** - CLI: Show created task file path (Assignees: @claude, Labels: cli, enhancement) | +| | | **task-93** - Fix Windows agent instructions file reading hang (Assignees: @claude, Labels: none) | +| | | **task-92** - CI: Fix intermittent Windows test failures (Assignees: @claude, Labels: none) | +| | | **task-91** - Fix Windows issues: empty task list and weird Q character (Assignees: @MrLesk, Labels: bug, windows, regression) | +| | | **task-90** - Fix task list scrolling behavior - selector should move before scrolling (Assignees: @claude, Labels: bug, ui) | +| | | **task-89** - Add dependency parameter for task create and edit commands (Assignees: @claude, Labels: cli, enhancement) | +| | | **task-88** - Fix missing metadata and implementation plan in task view command (Assignees: @claude, Labels: bug, cli) | +| | | **task-87** - Make agent guideline file updates idempotent during init (Assignees: @claude, Labels: enhancement, cli, init) | +| | | **task-86** - Update agent guidelines to emphasize outcome-focused acceptance criteria (Assignees: none, Labels: documentation, agents) | +| | | **task-85** - Merge and consolidate loading screen functions (Assignees: @claude, Labels: refactor, optimization) | +| | | **task-84** - Add -ac flag for acceptance criteria in task create/edit (Assignees: @claude, Labels: enhancement, cli) | +| | | **task-83** - Add case-insensitive status filter support (Assignees: @claude, Labels: enhancement, cli) | +| | | **task-82** - Add --plain flag to task view command for AI agents (Assignees: @claude, Labels: none) | +| | | **task-81** - Fix task list navigation skipping issue (Assignees: @claude, Labels: none) | +| | | **task-80** - Preserve case in task filenames for better agent discoverability (Assignees: @AI, Labels: enhancement, ai-agents) | +| | | **task-79** - Fix task list ordering - sort by decimal ID not string (Assignees: @AI, Labels: bug, regression) | +| | | **task-77** - Migrate from blessed to bblessed for better Bun and Windows support (Assignees: @ai-agent, Labels: refactoring, dependencies, windows) | +| | | **task-76** - Add Implementation Plan section (Assignees: @claude, Labels: docs, cli) | +| | | **task-75** - Fix task selection in board view - opens wrong task (Assignees: @ai-agent, Labels: bug, ui, board) | +| | | **task-74** - Fix TUI crash on Windows by disabling blessed tput (Assignees: @codex, Labels: bug, windows) | +| | | **task-73** - Fix Windows binary package name resolution (Assignees: @codex, Labels: bug, windows, packaging) | +| | | **task-72** - Fix board view on Windows without terminfo (Assignees: none, Labels: bug, windows) | +| | | **task-71** - Fix single task view regression (Assignees: @codex, Labels: none) | +| | | **task-70** - CI: eliminate extra binary download (Assignees: @codex, Labels: ci, packaging) | +| | | **task-69** - CLI: start tasks IDs at 1 (Assignees: @codex, Labels: none) | +| | | **task-68** - Verify Windows binary uses .exe (Assignees: @codex, Labels: packaging) | +| | | **task-67** - Add -p shorthand for --parent option in task create command (Assignees: none, Labels: cli, enhancement) | +| | | **task-61** - Embed blessed in standalone binary (Assignees: @codex, Labels: cli, packaging) | +| | | **task-59** - Simplify init command with modern CLI (Assignees: @codex, Labels: cli) | +| | | **task-58** - Unify task list view to use task viewer component (Assignees: @codex, Labels: none) | +| | | **task-57** - Fix version command to support -v flag and display correct version (Assignees: @codex, Labels: none) | +| | | **task-56** - Simplify TUI blessed import (Assignees: @codex, Labels: refactor) | +| | | **task-54** - CLI: fix init prompt colors (Assignees: @codex, Labels: bug) | +| | | **└─ task-55** - CLI: simplify init text prompt (Assignees: @codex, Labels: bug) | +| | | **task-53** - Fix blessed screen bug in Bun install (Assignees: @codex, Labels: bug) | +| | | **task-52** - CLI: Filter tasks list by status or assignee (Assignees: @codex, Labels: none) | +| | | **task-51** - Code-path styling (Assignees: none, Labels: enhancement) | +| | | **task-50** - Borders & padding (Assignees: none, Labels: enhancement) | +| | | **task-49** - Status styling (Assignees: Claude, Labels: enhancement) | +| | | **task-48** - Footer hint line (Assignees: none, Labels: enhancement) | +| | | **task-47** - Sticky header in detail view (Assignees: none, Labels: enhancement) | +| | | **task-46** - Split-pane layout (Assignees: none, Labels: enhancement) | +| | | **task-45** - Safe line-wrapping (Assignees: none, Labels: enhancement) | +| | | **task-44** - Checklist alignment (Assignees: none, Labels: ui, enhancement) | +| | | **task-43** - Remove duplicate Acceptance Criteria and style metadata (Assignees: none, Labels: ui, enhancement) | +| | | **task-42** - Visual hierarchy (Assignees: none, Labels: ui, enhancement) | +| | | **task-41** - CLI: Migrate terminal UI to bblessed (Assignees: Claude, Labels: cli) | +| | | **└─ task-41.1** - CLI: bblessed init wizard (Assignees: Claude, Labels: cli) | +| | | **└─ task-41.2** - CLI: bblessed task view (Assignees: Claude, Labels: cli) | +| | | **└─ task-41.3** - CLI: bblessed doc view (Assignees: Claude, Labels: cli) | +| | | **└─ task-41.4** - CLI: bblessed board view (Assignees: Claude, Labels: cli) | +| | | **└─ task-41.5** - CLI: audit remaining UI for bblessed (Assignees: Claude, Labels: cli) | +| | | **task-40** - CLI: Board command defaults to view (Assignees: @codex, Labels: cli) | +| | | **task-39** - CLI: fix empty agent instruction files on init (Assignees: @codex, Labels: cli, bug) | +| | | **task-38** - CLI: Improved Agent Selection for Init (Assignees: @AI, Labels: none) | +| | | **task-36** - CLI: Prompt for project name in init (Assignees: @codex, Labels: none) | +| | | **task-35** - Finalize package.json metadata for publishing (Assignees: @codex, Labels: none) | +| | | **task-34** - Split README.md for users and contributors (Assignees: @codex, Labels: docs) | +| | | **task-32** - CLI: Hide empty 'No Status' column (Assignees: none, Labels: cli, bug) | +| | | **task-31** - Update README for open source (Assignees: none, Labels: docs) | +| | | **task-29** - Add GitHub templates (Assignees: none, Labels: github, docs) | +| | | **task-27** - Add CONTRIBUTING guidelines (Assignees: none, Labels: docs, github) | +| | | **task-25** - CLI: Export Kanban board to README (Assignees: none, Labels: none) | +| | | **task-24** - Handle subtasks in the Kanban view (Assignees: none, Labels: none) | +| | | **task-23** - CLI: Kanban board order tasks by ID ASC (Assignees: none, Labels: none) | +| | | **task-22** - CLI: Prevent double dash in task filenames (Assignees: none, Labels: none) | +| | | **task-21** - Kanban board vertical layout (Assignees: none, Labels: none) | +| | | **task-20** - Add agent guideline to mark tasks In Progress on start (Assignees: none, Labels: agents) | +| | | **task-19** - CLI - fix default task status and remove Draft from statuses (Assignees: none, Labels: none) | +| | | **└─ task-13.1** - CLI: Agent Instruction File Selection (Assignees: none, Labels: cli, agents) | +| | | **task-7** - Kanban Board: Implement CLI Text-Based Kanban Board View (Assignees: none, Labels: cli, command) | +| | | **└─ task-7.1** - CLI: Kanban board detect remote task status (Assignees: none, Labels: none) | +| | | **task-6** - CLI: Argument Parsing, Help, and Packaging (Assignees: none, Labels: cli, command) | +| | | **└─ task-6.1** - CLI: Local installation support for bunx/npx (Assignees: none, Labels: cli) | +| | | **└─ task-6.2** - CLI: GitHub Actions for Build & Publish (Assignees: none, Labels: ci) | +| | | **task-5** - CLI: Implement Docs & Decisions CLI Commands (Basic) (Assignees: none, Labels: cli, command) | +| | | **task-4** - CLI: Task Management Commands (Assignees: none, Labels: cli, command) | +| | | **└─ task-4.1** - CLI: Task Creation Commands (Assignees: @MrLesk, Labels: cli, command) | +| | | **└─ task-4.2** - CLI: Task Listing and Viewing (Assignees: @MrLesk, Labels: cli, command) | +| | | **└─ task-4.3** - CLI: Task Editing (Assignees: @MrLesk, Labels: cli, command) | +| | | **└─ task-4.4** - CLI: Task Archiving and State Transitions (Assignees: @MrLesk, Labels: cli, command) | +| | | **└─ task-4.5** - CLI: Init prompts for reporter name and global/local config (Assignees: @MrLesk, Labels: cli, config) | +| | | **└─ task-4.6** - CLI: Add empty assignee array field for new tasks (Assignees: @MrLesk, Labels: cli, command) | +| | | **└─ task-4.7** - CLI: Parse unquoted created_date (Assignees: @MrLesk, Labels: cli, command) | +| | | **└─ task-4.8** - CLI: enforce description header (Assignees: none, Labels: none) | +| | | **└─ task-4.9** - CLI: Normalize task-id inputs (Assignees: none, Labels: cli, bug) | +| | | **└─ task-4.10** - CLI: enforce Agents to use backlog CLI to mark tasks Done (Assignees: none, Labels: cli, agents) | +| | | **└─ task-4.11** - Docs: add definition of done to agent guidelines (Assignees: none, Labels: docs, agents) | +| | | **└─ task-4.12** - CLI: Handle task ID conflicts across branches (Assignees: none, Labels: none) | +| | | **└─ task-4.13** - CLI: Fix config command local/global logic (Assignees: none, Labels: none) | +| | | **task-3** - CLI: Implement `backlog init` Command (Assignees: @MrLesk, Labels: cli, command) | +| | | **task-2** - CLI: Design & Implement Core Logic Library (Assignees: @MrLesk, Labels: cli, core-logic, architecture) | +| | | **task-1** - CLI: Setup Core Project (Bun, TypeScript, Git, Linters) (Assignees: @MrLesk, Labels: cli, setup) | diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..fe415c9 --- /dev/null +++ b/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["src/**/*.ts", "scripts/**/*.cjs", "*.json", "**/*.json", "!**/.claude"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 120 + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noParameterAssign": "error", + "useAsConstAssertion": "error", + "useDefaultParameterLast": "error", + "useEnumInitializers": "error", + "useSelfClosingElements": "error", + "useSingleVarDeclarator": "error", + "noUnusedTemplateLiteral": "error", + "useNumberNamespace": "error", + "noInferrableTypes": "error", + "noUselessElse": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8cb4e53 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1346 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "backlog.md", + "devDependencies": { + "@biomejs/biome": "2.3.8", + "@modelcontextprotocol/sdk": "1.24.2", + "@tailwindcss/cli": "4.1.17", + "@types/bun": "1.3.3", + "@types/jsdom": "27.0.0", + "@types/prompts": "2.4.9", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@types/react-router-dom": "5.3.3", + "@uiw/react-markdown-preview": "5.1.5", + "@uiw/react-md-editor": "4.0.10", + "commander": "14.0.2", + "fuse.js": "7.1.0", + "gray-matter": "4.0.3", + "husky": "9.1.7", + "install": "0.13.0", + "jsdom": "27.2.0", + "lint-staged": "16.2.7", + "mermaid": "11.12.2", + "neo-neo-bblessed": "1.0.9", + "prompts": "2.4.2", + "react": "19.2.1", + "react-dom": "19.2.1", + "react-router-dom": "7.10.0", + "react-tooltip": "5.30.0", + "tailwindcss": "4.1.17", + }, + "optionalDependencies": { + "backlog.md-darwin-arm64": "*", + "backlog.md-darwin-x64": "*", + "backlog.md-linux-arm64": "*", + "backlog.md-linux-x64": "*", + "backlog.md-windows-x64": "*", + }, + }, + }, + "trustedDependencies": [ + "@biomejs/biome", + ], + "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.24", "", {}, "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg=="], + + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@antfu/utils": ["@antfu/utils@9.3.0", "", {}, "sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.2" } }, "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.4", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.2" } }, "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="], + + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + + "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.20", "", {}, "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.0.2", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@antfu/utils": "^9.2.0", "@iconify/types": "^2.0.0", "debug": "^4.4.1", "globals": "^15.15.0", "kolorist": "^1.8.0", "local-pkg": "^1.1.1", "mlly": "^1.7.4" } }, "sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.24.2", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-hS/kzSfchqzvUeJUsdiDHi84/kNhLIZaZ6coGQVwbYIelOBbcAwUohUfaQTLa1MvFOK/jbTnGFzraHSFwB7pjQ=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + + "@tailwindcss/cli": ["@tailwindcss/cli@4.1.17", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "enhanced-resolve": "^5.18.3", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.1.17" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-jUIxcyUNlCC2aNPnyPEWU/L2/ik3pB4fF3auKGXr8AvN3T3OFESVctFKOBoPZQaZJIeUpPn1uCLp0MRxuek8gg=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="], + + "@types/jsdom": ["@types/jsdom@27.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@24.6.0", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA=="], + + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + + "@types/prompts": ["@types/prompts@2.4.9", "", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="], + + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/react-router": ["@types/react-router@5.1.20", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*" } }, "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="], + + "@types/react-router-dom": ["@types/react-router-dom@5.3.3", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router": "*" } }, "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.17", "", {}, "sha512-O2GUHV90Iw2VrSLVLK0OmNIMdZ5fgEg4NhvtwINsX+eZ/Wf6DWD0TdsK9xwV7dNRnK/UI2mQtl0a2/kRgm1m1A=="], + + "@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.1.5", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~9.0.1", "rehype-attr": "~3.0.1", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg=="], + + "@uiw/react-md-editor": ["@uiw/react-md-editor@4.0.10", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.0.6", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-Bh9Ypo1rDuxGzWbC3vG3nmOs0aFDZWmNoMos/JJqc8dLwro54sc1rr/MpXEfHwI6MNqlWIf/KICQzjt94Wgo7A=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@xterm/headless": ["@xterm/headless@5.5.0", "", {}, "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "backlog.md-darwin-arm64": ["backlog.md-darwin-arm64@1.14.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f6AcF+CZpw7BrRJlIhD1PuNWNYUrMRlAaP7v4sr/xwespAqWBc2xT0MS0ba2ory9cEFup16W2K5H789i1mBQ2Q=="], + + "backlog.md-darwin-x64": ["backlog.md-darwin-x64@1.14.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-DDuT04LqZgTWDJD42wl9Bw/gJACfsAuI1ZPZvqMSh9PtjySC25iCKmjflsgrkgRvGI49Cx7dCONTVfvILZZCTg=="], + + "backlog.md-linux-arm64": ["backlog.md-linux-arm64@1.14.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-efMv+NVoC5MkRfHg6rvSEw+MAjMF6w+FUjLtMuOtAR4vdL8sVUNqHgi9zsjQ6glnnz9/JBFtnYjjkRCozZuYuw=="], + + "backlog.md-linux-x64": ["backlog.md-linux-x64@1.14.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h3RT7jMqFZqQ/Sf49oB7ggqGJ2+p53nIqW544Ue+6RLL7VOmXieLU+hQlG5sh1rACbg22Y5gOhpvdnVvNgfiMA=="], + + "backlog.md-windows-x64": ["backlog.md-windows-x64@1.14.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8gpi4GVY7wW6Ci8tXfHuodoXFPY4I2hsmOFMK7ggmhhImThALoaw2MTPoJbbFAXS0x5t9lL0R5toufZcmcJwyA=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-truncate": ["cli-truncate@5.1.0", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-selector-parser": ["css-selector-parser@3.1.3", "", {}, "sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg=="], + + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "cssstyle": ["cssstyle@5.3.3", "", { "dependencies": { "@asamuzakjp/css-color": "^4.0.3", "@csstools/css-syntax-patches-for-csstree": "^1.0.14", "css-tree": "^3.1.0" } }, "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + + "data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="], + + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], + + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="], + + "dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.3.0", "", {}, "sha512-JqasYqGO32J2c91uYKdhu1vNmXGADaLB7OOgjAhjMvpjdvGb0tsYcuwn381MwqCg4YBQDtByQcNlFYuv2kmOug=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="], + + "hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@3.1.1", "", { "dependencies": { "@types/hast": "^2.0.0" } }, "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "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" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], + + "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@7.2.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^3.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], + + "install": ["install@0.13.0", "", {}, "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + + "jsdom": ["jsdom@27.2.0", "", { "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", "cssstyle": "^5.3.3", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "katex": ["katex@0.16.25", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], + + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + + "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], + + "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "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" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "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" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "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" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "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" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "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" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "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" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "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" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "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" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "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" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "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" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "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-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="], + + "micromark": ["micromark@4.0.2", "", { "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-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" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "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.0.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-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.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "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" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "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" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "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" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "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" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "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" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "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" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "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" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "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" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "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" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "neo-neo-bblessed": ["neo-neo-bblessed@1.0.9", "", { "dependencies": { "@xterm/headless": "^5.5.0", "crc-32": "^1.2.2", "eastasianwidth": "^0.3.0", "jimp": "^1.6.0", "pngjs": "^7.0.0", "unicode-properties": "^1.4.1" }, "peerDependencies": { "node-pty": "^1.0.0" }, "optionalPeers": ["node-pty"], "bin": { "neo-neo-bblessed": "bin/tput.ts" } }, "sha512-QiHsh4BZnjV9PLzxW8ZvfBuGgkyUNYPSdk/NgT1a9xq3a6WdCOAWLjBRpbkKAukgDXQROGLfIKj/bpvDhCBjRg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "package-manager-detector": ["package-manager-detector@1.5.0", "", {}, "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw=="], + + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "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" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], + + "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], + + "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + + "react-markdown": ["react-markdown@9.0.3", "", { "dependencies": { "@types/hast": "^3.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" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw=="], + + "react-router": ["react-router@7.10.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw=="], + + "react-router-dom": ["react-router-dom@7.10.0", "", { "dependencies": { "react-router": "7.10.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Q4haR150pN/5N75O30iIsRJcr3ef7p7opFaKpcaREy0GQit6uCRu1NEiIFIwnHJQy0bsziRFBweR/5EkmHgVUQ=="], + + "react-tooltip": ["react-tooltip@5.30.0", "", { "dependencies": { "@floating-ui/dom": "^1.6.1", "classnames": "^2.3.0" }, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0" } }, "sha512-Yn8PfbgQ/wmqnL7oBpz1QiDaLKrzZMdSUUdk7nVeGTwzbxCAJiJzR4VSYW+eIO42F1INt57sPUmpgKv0KwJKtg=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], + + "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], + + "rehype-attr": ["rehype-attr@3.0.3", "", { "dependencies": { "unified": "~11.0.0", "unist-util-visit": "~5.0.0" } }, "sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw=="], + + "rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="], + + "rehype-ignore": ["rehype-ignore@2.0.2", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-BpAT/3lU9DMJ2siYVD/dSR0A/zQgD6Fb+fxkJd4j+wDVy6TYbYpK+FZqu8eM9EuNKGvi4BJR7XTZ/+zF02Dq8w=="], + + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], + + "rehype-prism-plus": ["rehype-prism-plus@2.0.0", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-rewrite": ["rehype-rewrite@4.0.2", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.3", "unist-util-visit": "^5.0.0" } }, "sha512-rjLJ3z6fIV11phwCqHp/KRo8xuUCO8o9bFJCNw5o6O2wlLk6g8r323aRswdGBQwfXPFYeSuZdAjp4tzo6RGqEg=="], + + "rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "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" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-github-blockquote-alert": ["remark-github-blockquote-alert@1.3.1", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "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" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], + + "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], + + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], + + "tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], + + "tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], + + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + + "unified": ["unified@11.0.5", "", { "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" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-filter": ["unist-util-filter@5.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw=="], + + "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@types/react-router/@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], + + "@types/react-router-dom/@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], + + "body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "hast-util-parse-selector/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], + + "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + + "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], + + "hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "lightningcss/detect-libc": ["detect-libc@2.1.1", "", {}, "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw=="], + + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "react-router/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], + + "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "@types/react-router-dom/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "@types/react-router/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-parse-selector/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "refractor/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + } +} diff --git a/bun.nix b/bun.nix new file mode 100644 index 0000000..eaada69 --- /dev/null +++ b/bun.nix @@ -0,0 +1,2572 @@ +# Autogenerated by `bun2nix`, editing manually is not recommended +# +# Set of Bun packages to install +# +# Consume this with `fetchBunDeps` (recommended) +# or `pkgs.callPackage` if you wish to handle +# it manually. +{ + copyPathToStore, + fetchFromGitHub, + fetchgit, + fetchurl, + ... +}: +{ + "@acemir/cssom@0.9.24" = fetchurl { + url = "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz"; + hash = "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg=="; + }; + "@antfu/install-pkg@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz"; + hash = "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="; + }; + "@antfu/utils@9.3.0" = fetchurl { + url = "https://registry.npmjs.org/@antfu/utils/-/utils-9.3.0.tgz"; + hash = "sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA=="; + }; + "@asamuzakjp/css-color@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz"; + hash = "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w=="; + }; + "@asamuzakjp/dom-selector@6.7.4" = fetchurl { + url = "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz"; + hash = "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA=="; + }; + "@asamuzakjp/nwsapi@2.3.9" = fetchurl { + url = "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz"; + hash = "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="; + }; + "@babel/runtime@7.28.4" = fetchurl { + url = "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz"; + hash = "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="; + }; + "@biomejs/biome@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.8.tgz"; + hash = "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="; + }; + "@biomejs/cli-darwin-arm64@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.8.tgz"; + hash = "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="; + }; + "@biomejs/cli-darwin-x64@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.8.tgz"; + hash = "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="; + }; + "@biomejs/cli-linux-arm64-musl@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.8.tgz"; + hash = "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="; + }; + "@biomejs/cli-linux-arm64@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.8.tgz"; + hash = "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="; + }; + "@biomejs/cli-linux-x64-musl@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.8.tgz"; + hash = "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="; + }; + "@biomejs/cli-linux-x64@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.8.tgz"; + hash = "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="; + }; + "@biomejs/cli-win32-arm64@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.8.tgz"; + hash = "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="; + }; + "@biomejs/cli-win32-x64@2.3.8" = fetchurl { + url = "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.8.tgz"; + hash = "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="; + }; + "@braintree/sanitize-url@7.1.1" = fetchurl { + url = "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz"; + hash = "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="; + }; + "@chevrotain/cst-dts-gen@11.0.3" = fetchurl { + url = "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz"; + hash = "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="; + }; + "@chevrotain/gast@11.0.3" = fetchurl { + url = "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz"; + hash = "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="; + }; + "@chevrotain/regexp-to-ast@11.0.3" = fetchurl { + url = "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz"; + hash = "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="; + }; + "@chevrotain/types@11.0.3" = fetchurl { + url = "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz"; + hash = "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="; + }; + "@chevrotain/utils@11.0.3" = fetchurl { + url = "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz"; + hash = "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="; + }; + "@csstools/color-helpers@5.1.0" = fetchurl { + url = "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz"; + hash = "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="; + }; + "@csstools/css-calc@2.1.4" = fetchurl { + url = "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz"; + hash = "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="; + }; + "@csstools/css-color-parser@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz"; + hash = "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="; + }; + "@csstools/css-parser-algorithms@3.0.5" = fetchurl { + url = "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz"; + hash = "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="; + }; + "@csstools/css-syntax-patches-for-csstree@1.0.20" = fetchurl { + url = "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz"; + hash = "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ=="; + }; + "@csstools/css-tokenizer@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz"; + hash = "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="; + }; + "@emnapi/core@1.7.1" = fetchurl { + url = "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz"; + hash = "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="; + }; + "@emnapi/runtime@1.7.1" = fetchurl { + url = "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz"; + hash = "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="; + }; + "@emnapi/wasi-threads@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz"; + hash = "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="; + }; + "@floating-ui/core@1.7.3" = fetchurl { + url = "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz"; + hash = "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="; + }; + "@floating-ui/dom@1.7.4" = fetchurl { + url = "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz"; + hash = "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="; + }; + "@floating-ui/utils@0.2.10" = fetchurl { + url = "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz"; + hash = "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="; + }; + "@iconify/types@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz"; + hash = "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="; + }; + "@iconify/utils@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/@iconify/utils/-/utils-3.0.2.tgz"; + hash = "sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ=="; + }; + "@jimp/core@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz"; + hash = "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="; + }; + "@jimp/diff@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz"; + hash = "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="; + }; + "@jimp/file-ops@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz"; + hash = "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="; + }; + "@jimp/js-bmp@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz"; + hash = "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="; + }; + "@jimp/js-gif@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz"; + hash = "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="; + }; + "@jimp/js-jpeg@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz"; + hash = "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="; + }; + "@jimp/js-png@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz"; + hash = "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="; + }; + "@jimp/js-tiff@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz"; + hash = "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="; + }; + "@jimp/plugin-blit@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz"; + hash = "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="; + }; + "@jimp/plugin-blur@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz"; + hash = "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="; + }; + "@jimp/plugin-circle@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz"; + hash = "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="; + }; + "@jimp/plugin-color@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz"; + hash = "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="; + }; + "@jimp/plugin-contain@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz"; + hash = "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="; + }; + "@jimp/plugin-cover@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz"; + hash = "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="; + }; + "@jimp/plugin-crop@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz"; + hash = "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="; + }; + "@jimp/plugin-displace@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz"; + hash = "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="; + }; + "@jimp/plugin-dither@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz"; + hash = "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="; + }; + "@jimp/plugin-fisheye@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz"; + hash = "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="; + }; + "@jimp/plugin-flip@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz"; + hash = "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="; + }; + "@jimp/plugin-hash@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz"; + hash = "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="; + }; + "@jimp/plugin-mask@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz"; + hash = "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="; + }; + "@jimp/plugin-print@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz"; + hash = "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="; + }; + "@jimp/plugin-quantize@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz"; + hash = "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="; + }; + "@jimp/plugin-resize@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz"; + hash = "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="; + }; + "@jimp/plugin-rotate@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz"; + hash = "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="; + }; + "@jimp/plugin-threshold@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz"; + hash = "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="; + }; + "@jimp/types@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz"; + hash = "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="; + }; + "@jimp/utils@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz"; + hash = "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="; + }; + "@jridgewell/gen-mapping@0.3.13" = fetchurl { + url = "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"; + hash = "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="; + }; + "@jridgewell/remapping@2.3.5" = fetchurl { + url = "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz"; + hash = "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="; + }; + "@jridgewell/resolve-uri@3.1.2" = fetchurl { + url = "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"; + hash = "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="; + }; + "@jridgewell/sourcemap-codec@1.5.5" = fetchurl { + url = "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"; + hash = "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="; + }; + "@jridgewell/trace-mapping@0.3.31" = fetchurl { + url = "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz"; + hash = "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="; + }; + "@mermaid-js/parser@0.6.3" = fetchurl { + url = "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz"; + hash = "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="; + }; + "@modelcontextprotocol/sdk@1.24.2" = fetchurl { + url = "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.2.tgz"; + hash = "sha512-hS/kzSfchqzvUeJUsdiDHi84/kNhLIZaZ6coGQVwbYIelOBbcAwUohUfaQTLa1MvFOK/jbTnGFzraHSFwB7pjQ=="; + }; + "@napi-rs/wasm-runtime@1.0.7" = fetchurl { + url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz"; + hash = "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="; + }; + "@parcel/watcher-android-arm64@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz"; + hash = "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="; + }; + "@parcel/watcher-darwin-arm64@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz"; + hash = "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="; + }; + "@parcel/watcher-darwin-x64@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz"; + hash = "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="; + }; + "@parcel/watcher-freebsd-x64@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz"; + hash = "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="; + }; + "@parcel/watcher-linux-arm-glibc@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz"; + hash = "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="; + }; + "@parcel/watcher-linux-arm-musl@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz"; + hash = "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="; + }; + "@parcel/watcher-linux-arm64-glibc@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz"; + hash = "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="; + }; + "@parcel/watcher-linux-arm64-musl@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz"; + hash = "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="; + }; + "@parcel/watcher-linux-x64-glibc@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz"; + hash = "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="; + }; + "@parcel/watcher-linux-x64-musl@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz"; + hash = "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="; + }; + "@parcel/watcher-win32-arm64@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz"; + hash = "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="; + }; + "@parcel/watcher-win32-ia32@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz"; + hash = "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="; + }; + "@parcel/watcher-win32-x64@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz"; + hash = "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="; + }; + "@parcel/watcher@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz"; + hash = "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="; + }; + "@tailwindcss/cli@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.17.tgz"; + hash = "sha512-jUIxcyUNlCC2aNPnyPEWU/L2/ik3pB4fF3auKGXr8AvN3T3OFESVctFKOBoPZQaZJIeUpPn1uCLp0MRxuek8gg=="; + }; + "@tailwindcss/node@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz"; + hash = "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="; + }; + "@tailwindcss/oxide-android-arm64@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz"; + hash = "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="; + }; + "@tailwindcss/oxide-darwin-arm64@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz"; + hash = "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="; + }; + "@tailwindcss/oxide-darwin-x64@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz"; + hash = "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="; + }; + "@tailwindcss/oxide-freebsd-x64@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz"; + hash = "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="; + }; + "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz"; + hash = "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="; + }; + "@tailwindcss/oxide-linux-arm64-gnu@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz"; + hash = "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="; + }; + "@tailwindcss/oxide-linux-arm64-musl@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz"; + hash = "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="; + }; + "@tailwindcss/oxide-linux-x64-gnu@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz"; + hash = "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="; + }; + "@tailwindcss/oxide-linux-x64-musl@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz"; + hash = "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="; + }; + "@tailwindcss/oxide-wasm32-wasi@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz"; + hash = "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="; + }; + "@tailwindcss/oxide-win32-arm64-msvc@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz"; + hash = "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="; + }; + "@tailwindcss/oxide-win32-x64-msvc@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz"; + hash = "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="; + }; + "@tailwindcss/oxide@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz"; + hash = "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="; + }; + "@tokenizer/token@0.3.0" = fetchurl { + url = "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz"; + hash = "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="; + }; + "@tybys/wasm-util@0.10.1" = fetchurl { + url = "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz"; + hash = "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="; + }; + "@types/bun@1.3.3" = fetchurl { + url = "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz"; + hash = "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="; + }; + "@types/d3-array@3.2.2" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz"; + hash = "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="; + }; + "@types/d3-axis@3.0.6" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz"; + hash = "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="; + }; + "@types/d3-brush@3.0.6" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz"; + hash = "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="; + }; + "@types/d3-chord@3.0.6" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz"; + hash = "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="; + }; + "@types/d3-color@3.1.3" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz"; + hash = "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="; + }; + "@types/d3-contour@3.0.6" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz"; + hash = "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="; + }; + "@types/d3-delaunay@6.0.4" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz"; + hash = "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="; + }; + "@types/d3-dispatch@3.0.7" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz"; + hash = "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="; + }; + "@types/d3-drag@3.0.7" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz"; + hash = "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="; + }; + "@types/d3-dsv@3.0.7" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz"; + hash = "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="; + }; + "@types/d3-ease@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz"; + hash = "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="; + }; + "@types/d3-fetch@3.0.7" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz"; + hash = "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="; + }; + "@types/d3-force@3.0.10" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz"; + hash = "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="; + }; + "@types/d3-format@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz"; + hash = "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="; + }; + "@types/d3-geo@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz"; + hash = "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="; + }; + "@types/d3-hierarchy@3.1.7" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz"; + hash = "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="; + }; + "@types/d3-interpolate@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz"; + hash = "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="; + }; + "@types/d3-path@3.1.1" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz"; + hash = "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="; + }; + "@types/d3-polygon@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz"; + hash = "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="; + }; + "@types/d3-quadtree@3.0.6" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz"; + hash = "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="; + }; + "@types/d3-random@3.0.3" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz"; + hash = "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="; + }; + "@types/d3-scale-chromatic@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz"; + hash = "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="; + }; + "@types/d3-scale@4.0.9" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz"; + hash = "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="; + }; + "@types/d3-selection@3.0.11" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz"; + hash = "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="; + }; + "@types/d3-shape@3.1.7" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz"; + hash = "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="; + }; + "@types/d3-time-format@4.0.3" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz"; + hash = "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="; + }; + "@types/d3-time@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz"; + hash = "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="; + }; + "@types/d3-timer@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz"; + hash = "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="; + }; + "@types/d3-transition@3.0.9" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz"; + hash = "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="; + }; + "@types/d3-zoom@3.0.8" = fetchurl { + url = "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz"; + hash = "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="; + }; + "@types/d3@7.4.3" = fetchurl { + url = "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz"; + hash = "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="; + }; + "@types/debug@4.1.12" = fetchurl { + url = "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz"; + hash = "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="; + }; + "@types/estree-jsx@1.0.5" = fetchurl { + url = "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"; + hash = "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="; + }; + "@types/estree@1.0.8" = fetchurl { + url = "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"; + hash = "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="; + }; + "@types/geojson@7946.0.16" = fetchurl { + url = "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz"; + hash = "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="; + }; + "@types/hast@2.3.10" = fetchurl { + url = "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz"; + hash = "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="; + }; + "@types/hast@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz"; + hash = "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="; + }; + "@types/history@4.7.11" = fetchurl { + url = "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz"; + hash = "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="; + }; + "@types/jsdom@27.0.0" = fetchurl { + url = "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz"; + hash = "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw=="; + }; + "@types/mdast@4.0.4" = fetchurl { + url = "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz"; + hash = "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="; + }; + "@types/ms@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz"; + hash = "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="; + }; + "@types/node@16.9.1" = fetchurl { + url = "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz"; + hash = "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="; + }; + "@types/node@24.6.0" = fetchurl { + url = "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz"; + hash = "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA=="; + }; + "@types/prismjs@1.26.5" = fetchurl { + url = "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz"; + hash = "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="; + }; + "@types/prompts@2.4.9" = fetchurl { + url = "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz"; + hash = "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="; + }; + "@types/react-dom@19.2.3" = fetchurl { + url = "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz"; + hash = "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="; + }; + "@types/react-router-dom@5.3.3" = fetchurl { + url = "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz"; + hash = "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="; + }; + "@types/react-router@5.1.20" = fetchurl { + url = "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz"; + hash = "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="; + }; + "@types/react@19.1.13" = fetchurl { + url = "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz"; + hash = "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="; + }; + "@types/react@19.2.7" = fetchurl { + url = "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz"; + hash = "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="; + }; + "@types/tough-cookie@4.0.5" = fetchurl { + url = "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz"; + hash = "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="; + }; + "@types/trusted-types@2.0.7" = fetchurl { + url = "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz"; + hash = "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="; + }; + "@types/unist@2.0.11" = fetchurl { + url = "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz"; + hash = "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="; + }; + "@types/unist@3.0.3" = fetchurl { + url = "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz"; + hash = "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="; + }; + "@uiw/copy-to-clipboard@1.0.17" = fetchurl { + url = "https://registry.npmjs.org/@uiw/copy-to-clipboard/-/copy-to-clipboard-1.0.17.tgz"; + hash = "sha512-O2GUHV90Iw2VrSLVLK0OmNIMdZ5fgEg4NhvtwINsX+eZ/Wf6DWD0TdsK9xwV7dNRnK/UI2mQtl0a2/kRgm1m1A=="; + }; + "@uiw/react-markdown-preview@5.1.5" = fetchurl { + url = "https://registry.npmjs.org/@uiw/react-markdown-preview/-/react-markdown-preview-5.1.5.tgz"; + hash = "sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg=="; + }; + "@uiw/react-md-editor@4.0.10" = fetchurl { + url = "https://registry.npmjs.org/@uiw/react-md-editor/-/react-md-editor-4.0.10.tgz"; + hash = "sha512-Bh9Ypo1rDuxGzWbC3vG3nmOs0aFDZWmNoMos/JJqc8dLwro54sc1rr/MpXEfHwI6MNqlWIf/KICQzjt94Wgo7A=="; + }; + "@ungap/structured-clone@1.3.0" = fetchurl { + url = "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz"; + hash = "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="; + }; + "@xterm/headless@5.5.0" = fetchurl { + url = "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz"; + hash = "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g=="; + }; + "abort-controller@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz"; + hash = "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="; + }; + "accepts@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz"; + hash = "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="; + }; + "acorn@8.15.0" = fetchurl { + url = "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"; + hash = "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="; + }; + "agent-base@7.1.4" = fetchurl { + url = "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz"; + hash = "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="; + }; + "ajv-formats@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz"; + hash = "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="; + }; + "ajv@8.17.1" = fetchurl { + url = "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz"; + hash = "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="; + }; + "ansi-escapes@7.1.1" = fetchurl { + url = "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz"; + hash = "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="; + }; + "ansi-regex@6.2.2" = fetchurl { + url = "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz"; + hash = "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="; + }; + "ansi-styles@6.2.3" = fetchurl { + url = "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz"; + hash = "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="; + }; + "any-base@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz"; + hash = "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="; + }; + "argparse@1.0.10" = fetchurl { + url = "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz"; + hash = "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="; + }; + "await-to-js@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz"; + hash = "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="; + }; + "backlog.md-darwin-arm64@1.14.4" = fetchurl { + url = "https://registry.npmjs.org/backlog.md-darwin-arm64/-/backlog.md-darwin-arm64-1.14.4.tgz"; + hash = "sha512-f6AcF+CZpw7BrRJlIhD1PuNWNYUrMRlAaP7v4sr/xwespAqWBc2xT0MS0ba2ory9cEFup16W2K5H789i1mBQ2Q=="; + }; + "backlog.md-darwin-x64@1.14.4" = fetchurl { + url = "https://registry.npmjs.org/backlog.md-darwin-x64/-/backlog.md-darwin-x64-1.14.4.tgz"; + hash = "sha512-DDuT04LqZgTWDJD42wl9Bw/gJACfsAuI1ZPZvqMSh9PtjySC25iCKmjflsgrkgRvGI49Cx7dCONTVfvILZZCTg=="; + }; + "backlog.md-linux-arm64@1.14.4" = fetchurl { + url = "https://registry.npmjs.org/backlog.md-linux-arm64/-/backlog.md-linux-arm64-1.14.4.tgz"; + hash = "sha512-efMv+NVoC5MkRfHg6rvSEw+MAjMF6w+FUjLtMuOtAR4vdL8sVUNqHgi9zsjQ6glnnz9/JBFtnYjjkRCozZuYuw=="; + }; + "backlog.md-linux-x64@1.14.4" = fetchurl { + url = "https://registry.npmjs.org/backlog.md-linux-x64/-/backlog.md-linux-x64-1.14.4.tgz"; + hash = "sha512-h3RT7jMqFZqQ/Sf49oB7ggqGJ2+p53nIqW544Ue+6RLL7VOmXieLU+hQlG5sh1rACbg22Y5gOhpvdnVvNgfiMA=="; + }; + "backlog.md-windows-x64@1.14.4" = fetchurl { + url = "https://registry.npmjs.org/backlog.md-windows-x64/-/backlog.md-windows-x64-1.14.4.tgz"; + hash = "sha512-8gpi4GVY7wW6Ci8tXfHuodoXFPY4I2hsmOFMK7ggmhhImThALoaw2MTPoJbbFAXS0x5t9lL0R5toufZcmcJwyA=="; + }; + "bail@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz"; + hash = "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="; + }; + "base64-js@1.5.1" = fetchurl { + url = "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"; + hash = "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="; + }; + "bcp-47-match@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz"; + hash = "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="; + }; + "bidi-js@1.0.3" = fetchurl { + url = "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz"; + hash = "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="; + }; + "bmp-ts@1.0.9" = fetchurl { + url = "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz"; + hash = "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="; + }; + "body-parser@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz"; + hash = "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="; + }; + "boolbase@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz"; + hash = "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="; + }; + "braces@3.0.3" = fetchurl { + url = "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"; + hash = "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="; + }; + "buffer@6.0.3" = fetchurl { + url = "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz"; + hash = "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="; + }; + "bun-types@1.3.3" = fetchurl { + url = "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz"; + hash = "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="; + }; + "bytes@3.1.2" = fetchurl { + url = "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"; + hash = "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="; + }; + "call-bind-apply-helpers@1.0.2" = fetchurl { + url = "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz"; + hash = "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="; + }; + "call-bound@1.0.4" = fetchurl { + url = "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz"; + hash = "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="; + }; + "ccount@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"; + hash = "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="; + }; + "character-entities-html4@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz"; + hash = "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="; + }; + "character-entities-legacy@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz"; + hash = "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="; + }; + "character-entities@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz"; + hash = "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="; + }; + "character-reference-invalid@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz"; + hash = "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="; + }; + "chevrotain-allstar@0.3.1" = fetchurl { + url = "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz"; + hash = "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="; + }; + "chevrotain@11.0.3" = fetchurl { + url = "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz"; + hash = "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="; + }; + "classnames@2.5.1" = fetchurl { + url = "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz"; + hash = "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="; + }; + "cli-cursor@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz"; + hash = "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="; + }; + "cli-truncate@5.1.0" = fetchurl { + url = "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz"; + hash = "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g=="; + }; + "colorette@2.0.20" = fetchurl { + url = "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz"; + hash = "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="; + }; + "comma-separated-tokens@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"; + hash = "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="; + }; + "commander@14.0.2" = fetchurl { + url = "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz"; + hash = "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="; + }; + "commander@7.2.0" = fetchurl { + url = "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz"; + hash = "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="; + }; + "commander@8.3.0" = fetchurl { + url = "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz"; + hash = "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="; + }; + "confbox@0.1.8" = fetchurl { + url = "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz"; + hash = "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="; + }; + "confbox@0.2.2" = fetchurl { + url = "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz"; + hash = "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="; + }; + "content-disposition@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz"; + hash = "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="; + }; + "content-type@1.0.5" = fetchurl { + url = "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz"; + hash = "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="; + }; + "cookie-signature@1.2.2" = fetchurl { + url = "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz"; + hash = "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="; + }; + "cookie@0.7.2" = fetchurl { + url = "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz"; + hash = "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="; + }; + "cookie@1.0.2" = fetchurl { + url = "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz"; + hash = "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="; + }; + "cors@2.8.5" = fetchurl { + url = "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz"; + hash = "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="; + }; + "cose-base@1.0.3" = fetchurl { + url = "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz"; + hash = "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="; + }; + "cose-base@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz"; + hash = "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="; + }; + "crc-32@1.2.2" = fetchurl { + url = "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz"; + hash = "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="; + }; + "cross-spawn@7.0.6" = fetchurl { + url = "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"; + hash = "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="; + }; + "css-selector-parser@3.1.3" = fetchurl { + url = "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.1.3.tgz"; + hash = "sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg=="; + }; + "css-tree@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz"; + hash = "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="; + }; + "cssstyle@5.3.3" = fetchurl { + url = "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz"; + hash = "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw=="; + }; + "csstype@3.1.3" = fetchurl { + url = "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"; + hash = "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="; + }; + "csstype@3.2.3" = fetchurl { + url = "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz"; + hash = "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="; + }; + "cytoscape-cose-bilkent@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz"; + hash = "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="; + }; + "cytoscape-fcose@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz"; + hash = "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="; + }; + "cytoscape@3.33.1" = fetchurl { + url = "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz"; + hash = "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="; + }; + "d3-array@2.12.1" = fetchurl { + url = "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz"; + hash = "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="; + }; + "d3-array@3.2.4" = fetchurl { + url = "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz"; + hash = "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="; + }; + "d3-axis@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz"; + hash = "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="; + }; + "d3-brush@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz"; + hash = "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="; + }; + "d3-chord@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz"; + hash = "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="; + }; + "d3-color@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz"; + hash = "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="; + }; + "d3-contour@4.0.2" = fetchurl { + url = "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz"; + hash = "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="; + }; + "d3-delaunay@6.0.4" = fetchurl { + url = "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz"; + hash = "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="; + }; + "d3-dispatch@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz"; + hash = "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="; + }; + "d3-drag@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz"; + hash = "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="; + }; + "d3-dsv@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz"; + hash = "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="; + }; + "d3-ease@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz"; + hash = "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="; + }; + "d3-fetch@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz"; + hash = "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="; + }; + "d3-force@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz"; + hash = "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="; + }; + "d3-format@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz"; + hash = "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="; + }; + "d3-geo@3.1.1" = fetchurl { + url = "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz"; + hash = "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="; + }; + "d3-hierarchy@3.1.2" = fetchurl { + url = "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz"; + hash = "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="; + }; + "d3-interpolate@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz"; + hash = "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="; + }; + "d3-path@1.0.9" = fetchurl { + url = "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz"; + hash = "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="; + }; + "d3-path@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz"; + hash = "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="; + }; + "d3-polygon@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz"; + hash = "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="; + }; + "d3-quadtree@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz"; + hash = "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="; + }; + "d3-random@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz"; + hash = "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="; + }; + "d3-sankey@0.12.3" = fetchurl { + url = "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz"; + hash = "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="; + }; + "d3-scale-chromatic@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz"; + hash = "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="; + }; + "d3-scale@4.0.2" = fetchurl { + url = "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz"; + hash = "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="; + }; + "d3-selection@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz"; + hash = "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="; + }; + "d3-shape@1.3.7" = fetchurl { + url = "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz"; + hash = "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="; + }; + "d3-shape@3.2.0" = fetchurl { + url = "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz"; + hash = "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="; + }; + "d3-time-format@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz"; + hash = "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="; + }; + "d3-time@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz"; + hash = "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="; + }; + "d3-timer@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz"; + hash = "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="; + }; + "d3-transition@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz"; + hash = "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="; + }; + "d3-zoom@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz"; + hash = "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="; + }; + "d3@7.9.0" = fetchurl { + url = "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz"; + hash = "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="; + }; + "dagre-d3-es@7.0.13" = fetchurl { + url = "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz"; + hash = "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="; + }; + "data-urls@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz"; + hash = "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="; + }; + "dayjs@1.11.19" = fetchurl { + url = "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz"; + hash = "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="; + }; + "debug@4.4.3" = fetchurl { + url = "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"; + hash = "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="; + }; + "decimal.js@10.6.0" = fetchurl { + url = "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz"; + hash = "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="; + }; + "decode-named-character-reference@1.2.0" = fetchurl { + url = "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz"; + hash = "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="; + }; + "delaunator@5.0.1" = fetchurl { + url = "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz"; + hash = "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="; + }; + "depd@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"; + hash = "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="; + }; + "dequal@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz"; + hash = "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="; + }; + "detect-libc@1.0.3" = fetchurl { + url = "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz"; + hash = "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="; + }; + "detect-libc@2.1.1" = fetchurl { + url = "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz"; + hash = "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw=="; + }; + "devlop@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz"; + hash = "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="; + }; + "direction@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz"; + hash = "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="; + }; + "dompurify@3.3.0" = fetchurl { + url = "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz"; + hash = "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="; + }; + "dunder-proto@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"; + hash = "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="; + }; + "eastasianwidth@0.3.0" = fetchurl { + url = "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.3.0.tgz"; + hash = "sha512-JqasYqGO32J2c91uYKdhu1vNmXGADaLB7OOgjAhjMvpjdvGb0tsYcuwn381MwqCg4YBQDtByQcNlFYuv2kmOug=="; + }; + "ee-first@1.1.1" = fetchurl { + url = "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"; + hash = "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="; + }; + "emoji-regex@10.5.0" = fetchurl { + url = "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz"; + hash = "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="; + }; + "encodeurl@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"; + hash = "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="; + }; + "enhanced-resolve@5.18.3" = fetchurl { + url = "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz"; + hash = "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="; + }; + "entities@6.0.1" = fetchurl { + url = "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz"; + hash = "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="; + }; + "environment@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz"; + hash = "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="; + }; + "es-define-property@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz"; + hash = "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="; + }; + "es-errors@1.3.0" = fetchurl { + url = "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz"; + hash = "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="; + }; + "es-object-atoms@1.1.1" = fetchurl { + url = "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz"; + hash = "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="; + }; + "escape-html@1.0.3" = fetchurl { + url = "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"; + hash = "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="; + }; + "escape-string-regexp@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"; + hash = "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="; + }; + "esprima@4.0.1" = fetchurl { + url = "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"; + hash = "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="; + }; + "estree-util-is-identifier-name@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz"; + hash = "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="; + }; + "etag@1.8.1" = fetchurl { + url = "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"; + hash = "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="; + }; + "event-target-shim@5.0.1" = fetchurl { + url = "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz"; + hash = "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="; + }; + "eventemitter3@5.0.1" = fetchurl { + url = "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz"; + hash = "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="; + }; + "events@3.3.0" = fetchurl { + url = "https://registry.npmjs.org/events/-/events-3.3.0.tgz"; + hash = "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="; + }; + "eventsource-parser@3.0.6" = fetchurl { + url = "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz"; + hash = "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="; + }; + "eventsource@3.0.7" = fetchurl { + url = "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz"; + hash = "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="; + }; + "exif-parser@0.1.12" = fetchurl { + url = "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz"; + hash = "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="; + }; + "express-rate-limit@7.5.1" = fetchurl { + url = "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz"; + hash = "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="; + }; + "express@5.1.0" = fetchurl { + url = "https://registry.npmjs.org/express/-/express-5.1.0.tgz"; + hash = "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="; + }; + "exsolve@1.0.8" = fetchurl { + url = "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz"; + hash = "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="; + }; + "extend-shallow@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz"; + hash = "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="; + }; + "extend@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz"; + hash = "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="; + }; + "fast-deep-equal@3.1.3" = fetchurl { + url = "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"; + hash = "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="; + }; + "fast-uri@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz"; + hash = "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="; + }; + "file-type@16.5.4" = fetchurl { + url = "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz"; + hash = "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="; + }; + "fill-range@7.1.1" = fetchurl { + url = "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"; + hash = "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="; + }; + "finalhandler@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz"; + hash = "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="; + }; + "forwarded@0.2.0" = fetchurl { + url = "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"; + hash = "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="; + }; + "fresh@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz"; + hash = "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="; + }; + "function-bind@1.1.2" = fetchurl { + url = "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"; + hash = "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="; + }; + "fuse.js@7.1.0" = fetchurl { + url = "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz"; + hash = "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="; + }; + "get-east-asian-width@1.4.0" = fetchurl { + url = "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz"; + hash = "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="; + }; + "get-intrinsic@1.3.0" = fetchurl { + url = "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"; + hash = "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="; + }; + "get-proto@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz"; + hash = "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="; + }; + "gifwrap@0.10.1" = fetchurl { + url = "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz"; + hash = "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="; + }; + "github-slugger@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz"; + hash = "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="; + }; + "globals@15.15.0" = fetchurl { + url = "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz"; + hash = "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="; + }; + "gopd@1.2.0" = fetchurl { + url = "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"; + hash = "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="; + }; + "graceful-fs@4.2.11" = fetchurl { + url = "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"; + hash = "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="; + }; + "gray-matter@4.0.3" = fetchurl { + url = "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz"; + hash = "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="; + }; + "hachure-fill@0.5.2" = fetchurl { + url = "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz"; + hash = "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="; + }; + "has-symbols@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz"; + hash = "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="; + }; + "hasown@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz"; + hash = "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="; + }; + "hast-util-from-html@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz"; + hash = "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="; + }; + "hast-util-from-parse5@8.0.3" = fetchurl { + url = "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz"; + hash = "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="; + }; + "hast-util-has-property@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz"; + hash = "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="; + }; + "hast-util-heading-rank@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz"; + hash = "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="; + }; + "hast-util-is-element@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz"; + hash = "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="; + }; + "hast-util-parse-selector@3.1.1" = fetchurl { + url = "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz"; + hash = "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA=="; + }; + "hast-util-parse-selector@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz"; + hash = "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="; + }; + "hast-util-raw@9.1.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz"; + hash = "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="; + }; + "hast-util-select@6.0.4" = fetchurl { + url = "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz"; + hash = "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="; + }; + "hast-util-to-html@9.0.5" = fetchurl { + url = "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz"; + hash = "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="; + }; + "hast-util-to-jsx-runtime@2.3.6" = fetchurl { + url = "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz"; + hash = "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="; + }; + "hast-util-to-parse5@8.0.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz"; + hash = "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="; + }; + "hast-util-to-string@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz"; + hash = "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="; + }; + "hast-util-whitespace@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz"; + hash = "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="; + }; + "hastscript@7.2.0" = fetchurl { + url = "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz"; + hash = "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw=="; + }; + "hastscript@9.0.1" = fetchurl { + url = "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz"; + hash = "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="; + }; + "html-encoding-sniffer@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz"; + hash = "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="; + }; + "html-url-attributes@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz"; + hash = "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="; + }; + "html-void-elements@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz"; + hash = "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="; + }; + "http-errors@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz"; + hash = "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="; + }; + "http-proxy-agent@7.0.2" = fetchurl { + url = "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz"; + hash = "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="; + }; + "https-proxy-agent@7.0.6" = fetchurl { + url = "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz"; + hash = "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="; + }; + "husky@9.1.7" = fetchurl { + url = "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz"; + hash = "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="; + }; + "iconv-lite@0.6.3" = fetchurl { + url = "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz"; + hash = "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="; + }; + "iconv-lite@0.7.0" = fetchurl { + url = "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz"; + hash = "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="; + }; + "ieee754@1.2.1" = fetchurl { + url = "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"; + hash = "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="; + }; + "image-q@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz"; + hash = "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="; + }; + "inherits@2.0.4" = fetchurl { + url = "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"; + hash = "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="; + }; + "inline-style-parser@0.2.4" = fetchurl { + url = "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz"; + hash = "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="; + }; + "install@0.13.0" = fetchurl { + url = "https://registry.npmjs.org/install/-/install-0.13.0.tgz"; + hash = "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="; + }; + "internmap@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz"; + hash = "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="; + }; + "internmap@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz"; + hash = "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="; + }; + "ipaddr.js@1.9.1" = fetchurl { + url = "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"; + hash = "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="; + }; + "is-alphabetical@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz"; + hash = "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="; + }; + "is-alphanumerical@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz"; + hash = "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="; + }; + "is-decimal@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz"; + hash = "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="; + }; + "is-extendable@0.1.1" = fetchurl { + url = "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz"; + hash = "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="; + }; + "is-extglob@2.1.1" = fetchurl { + url = "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"; + hash = "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="; + }; + "is-fullwidth-code-point@5.1.0" = fetchurl { + url = "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz"; + hash = "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="; + }; + "is-glob@4.0.3" = fetchurl { + url = "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"; + hash = "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="; + }; + "is-hexadecimal@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz"; + hash = "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="; + }; + "is-number@7.0.0" = fetchurl { + url = "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"; + hash = "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="; + }; + "is-plain-obj@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz"; + hash = "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="; + }; + "is-potential-custom-element-name@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz"; + hash = "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="; + }; + "is-promise@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz"; + hash = "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="; + }; + "isexe@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"; + hash = "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="; + }; + "jimp@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz"; + hash = "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="; + }; + "jiti@2.6.1" = fetchurl { + url = "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"; + hash = "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="; + }; + "jose@6.1.3" = fetchurl { + url = "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz"; + hash = "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="; + }; + "jpeg-js@0.4.4" = fetchurl { + url = "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz"; + hash = "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="; + }; + "js-yaml@3.14.1" = fetchurl { + url = "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz"; + hash = "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="; + }; + "jsdom@27.2.0" = fetchurl { + url = "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz"; + hash = "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA=="; + }; + "json-schema-traverse@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz"; + hash = "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="; + }; + "katex@0.16.25" = fetchurl { + url = "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz"; + hash = "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="; + }; + "khroma@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz"; + hash = "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="; + }; + "kind-of@6.0.3" = fetchurl { + url = "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz"; + hash = "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="; + }; + "kleur@3.0.3" = fetchurl { + url = "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz"; + hash = "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="; + }; + "kolorist@1.8.0" = fetchurl { + url = "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz"; + hash = "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="; + }; + "langium@3.3.1" = fetchurl { + url = "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz"; + hash = "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="; + }; + "layout-base@1.0.2" = fetchurl { + url = "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz"; + hash = "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="; + }; + "layout-base@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz"; + hash = "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="; + }; + "lightningcss-android-arm64@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz"; + hash = "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="; + }; + "lightningcss-darwin-arm64@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz"; + hash = "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="; + }; + "lightningcss-darwin-x64@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz"; + hash = "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="; + }; + "lightningcss-freebsd-x64@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz"; + hash = "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="; + }; + "lightningcss-linux-arm-gnueabihf@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz"; + hash = "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="; + }; + "lightningcss-linux-arm64-gnu@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz"; + hash = "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="; + }; + "lightningcss-linux-arm64-musl@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz"; + hash = "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="; + }; + "lightningcss-linux-x64-gnu@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz"; + hash = "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="; + }; + "lightningcss-linux-x64-musl@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz"; + hash = "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="; + }; + "lightningcss-win32-arm64-msvc@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz"; + hash = "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="; + }; + "lightningcss-win32-x64-msvc@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz"; + hash = "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="; + }; + "lightningcss@1.30.2" = fetchurl { + url = "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz"; + hash = "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="; + }; + "lint-staged@16.2.7" = fetchurl { + url = "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz"; + hash = "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="; + }; + "listr2@9.0.5" = fetchurl { + url = "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz"; + hash = "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="; + }; + "local-pkg@1.1.2" = fetchurl { + url = "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz"; + hash = "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="; + }; + "lodash-es@4.17.21" = fetchurl { + url = "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"; + hash = "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="; + }; + "log-update@6.1.0" = fetchurl { + url = "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz"; + hash = "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="; + }; + "longest-streak@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz"; + hash = "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="; + }; + "lru-cache@11.2.2" = fetchurl { + url = "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz"; + hash = "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="; + }; + "magic-string@0.30.21" = fetchurl { + url = "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"; + hash = "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="; + }; + "markdown-table@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz"; + hash = "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="; + }; + "marked@16.4.2" = fetchurl { + url = "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz"; + hash = "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="; + }; + "math-intrinsics@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"; + hash = "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="; + }; + "mdast-util-find-and-replace@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz"; + hash = "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="; + }; + "mdast-util-from-markdown@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz"; + hash = "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="; + }; + "mdast-util-gfm-autolink-literal@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz"; + hash = "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="; + }; + "mdast-util-gfm-footnote@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz"; + hash = "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="; + }; + "mdast-util-gfm-strikethrough@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz"; + hash = "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="; + }; + "mdast-util-gfm-table@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz"; + hash = "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="; + }; + "mdast-util-gfm-task-list-item@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz"; + hash = "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="; + }; + "mdast-util-gfm@3.1.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz"; + hash = "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="; + }; + "mdast-util-mdx-expression@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz"; + hash = "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="; + }; + "mdast-util-mdx-jsx@3.2.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz"; + hash = "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="; + }; + "mdast-util-mdxjs-esm@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz"; + hash = "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="; + }; + "mdast-util-phrasing@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz"; + hash = "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="; + }; + "mdast-util-to-hast@13.2.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz"; + hash = "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="; + }; + "mdast-util-to-markdown@2.1.2" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz"; + hash = "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="; + }; + "mdast-util-to-string@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz"; + hash = "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="; + }; + "mdn-data@2.12.2" = fetchurl { + url = "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz"; + hash = "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="; + }; + "media-typer@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz"; + hash = "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="; + }; + "merge-descriptors@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz"; + hash = "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="; + }; + "mermaid@11.12.2" = fetchurl { + url = "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz"; + hash = "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="; + }; + "micromark-core-commonmark@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"; + hash = "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="; + }; + "micromark-extension-gfm-autolink-literal@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz"; + hash = "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="; + }; + "micromark-extension-gfm-footnote@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz"; + hash = "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="; + }; + "micromark-extension-gfm-strikethrough@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz"; + hash = "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="; + }; + "micromark-extension-gfm-table@2.1.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz"; + hash = "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="; + }; + "micromark-extension-gfm-tagfilter@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz"; + hash = "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="; + }; + "micromark-extension-gfm-task-list-item@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz"; + hash = "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="; + }; + "micromark-extension-gfm@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz"; + hash = "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="; + }; + "micromark-factory-destination@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz"; + hash = "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="; + }; + "micromark-factory-label@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz"; + hash = "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="; + }; + "micromark-factory-space@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz"; + hash = "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="; + }; + "micromark-factory-title@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz"; + hash = "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="; + }; + "micromark-factory-whitespace@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz"; + hash = "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="; + }; + "micromark-util-character@2.1.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz"; + hash = "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="; + }; + "micromark-util-chunked@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz"; + hash = "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="; + }; + "micromark-util-classify-character@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz"; + hash = "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="; + }; + "micromark-util-combine-extensions@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz"; + hash = "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="; + }; + "micromark-util-decode-numeric-character-reference@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz"; + hash = "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="; + }; + "micromark-util-decode-string@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz"; + hash = "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="; + }; + "micromark-util-encode@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz"; + hash = "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="; + }; + "micromark-util-html-tag-name@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz"; + hash = "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="; + }; + "micromark-util-normalize-identifier@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz"; + hash = "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="; + }; + "micromark-util-resolve-all@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz"; + hash = "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="; + }; + "micromark-util-sanitize-uri@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz"; + hash = "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="; + }; + "micromark-util-subtokenize@2.1.0" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz"; + hash = "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="; + }; + "micromark-util-symbol@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz"; + hash = "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="; + }; + "micromark-util-types@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz"; + hash = "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="; + }; + "micromark@4.0.2" = fetchurl { + url = "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz"; + hash = "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="; + }; + "micromatch@4.0.8" = fetchurl { + url = "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz"; + hash = "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="; + }; + "mime-db@1.54.0" = fetchurl { + url = "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz"; + hash = "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="; + }; + "mime-types@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz"; + hash = "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="; + }; + "mime@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz"; + hash = "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="; + }; + "mimic-function@5.0.1" = fetchurl { + url = "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz"; + hash = "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="; + }; + "mlly@1.8.0" = fetchurl { + url = "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz"; + hash = "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="; + }; + "mri@1.2.0" = fetchurl { + url = "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz"; + hash = "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="; + }; + "ms@2.1.3" = fetchurl { + url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"; + hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="; + }; + "nano-spawn@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz"; + hash = "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="; + }; + "negotiator@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz"; + hash = "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="; + }; + "neo-neo-bblessed@1.0.9" = fetchurl { + url = "https://registry.npmjs.org/neo-neo-bblessed/-/neo-neo-bblessed-1.0.9.tgz"; + hash = "sha512-QiHsh4BZnjV9PLzxW8ZvfBuGgkyUNYPSdk/NgT1a9xq3a6WdCOAWLjBRpbkKAukgDXQROGLfIKj/bpvDhCBjRg=="; + }; + "node-addon-api@7.1.1" = fetchurl { + url = "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz"; + hash = "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="; + }; + "nth-check@2.1.1" = fetchurl { + url = "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz"; + hash = "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="; + }; + "object-assign@4.1.1" = fetchurl { + url = "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"; + hash = "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="; + }; + "object-inspect@1.13.4" = fetchurl { + url = "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz"; + hash = "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="; + }; + "omggif@1.0.10" = fetchurl { + url = "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz"; + hash = "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="; + }; + "on-finished@2.4.1" = fetchurl { + url = "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"; + hash = "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="; + }; + "once@1.4.0" = fetchurl { + url = "https://registry.npmjs.org/once/-/once-1.4.0.tgz"; + hash = "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="; + }; + "onetime@7.0.0" = fetchurl { + url = "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz"; + hash = "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="; + }; + "package-manager-detector@1.5.0" = fetchurl { + url = "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.5.0.tgz"; + hash = "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw=="; + }; + "pako@0.2.9" = fetchurl { + url = "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz"; + hash = "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="; + }; + "pako@1.0.11" = fetchurl { + url = "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz"; + hash = "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="; + }; + "parse-bmfont-ascii@1.0.6" = fetchurl { + url = "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz"; + hash = "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="; + }; + "parse-bmfont-binary@1.0.6" = fetchurl { + url = "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz"; + hash = "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="; + }; + "parse-bmfont-xml@1.1.6" = fetchurl { + url = "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz"; + hash = "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="; + }; + "parse-entities@4.0.2" = fetchurl { + url = "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz"; + hash = "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="; + }; + "parse-numeric-range@1.3.0" = fetchurl { + url = "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz"; + hash = "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="; + }; + "parse5@7.3.0" = fetchurl { + url = "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz"; + hash = "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="; + }; + "parse5@8.0.0" = fetchurl { + url = "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz"; + hash = "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="; + }; + "parseurl@1.3.3" = fetchurl { + url = "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz"; + hash = "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="; + }; + "path-data-parser@0.1.0" = fetchurl { + url = "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz"; + hash = "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="; + }; + "path-key@3.1.1" = fetchurl { + url = "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"; + hash = "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="; + }; + "path-to-regexp@8.3.0" = fetchurl { + url = "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz"; + hash = "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="; + }; + "pathe@2.0.3" = fetchurl { + url = "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"; + hash = "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="; + }; + "peek-readable@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz"; + hash = "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="; + }; + "picocolors@1.1.1" = fetchurl { + url = "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"; + hash = "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="; + }; + "picomatch@2.3.1" = fetchurl { + url = "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"; + hash = "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="; + }; + "pidtree@0.6.0" = fetchurl { + url = "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz"; + hash = "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="; + }; + "pixelmatch@5.3.0" = fetchurl { + url = "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz"; + hash = "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="; + }; + "pkce-challenge@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz"; + hash = "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="; + }; + "pkg-types@1.3.1" = fetchurl { + url = "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz"; + hash = "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="; + }; + "pkg-types@2.3.0" = fetchurl { + url = "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz"; + hash = "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="; + }; + "pngjs@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz"; + hash = "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="; + }; + "pngjs@7.0.0" = fetchurl { + url = "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz"; + hash = "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="; + }; + "points-on-curve@0.2.0" = fetchurl { + url = "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz"; + hash = "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="; + }; + "points-on-path@0.2.1" = fetchurl { + url = "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz"; + hash = "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="; + }; + "process@0.11.10" = fetchurl { + url = "https://registry.npmjs.org/process/-/process-0.11.10.tgz"; + hash = "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="; + }; + "prompts@2.4.2" = fetchurl { + url = "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz"; + hash = "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="; + }; + "property-information@6.5.0" = fetchurl { + url = "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz"; + hash = "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="; + }; + "property-information@7.1.0" = fetchurl { + url = "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz"; + hash = "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="; + }; + "proxy-addr@2.0.7" = fetchurl { + url = "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz"; + hash = "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="; + }; + "punycode@2.3.1" = fetchurl { + url = "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"; + hash = "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="; + }; + "qs@6.14.0" = fetchurl { + url = "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz"; + hash = "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="; + }; + "quansync@0.2.11" = fetchurl { + url = "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz"; + hash = "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="; + }; + "range-parser@1.2.1" = fetchurl { + url = "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"; + hash = "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="; + }; + "raw-body@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz"; + hash = "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="; + }; + "react-dom@19.2.1" = fetchurl { + url = "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz"; + hash = "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="; + }; + "react-markdown@9.0.3" = fetchurl { + url = "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz"; + hash = "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw=="; + }; + "react-router-dom@7.10.0" = fetchurl { + url = "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.0.tgz"; + hash = "sha512-Q4haR150pN/5N75O30iIsRJcr3ef7p7opFaKpcaREy0GQit6uCRu1NEiIFIwnHJQy0bsziRFBweR/5EkmHgVUQ=="; + }; + "react-router@7.10.0" = fetchurl { + url = "https://registry.npmjs.org/react-router/-/react-router-7.10.0.tgz"; + hash = "sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw=="; + }; + "react-tooltip@5.30.0" = fetchurl { + url = "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.30.0.tgz"; + hash = "sha512-Yn8PfbgQ/wmqnL7oBpz1QiDaLKrzZMdSUUdk7nVeGTwzbxCAJiJzR4VSYW+eIO42F1INt57sPUmpgKv0KwJKtg=="; + }; + "react@19.2.1" = fetchurl { + url = "https://registry.npmjs.org/react/-/react-19.2.1.tgz"; + hash = "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="; + }; + "readable-stream@4.7.0" = fetchurl { + url = "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz"; + hash = "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="; + }; + "readable-web-to-node-stream@3.0.4" = fetchurl { + url = "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz"; + hash = "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="; + }; + "refractor@4.9.0" = fetchurl { + url = "https://registry.npmjs.org/refractor/-/refractor-4.9.0.tgz"; + hash = "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="; + }; + "rehype-attr@3.0.3" = fetchurl { + url = "https://registry.npmjs.org/rehype-attr/-/rehype-attr-3.0.3.tgz"; + hash = "sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw=="; + }; + "rehype-autolink-headings@7.1.0" = fetchurl { + url = "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz"; + hash = "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="; + }; + "rehype-ignore@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/rehype-ignore/-/rehype-ignore-2.0.2.tgz"; + hash = "sha512-BpAT/3lU9DMJ2siYVD/dSR0A/zQgD6Fb+fxkJd4j+wDVy6TYbYpK+FZqu8eM9EuNKGvi4BJR7XTZ/+zF02Dq8w=="; + }; + "rehype-parse@9.0.1" = fetchurl { + url = "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz"; + hash = "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="; + }; + "rehype-prism-plus@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/rehype-prism-plus/-/rehype-prism-plus-2.0.0.tgz"; + hash = "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ=="; + }; + "rehype-raw@7.0.0" = fetchurl { + url = "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz"; + hash = "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="; + }; + "rehype-rewrite@4.0.2" = fetchurl { + url = "https://registry.npmjs.org/rehype-rewrite/-/rehype-rewrite-4.0.2.tgz"; + hash = "sha512-rjLJ3z6fIV11phwCqHp/KRo8xuUCO8o9bFJCNw5o6O2wlLk6g8r323aRswdGBQwfXPFYeSuZdAjp4tzo6RGqEg=="; + }; + "rehype-slug@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz"; + hash = "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="; + }; + "rehype-stringify@10.0.1" = fetchurl { + url = "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz"; + hash = "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="; + }; + "rehype@13.0.2" = fetchurl { + url = "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz"; + hash = "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="; + }; + "remark-gfm@4.0.1" = fetchurl { + url = "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz"; + hash = "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="; + }; + "remark-github-blockquote-alert@1.3.1" = fetchurl { + url = "https://registry.npmjs.org/remark-github-blockquote-alert/-/remark-github-blockquote-alert-1.3.1.tgz"; + hash = "sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg=="; + }; + "remark-parse@11.0.0" = fetchurl { + url = "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"; + hash = "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="; + }; + "remark-rehype@11.1.2" = fetchurl { + url = "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz"; + hash = "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="; + }; + "remark-stringify@11.0.0" = fetchurl { + url = "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz"; + hash = "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="; + }; + "require-from-string@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz"; + hash = "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="; + }; + "restore-cursor@5.1.0" = fetchurl { + url = "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz"; + hash = "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="; + }; + "rfdc@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz"; + hash = "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="; + }; + "robust-predicates@3.0.2" = fetchurl { + url = "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz"; + hash = "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="; + }; + "roughjs@4.6.6" = fetchurl { + url = "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz"; + hash = "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="; + }; + "router@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/router/-/router-2.2.0.tgz"; + hash = "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="; + }; + "rw@1.3.3" = fetchurl { + url = "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz"; + hash = "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="; + }; + "safe-buffer@5.2.1" = fetchurl { + url = "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"; + hash = "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="; + }; + "safer-buffer@2.1.2" = fetchurl { + url = "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"; + hash = "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="; + }; + "sax@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz"; + hash = "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="; + }; + "saxes@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz"; + hash = "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="; + }; + "scheduler@0.27.0" = fetchurl { + url = "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz"; + hash = "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="; + }; + "section-matter@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz"; + hash = "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="; + }; + "send@1.2.0" = fetchurl { + url = "https://registry.npmjs.org/send/-/send-1.2.0.tgz"; + hash = "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="; + }; + "serve-static@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz"; + hash = "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="; + }; + "set-cookie-parser@2.7.1" = fetchurl { + url = "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz"; + hash = "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="; + }; + "setprototypeof@1.2.0" = fetchurl { + url = "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz"; + hash = "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="; + }; + "shebang-command@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"; + hash = "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="; + }; + "shebang-regex@3.0.0" = fetchurl { + url = "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"; + hash = "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="; + }; + "side-channel-list@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz"; + hash = "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="; + }; + "side-channel-map@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz"; + hash = "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="; + }; + "side-channel-weakmap@1.0.2" = fetchurl { + url = "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz"; + hash = "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="; + }; + "side-channel@1.1.0" = fetchurl { + url = "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz"; + hash = "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="; + }; + "signal-exit@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"; + hash = "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="; + }; + "simple-xml-to-json@1.2.3" = fetchurl { + url = "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz"; + hash = "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="; + }; + "sisteransi@1.0.5" = fetchurl { + url = "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"; + hash = "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="; + }; + "slice-ansi@7.1.2" = fetchurl { + url = "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz"; + hash = "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="; + }; + "source-map-js@1.2.1" = fetchurl { + url = "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"; + hash = "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="; + }; + "space-separated-tokens@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz"; + hash = "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="; + }; + "sprintf-js@1.0.3" = fetchurl { + url = "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"; + hash = "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="; + }; + "statuses@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz"; + hash = "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="; + }; + "string-argv@0.3.2" = fetchurl { + url = "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz"; + hash = "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="; + }; + "string-width@7.2.0" = fetchurl { + url = "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz"; + hash = "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="; + }; + "string-width@8.1.0" = fetchurl { + url = "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz"; + hash = "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="; + }; + "string_decoder@1.3.0" = fetchurl { + url = "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"; + hash = "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="; + }; + "stringify-entities@4.0.4" = fetchurl { + url = "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz"; + hash = "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="; + }; + "strip-ansi@7.1.2" = fetchurl { + url = "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz"; + hash = "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="; + }; + "strip-bom-string@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz"; + hash = "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="; + }; + "strtok3@6.3.0" = fetchurl { + url = "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz"; + hash = "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="; + }; + "style-to-js@1.1.17" = fetchurl { + url = "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz"; + hash = "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="; + }; + "style-to-object@1.0.9" = fetchurl { + url = "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz"; + hash = "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="; + }; + "stylis@4.3.6" = fetchurl { + url = "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz"; + hash = "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="; + }; + "symbol-tree@3.2.4" = fetchurl { + url = "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz"; + hash = "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="; + }; + "tailwindcss@4.1.17" = fetchurl { + url = "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz"; + hash = "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="; + }; + "tapable@2.2.3" = fetchurl { + url = "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz"; + hash = "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="; + }; + "tiny-inflate@1.0.3" = fetchurl { + url = "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz"; + hash = "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="; + }; + "tinycolor2@1.6.0" = fetchurl { + url = "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz"; + hash = "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="; + }; + "tinyexec@1.0.2" = fetchurl { + url = "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz"; + hash = "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="; + }; + "tldts-core@7.0.19" = fetchurl { + url = "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz"; + hash = "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="; + }; + "tldts@7.0.19" = fetchurl { + url = "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz"; + hash = "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="; + }; + "to-regex-range@5.0.1" = fetchurl { + url = "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"; + hash = "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="; + }; + "toidentifier@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"; + hash = "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="; + }; + "token-types@4.2.1" = fetchurl { + url = "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz"; + hash = "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="; + }; + "tough-cookie@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz"; + hash = "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="; + }; + "tr46@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz"; + hash = "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="; + }; + "trim-lines@3.0.1" = fetchurl { + url = "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz"; + hash = "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="; + }; + "trough@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz"; + hash = "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="; + }; + "ts-dedent@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz"; + hash = "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="; + }; + "tslib@2.8.1" = fetchurl { + url = "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"; + hash = "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="; + }; + "type-is@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz"; + hash = "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="; + }; + "ufo@1.6.1" = fetchurl { + url = "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz"; + hash = "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="; + }; + "undici-types@7.13.0" = fetchurl { + url = "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz"; + hash = "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="; + }; + "unicode-properties@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz"; + hash = "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="; + }; + "unicode-trie@2.0.0" = fetchurl { + url = "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz"; + hash = "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="; + }; + "unified@11.0.5" = fetchurl { + url = "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz"; + hash = "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="; + }; + "unist-util-filter@5.0.1" = fetchurl { + url = "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-5.0.1.tgz"; + hash = "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw=="; + }; + "unist-util-is@6.0.0" = fetchurl { + url = "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz"; + hash = "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="; + }; + "unist-util-position@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz"; + hash = "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="; + }; + "unist-util-stringify-position@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz"; + hash = "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="; + }; + "unist-util-visit-parents@6.0.1" = fetchurl { + url = "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz"; + hash = "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="; + }; + "unist-util-visit@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz"; + hash = "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="; + }; + "unpipe@1.0.0" = fetchurl { + url = "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"; + hash = "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="; + }; + "utif2@4.1.0" = fetchurl { + url = "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz"; + hash = "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="; + }; + "uuid@11.1.0" = fetchurl { + url = "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz"; + hash = "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="; + }; + "vary@1.1.2" = fetchurl { + url = "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"; + hash = "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="; + }; + "vfile-location@5.0.3" = fetchurl { + url = "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz"; + hash = "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="; + }; + "vfile-message@4.0.3" = fetchurl { + url = "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz"; + hash = "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="; + }; + "vfile@6.0.3" = fetchurl { + url = "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz"; + hash = "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="; + }; + "vscode-jsonrpc@8.2.0" = fetchurl { + url = "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz"; + hash = "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="; + }; + "vscode-languageserver-protocol@3.17.5" = fetchurl { + url = "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz"; + hash = "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="; + }; + "vscode-languageserver-textdocument@1.0.12" = fetchurl { + url = "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz"; + hash = "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="; + }; + "vscode-languageserver-types@3.17.5" = fetchurl { + url = "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz"; + hash = "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="; + }; + "vscode-languageserver@9.0.1" = fetchurl { + url = "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz"; + hash = "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="; + }; + "vscode-uri@3.0.8" = fetchurl { + url = "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz"; + hash = "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="; + }; + "w3c-xmlserializer@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz"; + hash = "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="; + }; + "web-namespaces@2.0.1" = fetchurl { + url = "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz"; + hash = "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="; + }; + "webidl-conversions@8.0.0" = fetchurl { + url = "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz"; + hash = "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="; + }; + "whatwg-encoding@3.1.1" = fetchurl { + url = "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz"; + hash = "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="; + }; + "whatwg-mimetype@4.0.0" = fetchurl { + url = "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz"; + hash = "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="; + }; + "whatwg-url@15.1.0" = fetchurl { + url = "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz"; + hash = "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="; + }; + "which@2.0.2" = fetchurl { + url = "https://registry.npmjs.org/which/-/which-2.0.2.tgz"; + hash = "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="; + }; + "wrap-ansi@9.0.2" = fetchurl { + url = "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz"; + hash = "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="; + }; + "wrappy@1.0.2" = fetchurl { + url = "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"; + hash = "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="; + }; + "ws@8.18.3" = fetchurl { + url = "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"; + hash = "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="; + }; + "xml-name-validator@5.0.0" = fetchurl { + url = "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz"; + hash = "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="; + }; + "xml-parse-from-string@1.0.1" = fetchurl { + url = "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz"; + hash = "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="; + }; + "xml2js@0.5.0" = fetchurl { + url = "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz"; + hash = "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="; + }; + "xmlbuilder@11.0.1" = fetchurl { + url = "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz"; + hash = "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="; + }; + "xmlchars@2.2.0" = fetchurl { + url = "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz"; + hash = "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="; + }; + "yaml@2.8.1" = fetchurl { + url = "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz"; + hash = "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="; + }; + "zod-to-json-schema@3.25.0" = fetchurl { + url = "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz"; + hash = "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="; + }; + "zod@3.25.76" = fetchurl { + url = "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz"; + hash = "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="; + }; + "zwitch@2.0.4" = fetchurl { + url = "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz"; + hash = "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="; + }; +} \ No newline at end of file diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..14e2cf1 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,9 @@ +[test] +# Timeout for individual tests - increased for Windows compatibility +timeout = "10s" + +# Reduce memory usage during test runs to prevent WSL2 crashes +smol = true + +# Reduce concurrency to help with Windows file system contention +# Note: This is a future-proofing setting as Bun may add concurrency controls \ No newline at end of file diff --git a/completions/.gitkeep b/completions/.gitkeep new file mode 100644 index 0000000..097d5c9 --- /dev/null +++ b/completions/.gitkeep @@ -0,0 +1,14 @@ +# Completion Scripts Reference + +The shell completion scripts in this directory serve as reference documentation. +The actual scripts are embedded in the backlog binary (see src/commands/completion.ts). + +During development, the CLI will read these files first if they exist, +otherwise it falls back to the embedded versions. + +Files: +- backlog.bash - Bash completion reference +- _backlog - Zsh completion reference +- backlog.fish - Fish completion reference +- README.md - Installation and usage guide +- EXAMPLES.md - Detailed examples and how it works diff --git a/completions/EXAMPLES.md b/completions/EXAMPLES.md new file mode 100644 index 0000000..5aa10ca --- /dev/null +++ b/completions/EXAMPLES.md @@ -0,0 +1,218 @@ +# Zsh Completion Examples + +This document demonstrates how the zsh completion script works for the backlog CLI. + +## How It Works + +When you press TAB in zsh, the completion system: + +1. Captures the current command line buffer (`$BUFFER`) +2. Captures the cursor position (`$CURSOR`) +3. Calls the `_backlog` completion function +4. The function runs: `backlog completion __complete "$BUFFER" "$CURSOR"` +5. Parses the newline-separated completions +6. Presents them using `_describe` + +## Example Scenarios + +### Top-Level Commands + +**Input:** +```bash +backlog <TAB> +``` + +**What happens internally:** +- Buffer: `"backlog "` +- Cursor: `8` (position after the space) +- CLI returns: `task\ndoc\nboard\nconfig\ncompletion` +- Zsh shows: `task doc board config completion` + +### Subcommands + +**Input:** +```bash +backlog task <TAB> +``` + +**What happens internally:** +- Buffer: `"backlog task "` +- Cursor: `13` +- CLI returns: `create\nedit\nview\nlist\nsearch\narchive` +- Zsh shows: `create edit view list search archive` + +### Flags + +**Input:** +```bash +backlog task create --<TAB> +``` + +**What happens internally:** +- Buffer: `"backlog task create --"` +- Cursor: `22` +- CLI returns: `--title\n--description\n--priority\n--status\n--assignee\n--labels` +- Zsh shows: `--title --description --priority --status --assignee --labels` + +### Dynamic Task ID Completion + +**Input:** +```bash +backlog task edit <TAB> +``` + +**What happens internally:** +- Buffer: `"backlog task edit "` +- Cursor: `18` +- CLI scans backlog directory for tasks +- CLI returns: `task-1\ntask-2\ntask-308\ntask-308.01\n...` +- Zsh shows: `task-1 task-2 task-308 task-308.01 ...` + +### Flag Value Completion + +**Input:** +```bash +backlog task edit task-308 --status <TAB> +``` + +**What happens internally:** +- Buffer: `"backlog task edit task-308 --status "` +- Cursor: `37` +- CLI recognizes `--status` flag +- CLI returns: `To Do\nIn Progress\nDone` +- Zsh shows: `To Do In Progress Done` + +### Partial Completion + +**Input:** +```bash +backlog task cr<TAB> +``` + +**What happens internally:** +- Buffer: `"backlog task cr"` +- Cursor: `15` +- Partial word: `"cr"` +- CLI filters subcommands starting with "cr" +- CLI returns: `create` +- Zsh completes to: `backlog task create` + +## Testing the Completion + +### Manual Testing + +1. Load the completion: + ```bash + source completions/_backlog + ``` + +2. Try various completions: + ```bash + backlog <TAB> + backlog task <TAB> + backlog task create --<TAB> + ``` + +### Testing Without Zsh + +You can test the backend directly: + +```bash +# Test top-level commands +backlog completion __complete "backlog " 8 + +# Test subcommands +backlog completion __complete "backlog task " 13 + +# Test with partial input +backlog completion __complete "backlog ta" 10 + +# Test flag completion +backlog completion __complete "backlog task create --" 22 +``` + +## Advanced Features + +### Context-Aware Completion + +The completion system understands context: + +```bash +# After --status flag, only show valid statuses +backlog task create --status <TAB> +# Shows: To Do, In Progress, Done + +# After --priority flag, only show valid priorities +backlog task create --priority <TAB> +# Shows: high, medium, low + +# For task ID arguments, show actual task IDs +backlog task edit <TAB> +# Shows: task-1, task-2, task-308, ... +``` + +### Multi-Word Arguments + +Zsh handles multi-word arguments automatically: + +```bash +backlog task create --title "My Task" --status <TAB> +# Correctly identifies we're completing after --status +``` + +### Error Handling + +If the CLI fails or returns no completions: + +```bash +backlog nonexistent <TAB> +# No completions shown, no error message +# The shell stays responsive +``` + +This is handled by: +- `2>/dev/null` - suppresses error output +- `return 1` - tells zsh no completions available +- Graceful fallback to default file/directory completion + +## Performance + +The completion system is designed to be fast: + +- Completions are generated on-demand +- Results are not cached (always current) +- CLI execution is optimized for quick response +- Typical completion time: < 100ms + +For large backlogs with many tasks, you may notice a slight delay when completing task IDs, but the system remains responsive. + +## Debugging + +If completions aren't working: + +1. Check the function is loaded: + ```bash + which _backlog + # Should output the function definition + ``` + +2. Test the backend directly: + ```bash + backlog completion __complete "backlog " 8 + # Should output: task, doc, board, config, completion + ``` + +3. Enable zsh completion debugging: + ```bash + zstyle ':completion:*' verbose yes + zstyle ':completion:*' format 'Completing %d' + ``` + +4. Check for errors: + ```bash + # Remove 2>/dev/null temporarily to see errors + _backlog() { + local completions=(${(f)"$(backlog completion __complete "$BUFFER" "$CURSOR")"}) + _describe 'backlog commands' completions + } + ``` diff --git a/completions/README.md b/completions/README.md new file mode 100644 index 0000000..e163780 --- /dev/null +++ b/completions/README.md @@ -0,0 +1,235 @@ +# Shell Completion Scripts + +**Note**: The completion scripts are embedded in the compiled `backlog` binary. These files serve as reference documentation and are used during development (the CLI reads them first if available, otherwise uses the embedded versions). + +## Available Shells + +### Zsh + +**File**: `_backlog` + +**Installation**: + +1. **Automatic** (recommended): + ```bash + backlog completion install --shell zsh + ``` + +2. **Manual**: + ```bash + # Copy to a directory in your $fpath + sudo cp _backlog /usr/local/share/zsh/site-functions/_backlog + + # Or add to your custom completions directory + mkdir -p ~/.zsh/completions + cp _backlog ~/.zsh/completions/_backlog + + # Add to ~/.zshrc if not already present: + fpath=(~/.zsh/completions $fpath) + autoload -Uz compinit && compinit + ``` + +3. **Testing without installation**: + ```bash + # In your current zsh session + fpath=(./completions $fpath) + autoload -Uz compinit && compinit + ``` + +**Verification**: +```bash +# Type and press TAB +backlog <TAB> +backlog task <TAB> +``` + +### Bash + +**File**: `backlog.bash` + +**Installation**: + +1. **Automatic** (recommended): + ```bash + backlog completion install --shell bash + ``` + +2. **Manual**: + ```bash + # Copy to bash-completion directory + sudo cp backlog.bash /etc/bash_completion.d/backlog + + # Or source in ~/.bashrc + echo "source /path/to/backlog.bash" >> ~/.bashrc + source ~/.bashrc + ``` + +3. **Testing without installation**: + ```bash + # In your current bash session + source ./completions/backlog.bash + ``` + +**Verification**: +```bash +# Type and press TAB +backlog <TAB> +backlog task <TAB> +``` + +### Fish + +**File**: `backlog.fish` + +**Installation**: + +1. **Automatic** (recommended): + ```bash + backlog completion install --shell fish + ``` + +2. **Manual**: + ```bash + # Copy to fish completions directory + cp backlog.fish ~/.config/fish/completions/backlog.fish + + # Completions are automatically loaded in new fish sessions + ``` + +3. **Testing without installation**: + ```bash + # In your current fish session + source ./completions/backlog.fish + ``` + +**Verification**: +```bash +# Type and press TAB +backlog <TAB> +backlog task <TAB> +``` + +## How It Works + +All completion scripts use the same backend: + +1. The shell calls the completion function when TAB is pressed +2. The completion function invokes `backlog completion __complete "$BUFFER" "$CURSOR"` +3. The CLI returns a newline-separated list of completions +4. The shell presents these completions to the user + +This architecture provides: +- **Dynamic completions**: Task IDs, labels, statuses are read from actual data +- **Consistent behavior**: All shells use the same completion logic +- **Easy maintenance**: Update completion logic once in TypeScript +- **Embedded scripts**: Scripts are built into the binary, no external files needed + +## Development + +### Testing Completions + +**Zsh**: +```bash +# Run automated tests +zsh _backlog.test.zsh + +# Or manually verify +zsh +source _backlog +which _backlog +``` + +**Bash**: +```bash +# Manually verify +bash +source backlog.bash +complete -p backlog +``` + +**Fish**: +```bash +# Run automated tests +fish backlog.test.fish + +# Or manually verify +fish +source backlog.fish +complete -C'backlog ' +``` + +### Adding New Completions + +Completions are generated by: +- `/src/completions/helper.ts` - Main completion logic +- `/src/completions/command-structure.ts` - Command parsing +- `/src/completions/data-providers.ts` - Dynamic data (task IDs, labels, etc.) +- `/src/commands/completion.ts` - Embedded shell scripts in `getEmbeddedCompletionScript()` + +To update completion scripts: +1. Edit the embedded scripts in `/src/commands/completion.ts` +2. (Optional) Update the reference files in `/completions/` for documentation +3. Rebuild: `bun run build` + +## Requirements + +- **Zsh**: Version 5.x or higher +- **Bash**: Version 4.x or higher +- **Fish**: Version 3.x or higher + +## Troubleshooting + +### Completions not working + +1. Verify the CLI is in your PATH: + ```bash + which backlog + ``` + +2. Check completion function is loaded: + ```bash + # Zsh + which _backlog + + # Bash + complete -p backlog + + # Fish + complete -C'backlog ' + ``` + +3. Test the completion backend directly: + ```bash + backlog completion __complete "backlog task " 13 + ``` + This should output available subcommands for `backlog task`. + +4. Reload your shell configuration: + ```bash + # Zsh + exec zsh + + # Bash + exec bash + + # Fish + exec fish + ``` + +### Slow completions + +If completions feel slow, it may be because: +- Large number of tasks/documents in your backlog +- Network latency (if applicable) +- First completion triggers CLI initialization + +The completion system is designed to be fast, but with very large datasets you may notice a slight delay. + +## Contributing + +When adding new completion features: + +1. Update the backend in `/src/completions/` +2. Test with `backlog completion __complete` +3. Verify each shell script still works +4. Update this README if behavior changes diff --git a/completions/_backlog b/completions/_backlog new file mode 100644 index 0000000..16c87aa --- /dev/null +++ b/completions/_backlog @@ -0,0 +1,35 @@ +#compdef backlog + +# Zsh completion script for backlog CLI +# +# NOTE: This script is embedded in the backlog binary and installed automatically +# via 'backlog completion install'. This file serves as reference documentation. +# +# Installation: +# - Recommended: backlog completion install --shell zsh +# - Manual: Copy this file to a directory in your $fpath and run compinit + +_backlog() { + # Get the current command line buffer and cursor position + local line=$BUFFER + local point=$CURSOR + + # Call the backlog completion command to get dynamic completions + # The __complete command returns one completion per line + local -a completions + completions=(${(f)"$(backlog completion __complete "$line" "$point" 2>/dev/null)"}) + + # Check if we got any completions + if (( ${#completions[@]} == 0 )); then + # No completions available + return 1 + fi + + # Present the completions to the user + # _describe shows completions with optional descriptions + # The first argument is the tag name shown in completion groups + _describe 'backlog commands' completions +} + +# Register the completion function for the backlog command +compdef _backlog backlog diff --git a/completions/backlog.bash b/completions/backlog.bash new file mode 100755 index 0000000..6ff9838 --- /dev/null +++ b/completions/backlog.bash @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Bash completion script for backlog CLI +# +# NOTE: This script is embedded in the backlog binary and installed automatically +# via 'backlog completion install'. This file serves as reference documentation. +# +# Installation: +# - Recommended: backlog completion install --shell bash +# - Manual: Copy to /etc/bash_completion.d/backlog +# - Or source directly in ~/.bashrc: source /path/to/backlog.bash +# +# Requirements: +# - Bash 4.x or 5.x +# - bash-completion package (optional but recommended) + +# Main completion function for backlog CLI +_backlog() { + # Initialize completion variables using bash-completion helper if available + # Falls back to manual initialization if bash-completion is not installed + local cur prev words cword + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion || return + else + # Manual initialization fallback + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi + + # Get the full command line and cursor position + local line="${COMP_LINE}" + local point="${COMP_POINT}" + + # Call the CLI's internal completion command + # This delegates all completion logic to the TypeScript implementation + # Output format: one completion per line + local completions + completions=$(backlog completion __complete "$line" "$point" 2>/dev/null) + + # Check if the completion command failed + if [[ $? -ne 0 ]]; then + # Silent failure - completion should never break the shell + return 0 + fi + + # Generate completion replies using compgen + # -W: wordlist - splits completions by whitespace + # --: end of options + # "$cur": current word being completed + COMPREPLY=( $(compgen -W "$completions" -- "$cur") ) + + # Return success + return 0 +} + +# Register the completion function for the 'backlog' command +# -F: use function for completion +# _backlog: name of the completion function +# backlog: command to complete +complete -F _backlog backlog diff --git a/completions/backlog.fish b/completions/backlog.fish new file mode 100644 index 0000000..2d4465c --- /dev/null +++ b/completions/backlog.fish @@ -0,0 +1,38 @@ +#!/usr/bin/env fish +# Fish completion script for backlog CLI +# +# NOTE: This script is embedded in the backlog binary and installed automatically +# via 'backlog completion install'. This file serves as reference documentation. +# +# Installation: +# - Recommended: backlog completion install --shell fish +# - Manual: Copy to ~/.config/fish/completions/backlog.fish +# +# Requirements: +# - Fish 3.x or later + +# Helper function to get completions from the CLI +# This delegates all completion logic to the TypeScript implementation +function __backlog_complete + # Get the current command line and cursor position + # -cp: get the command line with cursor position preserved + set -l line (commandline -cp) + + # Calculate the cursor position (length of the line up to cursor) + # Fish tracks cursor position differently than bash/zsh + set -l point (string length "$line") + + # Call the CLI's internal completion command + # Output format: one completion per line + # Redirect stderr to /dev/null to suppress error messages + backlog completion __complete "$line" "$point" 2>/dev/null + + # Fish will automatically handle the exit status + # If the command fails, no completions will be shown +end + +# Register completion for the 'backlog' command +# -c: specify the command to complete +# -f: disable file completion (we handle all completions dynamically) +# -a: add completion candidates from the function output +complete -c backlog -f -a '(__backlog_complete)' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3973923 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + backlog: + build: . + container_name: backlog-md + restart: unless-stopped + volumes: + # Persist backlog data + - ./backlog:/app/backlog + - backlog-data:/app/.backlog + labels: + - "traefik.enable=true" + - "traefik.http.routers.backlog.rule=Host(`backlog.jeffemmett.com`)" + - "traefik.http.routers.backlog.entrypoints=web" + - "traefik.http.services.backlog.loadbalancer.server.port=6420" + - "traefik.docker.network=traefik-public" + networks: + - traefik-public + environment: + - PORT=6420 + - NODE_ENV=production + +volumes: + backlog-data: + +networks: + traefik-public: + external: true diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..cd71d1a --- /dev/null +++ b/flake.lock @@ -0,0 +1,146 @@ +{ + "nodes": { + "blueprint": { + "inputs": { + "nixpkgs": [ + "bun2nix", + "nixpkgs" + ], + "systems": [ + "bun2nix", + "systems" + ] + }, + "locked": { + "lastModified": 1744632722, + "narHash": "sha256-0chvqUV1Kzf8BMQ7MsH3CeicJEb2HeCpwliS77FGyfc=", + "owner": "numtide", + "repo": "blueprint", + "rev": "49bbd5d072b577072f4a1d07d4b0621ecce768af", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "blueprint", + "type": "github" + } + }, + "bun2nix": { + "inputs": { + "blueprint": "blueprint", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1750682174, + "narHash": "sha256-rUpcATQ0LiY8IYRndqTlPUhF4YGJH3lM2aMOs5vBDGM=", + "owner": "baileyluTCD", + "repo": "bun2nix", + "rev": "85d692d68a5345d868d3bb1158b953d2996d70f7", + "type": "github" + }, + "original": { + "owner": "baileyluTCD", + "repo": "bun2nix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1752480373, + "narHash": "sha256-JHQbm+OcGp32wAsXTE/FLYGNpb+4GLi5oTvCxwSoBOA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "bun2nix": "bun2nix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "bun2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1748243702, + "narHash": "sha256-9YzfeN8CB6SzNPyPm2XjRRqSixDopTapaRsnTpXUEY8=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "1f3f7b784643d488ba4bf315638b2b0a4c5fb007", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f273d9b --- /dev/null +++ b/flake.nix @@ -0,0 +1,148 @@ +{ + description = "Backlog.md - A markdown-based task management CLI tool"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + bun2nix = { + url = "github:baileyluTCD/bun2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, bun2nix }: + flake-utils.lib.eachDefaultSystem (system: + let + # Use baseline Bun for x86_64-linux to support older CPUs without AVX2 + # This fixes issue #412 where users with older CPUs (i7-3770, i7-3612QE) + # get "Illegal instruction" errors during the build process. + # + # The baseline build targets Nehalem architecture (2008+) with SSE4.2 + # instead of Haswell (2013+) with AVX2, allowing builds on older hardware. + # + # Using an overlay to replace the Bun package maintains full compatibility + # with the standard Bun package structure (thanks to @erdosxx for this solution). + pkgs = import nixpkgs { + inherit system; + overlays = if system == "x86_64-linux" then + let bunVersion = "1.2.23"; in [ + (final: prev: { + bun = prev.bun.overrideAttrs (oldAttrs: { + src = prev.fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-linux-x64-baseline.zip"; + sha256 = "017f89e19e1b40aa4c11a7cf671d3990cb51cc12288a43473238a019a8cafffc"; + }; + }); + }) + ] + else + []; + }; + + # Read version from package.json + packageJson = builtins.fromJSON (builtins.readFile ./package.json); + version = packageJson.version; + + ldLibraryPath = '' + LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath [ + pkgs.stdenv.cc.cc.lib + ]}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + ''; + + backlog-md = bun2nix.lib.${system}.mkBunDerivation { + pname = "backlog"; + inherit version; + src = ./.; + packageJson = ./package.json; + bunNix = ./bun.nix; + + nativeBuildInputs = with pkgs; [ bun git rsync ]; + + preBuild = '' + export HOME=$TMPDIR + export HUSKY=0 + export ${ldLibraryPath} + ''; + + buildPhase = '' + runHook preBuild + + # Build the CLI tool with embedded version + # Note: CSS is pre-compiled and committed to git, no need to build here + bun build --compile --minify --define "__EMBEDDED_VERSION__=${version}" --outfile=dist/backlog src/cli.ts + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin + cp dist/backlog $out/bin/backlog + chmod +x $out/bin/backlog + + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "A markdown-based task management CLI tool with Kanban board"; + longDescription = '' + Backlog.md is a command-line tool for managing tasks and projects using markdown files. + It provides Kanban board visualization, task management, and integrates with Git workflows. + ''; + homepage = "https://backlog.md"; + changelog = "https://github.com/MrLesk/Backlog.md/releases"; + license = licenses.mit; + maintainers = let + mrlesk = { + name = "MrLesk"; + github = "MrLesk"; + githubId = 181345848; + }; + in + with maintainers; [ anpryl mrlesk ]; + platforms = platforms.all; + mainProgram = "backlog"; + }; + }; + in + { + packages = { + default = backlog-md; + "backlog-md" = backlog-md; + }; + + apps = { + default = flake-utils.lib.mkApp { + drv = backlog-md; + name = "backlog"; + }; + }; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + bun + bun2nix.packages.${system}.default + ]; + + buildInputs = with pkgs; [ + bun + nodejs_20 + git + biome + ]; + + shellHook = '' + export ${ldLibraryPath} + + echo "Backlog.md development environment" + echo "Available commands:" + echo " bun i - Install dependencies" + echo " bun test - Run tests" + echo " bun run cli - Run CLI in development mode" + echo " bun run build - Build the CLI tool" + echo " bun run check - Run Biome checks" + ''; + }; + }); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..224f354 --- /dev/null +++ b/package.json @@ -0,0 +1,97 @@ +{ + "name": "backlog.md", + "version": "1.26.0", + "type": "module", + "module": "src/cli.ts", + "files": [ + "scripts/*.cjs", + "README.md", + "LICENSE" + ], + "bin": { + "backlog": "scripts/cli.cjs" + }, + "optionalDependencies": { + "backlog.md-darwin-arm64": "*", + "backlog.md-darwin-x64": "*", + "backlog.md-linux-arm64": "*", + "backlog.md-linux-x64": "*", + "backlog.md-windows-x64": "*" + }, + "devDependencies": { + "@biomejs/biome": "2.3.8", + "@tailwindcss/cli": "4.1.17", + "@types/bun": "1.3.3", + "@modelcontextprotocol/sdk": "1.24.2", + "@types/prompts": "2.4.9", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@types/react-router-dom": "5.3.3", + "@types/jsdom": "27.0.0", + "@uiw/react-markdown-preview": "5.1.5", + "@uiw/react-md-editor": "4.0.10", + "commander": "14.0.2", + "fuse.js": "7.1.0", + "gray-matter": "4.0.3", + "husky": "9.1.7", + "install": "0.13.0", + "lint-staged": "16.2.7", + "mermaid": "11.12.2", + "jsdom": "27.2.0", + "neo-neo-bblessed": "1.0.9", + "prompts": "2.4.2", + "react": "19.2.1", + "react-dom": "19.2.1", + "react-router-dom": "7.10.0", + "react-tooltip": "5.30.0", + "tailwindcss": "4.1.17" + }, + "scripts": { + "test": "bun test", + "format": "biome format --write .", + "lint": "biome lint --write .", + "check": "biome check .", + "check:types": "bunx tsc --noEmit", + "prepare": "husky", + "build:css": "bun ./node_modules/@tailwindcss/cli/dist/index.mjs -i src/web/styles/source.css -o src/web/styles/style.css --minify", + "build": "bun run build:css && bun build --production --compile --minify --outfile=dist/backlog src/cli.ts", + "cli": "bun run build:css && bun src/cli.ts", + "mcp": "bun src/cli.ts mcp start", + "update-nix": "sh scripts/update-nix.sh", + "postinstall": "sh -c 'command -v bun2nix >/dev/null 2>&1 && bun2nix -o bun.nix || (command -v nix >/dev/null 2>&1 && nix --extra-experimental-features \"nix-command flakes\" run github:baileyluTCD/bun2nix -- -o bun.nix || true)'" + }, + "lint-staged": { + "package.json": [ + "biome check --write --files-ignore-unknown=true" + ], + "*.json": [ + "biome check --write --files-ignore-unknown=true" + ], + "src/**/*.{ts,js}": [ + "biome check --write --files-ignore-unknown=true" + ] + }, + "author": "Alex Gavrilescu (https://github.com/MrLesk)", + "repository": { + "type": "git", + "url": "git+https://github.com/MrLesk/Backlog.md.git" + }, + "bugs": { + "url": "https://github.com/MrLesk/Backlog.md/issues" + }, + "homepage": "https://backlog.md", + "keywords": [ + "cli", + "markdown", + "kanban", + "task", + "project-management", + "backlog", + "agents" + ], + "license": "MIT", + "trustedDependencies": [ + "@biomejs/biome", + "node-pty" + ] +} diff --git a/scripts/cli.cjs b/scripts/cli.cjs new file mode 100755 index 0000000..9d83baa --- /dev/null +++ b/scripts/cli.cjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +const { spawn } = require("node:child_process"); +const { resolveBinaryPath } = require("./resolveBinary.cjs"); + +let binaryPath; +try { + binaryPath = resolveBinaryPath(); +} catch { + console.error(`Binary package not installed for ${process.platform}-${process.arch}.`); + process.exit(1); +} + +// Clean up unexpected args some global shims pass (e.g. bun) like the binary path itself +const rawArgs = process.argv.slice(2); +const cleanedArgs = rawArgs.filter((arg) => { + if (arg === binaryPath) return false; + // Filter any accidental deep path to our platform package binary + try { + const pattern = /node_modules[/\\]backlog\.md-(darwin|linux|windows)-[^/\\]+[/\\]backlog(\.exe)?$/i; + return !pattern.test(arg); + } catch { + return true; + } +}); + +// Spawn the binary with cleaned arguments +const child = spawn(binaryPath, cleanedArgs, { + stdio: "inherit", + windowsHide: true, +}); + +// Handle exit +child.on("exit", (code) => { + process.exit(code || 0); +}); + +// Handle errors +child.on("error", (err) => { + if (err.code === "ENOENT") { + console.error(`Binary not found: ${binaryPath}`); + console.error(`Please ensure you have the correct version for your platform (${process.platform}-${process.arch})`); + } else { + console.error("Failed to start backlog:", err); + } + process.exit(1); +}); diff --git a/scripts/postuninstall.cjs b/scripts/postuninstall.cjs new file mode 100644 index 0000000..ab53b88 --- /dev/null +++ b/scripts/postuninstall.cjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +const { spawn } = require("node:child_process"); + +// Platform-specific packages to uninstall +const platformPackages = [ + "backlog.md-linux-x64", + "backlog.md-linux-arm64", + "backlog.md-darwin-x64", + "backlog.md-darwin-arm64", + "backlog.md-windows-x64", +]; + +// Detect package manager +const packageManager = process.env.npm_config_user_agent?.split("/")[0] || "npm"; + +console.log("Cleaning up platform-specific packages..."); + +// Try to uninstall all platform packages +for (const pkg of platformPackages) { + const args = packageManager === "bun" ? ["remove", "-g", pkg] : ["uninstall", "-g", pkg]; + + const child = spawn(packageManager, args, { + stdio: "pipe", // Don't show output to avoid spam + windowsHide: true, + }); + + child.on("exit", (code) => { + if (code === 0) { + console.log(`βœ“ Cleaned up ${pkg}`); + } + // Silently ignore failures - package might not be installed + }); +} + +console.log("Platform package cleanup completed."); diff --git a/scripts/resolveBinary.cjs b/scripts/resolveBinary.cjs new file mode 100644 index 0000000..2c6aa30 --- /dev/null +++ b/scripts/resolveBinary.cjs @@ -0,0 +1,33 @@ +function mapPlatform(platform = process.platform) { + switch (platform) { + case "win32": + return "windows"; + case "darwin": + case "linux": + return platform; + default: + return platform; + } +} + +function mapArch(arch = process.arch) { + switch (arch) { + case "x64": + case "arm64": + return arch; + default: + return arch; + } +} + +function getPackageName(platform = process.platform, arch = process.arch) { + return `backlog.md-${mapPlatform(platform)}-${mapArch(arch)}`; +} + +function resolveBinaryPath(platform = process.platform, arch = process.arch) { + const packageName = getPackageName(platform, arch); + const binary = `backlog${platform === "win32" ? ".exe" : ""}`; + return require.resolve(`${packageName}/${binary}`); +} + +module.exports = { getPackageName, resolveBinaryPath }; diff --git a/scripts/update-nix.sh b/scripts/update-nix.sh new file mode 100755 index 0000000..6f51814 --- /dev/null +++ b/scripts/update-nix.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Updates bun.nix using bun2nix via Docker +# Run this after updating dependencies (bun install) before committing + +set -e + +echo "πŸ”„ Regenerating bun.nix using bun2nix..." + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "❌ Error: Docker is not installed or not in PATH" + echo " Please install Docker or use Nix directly if available" + exit 1 +fi + +# Run bun2nix in Docker +docker run --rm -v "$(pwd):/app" -w /app nixos/nix:latest \ + nix --extra-experimental-features "nix-command flakes" run github:baileyluTCD/bun2nix -- -o bun.nix + +echo "βœ… bun.nix has been regenerated successfully" +echo " Don't forget to commit the updated bun.nix file!" diff --git a/src/agent-instructions.ts b/src/agent-instructions.ts new file mode 100644 index 0000000..85d6dbd --- /dev/null +++ b/src/agent-instructions.ts @@ -0,0 +1,277 @@ +import { existsSync, readFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + AGENT_GUIDELINES, + CLAUDE_AGENT_CONTENT, + CLAUDE_GUIDELINES, + COPILOT_GUIDELINES, + GEMINI_GUIDELINES, + MCP_AGENT_NUDGE, + README_GUIDELINES, +} from "./constants/index.ts"; +import type { GitOperations } from "./git/operations.ts"; + +export type AgentInstructionFile = + | "AGENTS.md" + | "CLAUDE.md" + | "GEMINI.md" + | ".github/copilot-instructions.md" + | "README.md"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function loadContent(textOrPath: string): Promise<string> { + if (textOrPath.includes("\n")) return textOrPath; + try { + const path = isAbsolute(textOrPath) ? textOrPath : join(__dirname, textOrPath); + return await Bun.file(path).text(); + } catch { + return textOrPath; + } +} + +type GuidelineMarkerKind = "default" | "mcp"; + +/** + * Gets the appropriate markers for a given file type + */ +function getMarkers(fileName: string, kind: GuidelineMarkerKind = "default"): { start: string; end: string } { + const label = kind === "mcp" ? "BACKLOG.MD MCP GUIDELINES" : "BACKLOG.MD GUIDELINES"; + if (fileName === ".cursorrules") { + // .cursorrules doesn't support HTML comments, use markdown-style comments + return { + start: `# === ${label} START ===`, + end: `# === ${label} END ===`, + }; + } + // All markdown files support HTML comments + return { + start: `<!-- ${label} START -->`, + end: `<!-- ${label} END -->`, + }; +} + +/** + * Checks if the Backlog.md guidelines are already present in the content + */ +function hasBacklogGuidelines(content: string, fileName: string): boolean { + const { start } = getMarkers(fileName); + return content.includes(start); +} + +/** + * Wraps the Backlog.md guidelines with appropriate markers + */ +function wrapWithMarkers(content: string, fileName: string, kind: GuidelineMarkerKind = "default"): string { + const { start, end } = getMarkers(fileName, kind); + return `\n${start}\n${content}\n${end}\n`; +} + +function stripGuidelineSection( + content: string, + fileName: string, + kind: GuidelineMarkerKind, +): { content: string; removed: boolean; firstIndex?: number } { + const { start, end } = getMarkers(fileName, kind); + let removed = false; + let result = content; + let firstIndex: number | undefined; + + while (true) { + const startIndex = result.indexOf(start); + if (startIndex === -1) { + break; + } + + const endIndex = result.indexOf(end, startIndex); + if (endIndex === -1) { + break; + } + + let removalStart = startIndex; + while (removalStart > 0 && (result[removalStart - 1] === " " || result[removalStart - 1] === "\t")) { + removalStart -= 1; + } + if (removalStart > 0 && result[removalStart - 1] === "\n") { + removalStart -= 1; + if (removalStart > 0 && result[removalStart - 1] === "\r") { + removalStart -= 1; + } + } else if (removalStart > 0 && result[removalStart - 1] === "\r") { + removalStart -= 1; + } + + let removalEnd = endIndex + end.length; + if (removalEnd < result.length && result[removalEnd] === "\r") { + removalEnd += 1; + } + if (removalEnd < result.length && result[removalEnd] === "\n") { + removalEnd += 1; + } + + if (firstIndex === undefined) { + firstIndex = removalStart; + } + result = result.slice(0, removalStart) + result.slice(removalEnd); + removed = true; + } + + return { content: result, removed, firstIndex }; +} + +export async function addAgentInstructions( + projectRoot: string, + git?: GitOperations, + files: AgentInstructionFile[] = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md"], + autoCommit = false, +): Promise<void> { + const mapping: Record<AgentInstructionFile, string> = { + "AGENTS.md": AGENT_GUIDELINES, + "CLAUDE.md": CLAUDE_GUIDELINES, + "GEMINI.md": GEMINI_GUIDELINES, + ".github/copilot-instructions.md": COPILOT_GUIDELINES, + "README.md": README_GUIDELINES, + }; + + const paths: string[] = []; + for (const name of files) { + const content = await loadContent(mapping[name]); + const filePath = join(projectRoot, name); + let finalContent = ""; + + // Check if file exists first to avoid Windows hanging issue + if (existsSync(filePath)) { + try { + // On Windows, use synchronous read to avoid hanging + let existing: string; + if (process.platform === "win32") { + existing = readFileSync(filePath, "utf-8"); + } else { + existing = await Bun.file(filePath).text(); + } + + const mcpStripped = stripGuidelineSection(existing, name, "mcp"); + if (mcpStripped.removed) { + existing = mcpStripped.content; + } + + // Check if Backlog.md guidelines are already present + if (hasBacklogGuidelines(existing, name)) { + // Guidelines already exist, skip this file + continue; + } + + // Append Backlog.md guidelines with markers + if (!existing.endsWith("\n")) existing += "\n"; + finalContent = existing + wrapWithMarkers(content, name); + } catch (error) { + console.error(`Error reading existing file ${filePath}:`, error); + // If we can't read it, just use the new content with markers + finalContent = wrapWithMarkers(content, name); + } + } else { + // File doesn't exist, create with markers + finalContent = wrapWithMarkers(content, name); + } + + await mkdir(dirname(filePath), { recursive: true }); + await Bun.write(filePath, finalContent); + paths.push(filePath); + } + + if (git && paths.length > 0 && autoCommit) { + await git.addFiles(paths); + await git.commitChanges("Add AI agent instructions"); + } +} + +export { loadContent as _loadAgentGuideline }; + +function _hasMcpGuidelines(content: string, fileName: string): boolean { + const { start } = getMarkers(fileName, "mcp"); + return content.includes(start); +} + +async function readExistingFile(filePath: string): Promise<string> { + if (process.platform === "win32") { + return readFileSync(filePath, "utf-8"); + } + return await Bun.file(filePath).text(); +} + +export interface EnsureMcpGuidelinesResult { + changed: boolean; + created: boolean; + fileName: AgentInstructionFile; + filePath: string; +} + +export async function ensureMcpGuidelines( + projectRoot: string, + fileName: AgentInstructionFile, +): Promise<EnsureMcpGuidelinesResult> { + const filePath = join(projectRoot, fileName); + const fileExists = existsSync(filePath); + let existing = ""; + let original = ""; + let insertIndex: number | null = null; + + if (fileExists) { + try { + existing = await readExistingFile(filePath); + original = existing; + const cliStripped = stripGuidelineSection(existing, fileName, "default"); + if (cliStripped.removed && cliStripped.firstIndex !== undefined) { + insertIndex = cliStripped.firstIndex; + } + existing = cliStripped.content; + const mcpStripped = stripGuidelineSection(existing, fileName, "mcp"); + if (mcpStripped.removed && mcpStripped.firstIndex !== undefined) { + insertIndex = mcpStripped.firstIndex; + } + existing = mcpStripped.content; + } catch (error) { + console.error(`Error reading existing file ${filePath}:`, error); + existing = ""; + } + } + + const nudgeBlock = wrapWithMarkers(MCP_AGENT_NUDGE, fileName, "mcp"); + let nextContent: string; + if (insertIndex !== null) { + const normalizedIndex = Math.max(0, Math.min(insertIndex, existing.length)); + nextContent = existing.slice(0, normalizedIndex) + nudgeBlock + existing.slice(normalizedIndex); + } else { + nextContent = existing; + if (nextContent && !nextContent.endsWith("\n")) { + nextContent += "\n"; + } + nextContent += nudgeBlock; + } + + const finalContent = nextContent; + const changed = !fileExists || finalContent !== original; + + await mkdir(dirname(filePath), { recursive: true }); + if (changed) { + await Bun.write(filePath, finalContent); + } + + return { changed, created: !fileExists, fileName, filePath }; +} + +/** + * Installs the Claude Code backlog agent to the project's .claude/agents directory + */ +export async function installClaudeAgent(projectRoot: string): Promise<void> { + const agentDir = join(projectRoot, ".claude", "agents"); + const agentPath = join(agentDir, "project-manager-backlog.md"); + + // Create the directory if it doesn't exist + await mkdir(agentDir, { recursive: true }); + + // Write the agent content + await Bun.write(agentPath, CLAUDE_AGENT_CONTENT); +} diff --git a/src/board.ts b/src/board.ts new file mode 100644 index 0000000..e18c128 --- /dev/null +++ b/src/board.ts @@ -0,0 +1,198 @@ +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { Task } from "./types/index.ts"; + +export interface BoardOptions { + statuses?: string[]; +} + +export type BoardLayout = "horizontal" | "vertical"; +export type BoardFormat = "terminal" | "markdown"; + +export function buildKanbanStatusGroups( + tasks: Task[], + statuses: string[], +): { orderedStatuses: string[]; groupedTasks: Map<string, Task[]> } { + const canonicalByLower = new Map<string, string>(); + const orderedConfiguredStatuses: string[] = []; + const configuredSeen = new Set<string>(); + + for (const status of statuses ?? []) { + if (typeof status !== "string") continue; + const trimmed = status.trim(); + if (!trimmed) continue; + const lower = trimmed.toLowerCase(); + if (!canonicalByLower.has(lower)) { + canonicalByLower.set(lower, trimmed); + } + if (!configuredSeen.has(trimmed)) { + orderedConfiguredStatuses.push(trimmed); + configuredSeen.add(trimmed); + } + } + + const groupedTasks = new Map<string, Task[]>(); + for (const status of orderedConfiguredStatuses) { + groupedTasks.set(status, []); + } + + for (const task of tasks) { + const raw = (task.status ?? "").trim(); + if (!raw) continue; + const canonical = canonicalByLower.get(raw.toLowerCase()) ?? raw; + if (!groupedTasks.has(canonical)) { + groupedTasks.set(canonical, []); + } + groupedTasks.get(canonical)?.push(task); + } + + const orderedStatuses: string[] = []; + const seen = new Set<string>(); + + for (const status of orderedConfiguredStatuses) { + if (seen.has(status)) continue; + orderedStatuses.push(status); + seen.add(status); + } + + for (const status of groupedTasks.keys()) { + if (seen.has(status)) continue; + orderedStatuses.push(status); + seen.add(status); + } + + return { orderedStatuses, groupedTasks }; +} + +export function generateKanbanBoardWithMetadata(tasks: Task[], statuses: string[], projectName: string): string { + // Generate timestamp + const now = new Date(); + const timestamp = now.toISOString().replace("T", " ").substring(0, 19); + + const { orderedStatuses, groupedTasks } = buildKanbanStatusGroups(tasks, statuses); + + // Create header + const header = `# Kanban Board Export (powered by Backlog.md) +Generated on: ${timestamp} +Project: ${projectName} + +`; + + // Return early if there are no configured statuses and no tasks + if (orderedStatuses.length === 0) { + return `${header}No tasks found.`; + } + + // Create table header + const headerRow = `| ${orderedStatuses.map((status) => status || "No Status").join(" | ")} |`; + const separatorRow = `| ${orderedStatuses.map(() => "---").join(" | ")} |`; + + // Map for quick lookup by id + const byId = new Map<string, Task>(tasks.map((t) => [t.id, t])); + + // Group tasks by status and handle parent-child relationships + const columns: Task[][] = orderedStatuses.map((status) => { + const items = groupedTasks.get(status) || []; + const top: Task[] = []; + const children = new Map<string, Task[]>(); + + // Sort items: All columns by updatedDate descending (fallback to createdDate), then by ID as secondary + const sortedItems = items.sort((a, b) => { + // Primary sort: updatedDate (newest first), fallback to createdDate if updatedDate is missing + const dateA = a.updatedDate ? new Date(a.updatedDate).getTime() : new Date(a.createdDate).getTime(); + const dateB = b.updatedDate ? new Date(b.updatedDate).getTime() : new Date(b.createdDate).getTime(); + if (dateB !== dateA) { + return dateB - dateA; // Newest first + } + // Secondary sort: ID descending when dates are equal + const idA = Number.parseInt(a.id.replace("task-", ""), 10); + const idB = Number.parseInt(b.id.replace("task-", ""), 10); + return idB - idA; // Highest ID first (newest) + }); + + // Separate top-level tasks from subtasks + for (const t of sortedItems) { + const parent = t.parentTaskId ? byId.get(t.parentTaskId) : undefined; + if (parent && parent.status === t.status) { + // Subtask with same status as parent - group under parent + const list = children.get(parent.id) || []; + list.push(t); + children.set(parent.id, list); + } else { + // Top-level task or subtask with different status + top.push(t); + } + } + + // Build final list with subtasks nested under parents + const result: Task[] = []; + for (const t of top) { + result.push(t); + const subs = children.get(t.id) || []; + subs.sort((a, b) => { + const idA = Number.parseInt(a.id.replace("task-", ""), 10); + const idB = Number.parseInt(b.id.replace("task-", ""), 10); + return idA - idB; // Subtasks in ascending order + }); + result.push(...subs); + } + + return result; + }); + + const maxTasks = Math.max(...columns.map((c) => c.length), 0); + const rows = [headerRow, separatorRow]; + + for (let taskIdx = 0; taskIdx < maxTasks; taskIdx++) { + const row = orderedStatuses.map((_, cIdx) => { + const task = columns[cIdx]?.[taskIdx]; + if (!task || !task.id || !task.title) return ""; + + // Check if this is a subtask + const isSubtask = task.parentTaskId; + const taskIdPrefix = isSubtask ? "└─ " : ""; + const taskIdUpper = task.id.toUpperCase(); + + // Format assignees in brackets or empty string if none + // Add @ prefix only if not already present + const assigneesText = + task.assignee && task.assignee.length > 0 + ? ` [${task.assignee.map((a) => (a.startsWith("@") ? a : `@${a}`)).join(", ")}]` + : ""; + + // Format labels with # prefix and italic or empty string if none + const labelsText = + task.labels && task.labels.length > 0 ? `<br>*${task.labels.map((label) => `#${label}`).join(" ")}*` : ""; + + return `${taskIdPrefix}**${taskIdUpper}** - ${task.title}${assigneesText}${labelsText}`; + }); + rows.push(`| ${row.join(" | ")} |`); + } + + const table = `${rows.join("\n")}`; + if (maxTasks === 0) { + return `${header}${table}\n\nNo tasks found.\n`; + } + + return `${header}${table}\n`; +} + +export async function exportKanbanBoardToFile( + tasks: Task[], + statuses: string[], + filePath: string, + projectName: string, + _overwrite = false, +): Promise<void> { + const board = generateKanbanBoardWithMetadata(tasks, statuses, projectName); + + // Ensure directory exists + try { + await mkdir(dirname(filePath), { recursive: true }); + } catch { + // Directory might already exist + } + + // Write the content (overwrite mode) + await Bun.write(filePath, board); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..813479c --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,3080 @@ +#!/usr/bin/env node + +import { basename, join } from "node:path"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { $, spawn } from "bun"; +import { Command } from "commander"; +import prompts from "prompts"; +import { runAdvancedConfigWizard } from "./commands/advanced-config-wizard.ts"; +import { type CompletionInstallResult, installCompletion, registerCompletionCommand } from "./commands/completion.ts"; +import { configureAdvancedSettings } from "./commands/configure-advanced-settings.ts"; +import { registerMcpCommand } from "./commands/mcp.ts"; +import { DEFAULT_DIRECTORIES } from "./constants/index.ts"; +import { initializeProject } from "./core/init.ts"; +import { computeSequences } from "./core/sequences.ts"; +import { formatTaskPlainText } from "./formatters/task-plain-text.ts"; +import { + type AgentInstructionFile, + addAgentInstructions, + Core, + type EnsureMcpGuidelinesResult, + ensureMcpGuidelines, + exportKanbanBoardToFile, + initializeGitRepository, + installClaudeAgent, + isGitRepository, + updateReadmeWithBoard, +} from "./index.ts"; +import { + type BacklogConfig, + type Decision, + type DecisionSearchResult, + type Document as DocType, + type DocumentSearchResult, + isLocalEditableTask, + type SearchPriorityFilter, + type SearchResult, + type SearchResultType, + type Task, + type TaskListFilter, + type TaskSearchResult, +} from "./types/index.ts"; +import type { TaskEditArgs } from "./types/task-edit-args.ts"; +import { genericSelectList } from "./ui/components/generic-list.ts"; +import { createLoadingScreen } from "./ui/loading.ts"; +import { viewTaskEnhanced } from "./ui/task-viewer-with-search.ts"; +import { promptText, scrollableViewer } from "./ui/tui.ts"; +import { type AgentSelectionValue, PLACEHOLDER_AGENT_VALUE, processAgentSelection } from "./utils/agent-selection.ts"; +import { formatValidStatuses, getCanonicalStatus, getValidStatuses } from "./utils/status.ts"; +import { parsePositiveIndexList, processAcceptanceCriteriaOptions, toStringArray } from "./utils/task-builders.ts"; +import { buildTaskUpdateInput } from "./utils/task-edit-builder.ts"; +import { normalizeTaskId, taskIdsEqual } from "./utils/task-path.ts"; +import { sortTasks } from "./utils/task-sorting.ts"; +import { getVersion } from "./utils/version.ts"; + +type IntegrationMode = "mcp" | "cli" | "none"; + +function normalizeIntegrationOption(value: string): IntegrationMode | null { + const normalized = value.trim().toLowerCase(); + if ( + normalized === "mcp" || + normalized === "connector" || + normalized === "model-context-protocol" || + normalized === "model_context_protocol" + ) { + return "mcp"; + } + if ( + normalized === "cli" || + normalized === "legacy" || + normalized === "commands" || + normalized === "command" || + normalized === "instructions" || + normalized === "instruction" || + normalized === "agent" || + normalized === "agents" + ) { + return "cli"; + } + if ( + normalized === "none" || + normalized === "skip" || + normalized === "manual" || + normalized === "later" || + normalized === "no" || + normalized === "off" + ) { + return "none"; + } + return null; +} + +// Always use "backlog" as the global MCP server name so fallback mode works when the project isn't initialized. +const MCP_SERVER_NAME = "backlog"; + +const MCP_CLIENT_INSTRUCTION_MAP: Record<string, AgentInstructionFile> = { + claude: "CLAUDE.md", + codex: "AGENTS.md", + gemini: "GEMINI.md", + guide: "AGENTS.md", +}; + +async function openUrlInBrowser(url: string): Promise<void> { + let cmd: string[]; + if (process.platform === "darwin") { + cmd = ["open", url]; + } else if (process.platform === "win32") { + cmd = ["cmd", "/c", "start", "", url]; + } else { + cmd = ["xdg-open", url]; + } + try { + await $`${cmd}`.quiet(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(` ⚠️ Unable to open browser automatically (${message}). Please visit ${url}`); + } +} + +async function runMcpClientCommand(label: string, command: string, args: string[]): Promise<string> { + console.log(` Configuring ${label}...`); + try { + const child = spawn({ + cmd: [command, ...args], + stdout: "inherit", + stderr: "inherit", + }); + await child.exited; + console.log(` βœ“ Added Backlog MCP server to ${label}`); + return label; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(` ⚠️ Unable to configure ${label} automatically (${message}).`); + console.warn(` Run manually: ${command} ${args.join(" ")}`); + return `${label} (manual setup required)`; + } +} + +// Helper function for accumulating multiple CLI option values +function createMultiValueAccumulator() { + return (value: string, previous: string | string[]) => { + const soFar = Array.isArray(previous) ? previous : previous ? [previous] : []; + return [...soFar, value]; + }; +} + +// Helper function to process multiple AC operations +/** + * Processes --ac and --acceptance-criteria options to extract acceptance criteria + * Handles both single values and arrays from multi-value accumulators + */ +function getDefaultAdvancedConfig(existingConfig?: BacklogConfig | null): Partial<BacklogConfig> { + return { + checkActiveBranches: existingConfig?.checkActiveBranches ?? true, + remoteOperations: existingConfig?.remoteOperations ?? true, + activeBranchDays: existingConfig?.activeBranchDays ?? 30, + bypassGitHooks: existingConfig?.bypassGitHooks ?? false, + autoCommit: existingConfig?.autoCommit ?? false, + zeroPaddedIds: existingConfig?.zeroPaddedIds, + defaultEditor: existingConfig?.defaultEditor, + defaultPort: existingConfig?.defaultPort ?? 6420, + autoOpenBrowser: existingConfig?.autoOpenBrowser ?? true, + }; +} + +// Windows color fix +if (process.platform === "win32") { + const term = process.env.TERM; + if (!term || /^(xterm|dumb|ansi|vt100)$/i.test(term)) { + process.env.TERM = "xterm-256color"; + } +} + +// Temporarily isolate BUN_OPTIONS during CLI parsing to prevent conflicts +// Save the original value so it's available for subsequent commands +const originalBunOptions = process.env.BUN_OPTIONS; +if (process.env.BUN_OPTIONS) { + delete process.env.BUN_OPTIONS; +} + +// Get version from package.json +const version = await getVersion(); + +// Bare-run splash screen handling (before Commander parses commands) +// Show a welcome splash when invoked without subcommands, unless help/version requested +try { + let rawArgs = process.argv.slice(2); + // Some package managers (e.g., Bun global shims) may inject the resolved + // binary path as the first non-node argument. Strip it if detected. + if (rawArgs.length > 0) { + const first = rawArgs[0]; + if ( + typeof first === "string" && + /node_modules[\\/]+backlog\.md-(darwin|linux|windows)-[^\\/]+[\\/]+backlog(\.exe)?$/.test(first) + ) { + rawArgs = rawArgs.slice(1); + } + } + const wantsHelp = rawArgs.includes("-h") || rawArgs.includes("--help"); + const wantsVersion = rawArgs.includes("-v") || rawArgs.includes("--version"); + // Treat only --plain as allowed flag for splash; any other args means use normal CLI parsing + const onlyPlain = rawArgs.length === 1 && rawArgs[0] === "--plain"; + const isBare = rawArgs.length === 0 || onlyPlain; + if (isBare && !wantsHelp && !wantsVersion) { + const isTTY = !!process.stdout.isTTY; + const forcePlain = rawArgs.includes("--plain"); + const noColor = !!process.env.NO_COLOR || !isTTY; + + let initialized = false; + try { + const core = new Core(process.cwd()); + const cfg = await core.filesystem.loadConfig(); + initialized = !!cfg; + } catch { + initialized = false; + } + + const { printSplash } = await import("./ui/splash.ts"); + // Auto-fallback to plain when non-TTY, or explicit --plain, or if terminal very narrow + const termWidth = Math.max(0, Number(process.stdout.columns || 0)); + const autoPlain = !isTTY || (termWidth > 0 && termWidth < 60); + await printSplash({ + version, + initialized, + plain: forcePlain || autoPlain, + color: !noColor, + }); + // Ensure we don't enter Commander command parsing + process.exit(0); + } +} catch { + // Fall through to normal CLI parsing on any splash error +} + +// Global config migration - run before any command processing +// Only run if we're in a backlog project (skip for init, help, version) +const shouldRunMigration = + !process.argv.includes("init") && + !process.argv.includes("--help") && + !process.argv.includes("-h") && + !process.argv.includes("--version") && + !process.argv.includes("-v") && + process.argv.length > 2; // Ensure we have actual commands + +if (shouldRunMigration) { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + + // Only migrate if config already exists (project is already initialized) + const config = await core.filesystem.loadConfig(); + if (config) { + await core.ensureConfigMigrated(); + } + } catch (_error) { + // Silently ignore migration errors - project might not be initialized yet + } +} + +const program = new Command(); +program + .name("backlog") + .description("Backlog.md - Project management CLI") + .version(version, "-v, --version", "display version number"); + +program + .command("init [projectName]") + .description("initialize backlog project in the current repository") + .option( + "--agent-instructions <instructions>", + "comma-separated agent instructions to create. Valid: claude, agents, gemini, copilot, cursor (alias of agents), none. Use 'none' to skip; when combined with others, 'none' is ignored.", + ) + .option("--check-branches <boolean>", "check task states across active branches (default: true)") + .option("--include-remote <boolean>", "include remote branches when checking (default: true)") + .option("--branch-days <number>", "days to consider branch active (default: 30)") + .option("--bypass-git-hooks <boolean>", "bypass git hooks when committing (default: false)") + .option("--zero-padded-ids <number>", "number of digits for zero-padding IDs (0 to disable)") + .option("--default-editor <editor>", "default editor command") + .option("--web-port <number>", "default web UI port (default: 6420)") + .option("--auto-open-browser <boolean>", "auto-open browser for web UI (default: true)") + .option("--install-claude-agent <boolean>", "install Claude Code agent (default: false)") + .option("--integration-mode <mode>", "choose how AI tools connect to Backlog.md (mcp, cli, or none)") + .option("--defaults", "use default values for all prompts") + .action( + async ( + projectName: string | undefined, + options: { + agentInstructions?: string; + checkBranches?: string; + includeRemote?: string; + branchDays?: string; + bypassGitHooks?: string; + zeroPaddedIds?: string; + defaultEditor?: string; + webPort?: string; + autoOpenBrowser?: string; + installClaudeAgent?: string; + integrationMode?: string; + defaults?: boolean; + }, + ) => { + try { + const cwd = process.cwd(); + const isRepo = await isGitRepository(cwd); + + if (!isRepo) { + const rl = createInterface({ input, output }); + const answer = (await rl.question("No git repository found. Initialize one here? [y/N] ")) + .trim() + .toLowerCase(); + rl.close(); + + if (answer.startsWith("y")) { + await initializeGitRepository(cwd); + } else { + console.log("Aborting initialization."); + process.exit(1); + } + } + + const core = new Core(cwd); + + // Check if project is already initialized and load existing config + const existingConfig = await core.filesystem.loadConfig(); + const isReInitialization = !!existingConfig; + + if (isReInitialization) { + console.log( + "Existing backlog project detected. Current configuration will be preserved where not specified.", + ); + } + + // Helper function to parse boolean strings + const parseBoolean = (value: string | undefined, defaultValue: boolean): boolean => { + if (value === undefined) return defaultValue; + return value.toLowerCase() === "true" || value === "1"; + }; + + // Helper function to parse number strings + const parseNumber = (value: string | undefined, defaultValue: number): number => { + if (value === undefined) return defaultValue; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; + }; + + // Non-interactive mode when any flag is provided or --defaults is used + const isNonInteractive = !!( + options.agentInstructions || + options.defaults || + options.checkBranches || + options.includeRemote || + options.branchDays || + options.bypassGitHooks || + options.zeroPaddedIds || + options.defaultEditor || + options.webPort || + options.autoOpenBrowser || + options.installClaudeAgent || + options.integrationMode + ); + + // Get project name + let name = projectName; + if (!name) { + const defaultName = existingConfig?.projectName || ""; + const promptMessage = isReInitialization && defaultName ? `Project name (${defaultName}):` : "Project name:"; + name = await promptText(promptMessage); + // Use existing name if nothing entered during re-init + if (!name && isReInitialization && defaultName) { + name = defaultName; + } + if (!name) { + console.log("Aborting initialization."); + process.exit(1); + } + } + + const defaultAdvancedConfig = getDefaultAdvancedConfig(existingConfig); + const applyAdvancedOptionOverrides = () => { + const result: Partial<BacklogConfig> = { ...defaultAdvancedConfig }; + result.checkActiveBranches = parseBoolean(options.checkBranches, result.checkActiveBranches ?? true); + if (result.checkActiveBranches) { + result.remoteOperations = parseBoolean(options.includeRemote, result.remoteOperations ?? true); + result.activeBranchDays = parseNumber(options.branchDays, result.activeBranchDays ?? 30); + } else { + result.remoteOperations = false; + } + result.bypassGitHooks = parseBoolean(options.bypassGitHooks, result.bypassGitHooks ?? false); + const paddingValue = parseNumber(options.zeroPaddedIds, result.zeroPaddedIds ?? 0); + result.zeroPaddedIds = paddingValue > 0 ? paddingValue : undefined; + result.defaultEditor = + options.defaultEditor || + existingConfig?.defaultEditor || + process.env.EDITOR || + process.env.VISUAL || + undefined; + result.defaultPort = parseNumber(options.webPort, result.defaultPort ?? 6420); + result.autoOpenBrowser = parseBoolean(options.autoOpenBrowser, result.autoOpenBrowser ?? true); + return result; + }; + + const integrationOption = options.integrationMode + ? normalizeIntegrationOption(options.integrationMode) + : undefined; + if (options.integrationMode && !integrationOption) { + console.error(`Invalid integration mode: ${options.integrationMode}. Valid options are: mcp, cli, none`); + process.exit(1); + } + + let integrationMode: IntegrationMode | null = integrationOption ?? (isNonInteractive ? "mcp" : null); + const mcpServerName = MCP_SERVER_NAME; + type AgentSelection = AgentSelectionValue; + let agentFiles: AgentInstructionFile[] = []; + let agentInstructionsSkipped = false; + let mcpClientSetupSummary: string | undefined; + const mcpGuideUrl = "https://github.com/MrLesk/Backlog.md#-mcp-integration-model-context-protocol"; + + if ( + !integrationOption && + integrationMode === "mcp" && + (options.agentInstructions || options.installClaudeAgent) + ) { + integrationMode = "cli"; + } + + if (integrationMode === "mcp" && (options.agentInstructions || options.installClaudeAgent)) { + console.error( + "The MCP connector option cannot be combined with --agent-instructions or --install-claude-agent.", + ); + process.exit(1); + } + + if (integrationMode === "none" && (options.agentInstructions || options.installClaudeAgent)) { + console.error( + "Skipping AI integration cannot be combined with --agent-instructions or --install-claude-agent.", + ); + process.exit(1); + } + + mainSelection: while (true) { + if (integrationMode === null) { + let cancelled = false; + const integrationPrompt = await prompts( + { + type: "select", + name: "mode", + message: "How would you like your AI tools to connect to Backlog.md?", + hint: "Pick MCP when your editor supports the Model Context Protocol.", + initial: 0, + choices: [ + { + title: "via MCP connector (recommended for Claude Code, Codex, Gemini, Cursor, etc.)", + description: "Agents learn the Backlog.md workflow through MCP tools, resources, and prompts.", + value: "mcp", + }, + { + title: "via CLI commands (broader compatibility)", + description: "Agents will use Backlog.md by invoking CLI commands directly", + value: "cli", + }, + { + title: "Skip for now (I am not using Backlog.md with AI tools)", + description: "Continue without setting up MCP or instruction files.", + value: "none", + }, + ], + }, + { + onCancel: () => { + cancelled = true; + }, + }, + ); + + if (cancelled) { + console.log("Initialization cancelled."); + return; + } + + const selectedMode = integrationPrompt?.mode + ? normalizeIntegrationOption(String(integrationPrompt.mode)) + : null; + integrationMode = selectedMode ?? "mcp"; + console.log(""); + } + + if (integrationMode === "cli") { + if (options.agentInstructions) { + const nameMap: Record<string, AgentSelection> = { + cursor: "AGENTS.md", + claude: "CLAUDE.md", + agents: "AGENTS.md", + gemini: "GEMINI.md", + copilot: ".github/copilot-instructions.md", + none: "none", + "CLAUDE.md": "CLAUDE.md", + "AGENTS.md": "AGENTS.md", + "GEMINI.md": "GEMINI.md", + ".github/copilot-instructions.md": ".github/copilot-instructions.md", + }; + + const requestedInstructions = options.agentInstructions.split(",").map((f) => f.trim().toLowerCase()); + const mappedFiles: AgentSelection[] = []; + + for (const instruction of requestedInstructions) { + const mappedFile = nameMap[instruction]; + if (!mappedFile) { + console.error(`Invalid agent instruction: ${instruction}`); + console.error("Valid options are: cursor, claude, agents, gemini, copilot, none"); + process.exit(1); + } + mappedFiles.push(mappedFile); + } + + const { files, needsRetry, skipped } = processAgentSelection({ selected: mappedFiles }); + if (needsRetry) { + console.error("Please select at least one agent instruction file before continuing."); + process.exit(1); + } + agentFiles = files; + agentInstructionsSkipped = skipped; + } else if (isNonInteractive) { + agentFiles = []; + } else { + const defaultHint = "Enter selects highlighted agent (after moving); space toggles selections\n"; + while (true) { + let highlighted: AgentSelection | undefined; + let initialCursor: number | undefined; + let cursorMoved = false; + let selectionCancelled = false; + const response = await prompts( + { + type: "multiselect", + name: "files", + message: "Select instruction files for CLI-based AI tools", + choices: [ + { + title: "↓ Use space to toggle instruction files (enter accepts)", + value: PLACEHOLDER_AGENT_VALUE, + disabled: true, + }, + { title: "CLAUDE.md β€” Claude Code", value: "CLAUDE.md" }, + { + title: "AGENTS.md β€” Codex, Cursor, Zed, Warp, Aider, RooCode, etc.", + value: "AGENTS.md", + }, + { title: "GEMINI.md β€” Google Gemini Code Assist CLI", value: "GEMINI.md" }, + { title: "Copilot instructions β€” GitHub Copilot", value: ".github/copilot-instructions.md" }, + ], + hint: defaultHint, + instructions: false, + onRender: function () { + try { + const promptInstance = this as unknown as { + cursor: number; + value: Array<{ value: AgentSelection }>; + hint: string; + }; + if (initialCursor === undefined) { + initialCursor = promptInstance.cursor; + } + if (initialCursor !== undefined && promptInstance.cursor !== initialCursor) { + cursorMoved = true; + } + const focus = promptInstance.value?.[promptInstance.cursor]; + highlighted = focus?.value; + promptInstance.hint = defaultHint; + } catch {} + return undefined; + }, + }, + { + onCancel: () => { + selectionCancelled = true; + }, + }, + ); + + if (selectionCancelled) { + integrationMode = null; + console.log(""); + continue mainSelection; + } + + const rawSelection = (response?.files ?? []) as AgentSelection[]; + const selected = + rawSelection.length === 0 && + highlighted && + highlighted !== PLACEHOLDER_AGENT_VALUE && + highlighted !== "none" + ? [highlighted] + : rawSelection; + const { files, needsRetry, skipped } = processAgentSelection({ + selected, + highlighted, + useHighlightFallback: cursorMoved, + }); + if (needsRetry) { + console.log("Please select at least one agent instruction file before continuing."); + continue; + } + agentFiles = files; + agentInstructionsSkipped = skipped; + break; + } + } + + break; + } + + if (integrationMode === "mcp") { + if (isNonInteractive) { + mcpClientSetupSummary = "skipped (non-interactive)"; + break; + } + + console.log(` MCP server name: ${mcpServerName}`); + while (true) { + let clientSelectionCancelled = false; + let highlightedClient: string | undefined; + const clientResponse = await prompts( + { + type: "multiselect", + name: "clients", + message: "Which AI tools should we configure right now?", + hint: "Space toggles items β€’ Enter confirms (leave empty to skip)", + instructions: false, + choices: [ + { title: "Claude Code", value: "claude" }, + { title: "OpenAI Codex", value: "codex" }, + { title: "Gemini CLI", value: "gemini" }, + { title: "Other (open setup guide)", value: "guide" }, + ], + onRender: function () { + try { + const promptInstance = this as unknown as { + cursor: number; + value: Array<{ value: string }>; + }; + highlightedClient = promptInstance.value?.[promptInstance.cursor]?.value; + } catch {} + return undefined; + }, + }, + { + onCancel: () => { + clientSelectionCancelled = true; + }, + }, + ); + + if (clientSelectionCancelled) { + integrationMode = null; + console.log(""); + continue mainSelection; + } + + const rawClients = (clientResponse?.clients ?? []) as string[]; + const selectedClients = rawClients.length === 0 && highlightedClient ? [highlightedClient] : rawClients; + highlightedClient = undefined; + if (selectedClients.length === 0) { + console.log(" MCP client setup skipped (configure later if needed)."); + mcpClientSetupSummary = "skipped"; + break; + } + + const results: string[] = []; + const mcpGuidelineUpdates: EnsureMcpGuidelinesResult[] = []; + const recordGuidelinesForClient = async (clientKey: string) => { + const instructionFile = MCP_CLIENT_INSTRUCTION_MAP[clientKey]; + if (!instructionFile) { + return; + } + const nudgeResult = await ensureMcpGuidelines(cwd, instructionFile); + if (nudgeResult.changed) { + mcpGuidelineUpdates.push(nudgeResult); + } + }; + const uniq = (values: string[]) => [...new Set(values)]; + + for (const client of selectedClients) { + if (client === "claude") { + const result = await runMcpClientCommand("Claude Code", "claude", [ + "mcp", + "add", + "-s", + "user", + mcpServerName, + "--", + "backlog", + "mcp", + "start", + ]); + results.push(result); + await recordGuidelinesForClient(client); + continue; + } + if (client === "codex") { + const result = await runMcpClientCommand("OpenAI Codex", "codex", [ + "mcp", + "add", + mcpServerName, + "backlog", + "mcp", + "start", + ]); + results.push(result); + await recordGuidelinesForClient(client); + continue; + } + if (client === "gemini") { + const result = await runMcpClientCommand("Gemini CLI", "gemini", [ + "mcp", + "add", + "-s", + "user", + mcpServerName, + "backlog", + "mcp", + "start", + ]); + results.push(result); + await recordGuidelinesForClient(client); + continue; + } + if (client === "guide") { + console.log(" Opening MCP setup guide in your browser..."); + await openUrlInBrowser(mcpGuideUrl); + results.push("Setup guide opened"); + await recordGuidelinesForClient(client); + } + } + + if (mcpGuidelineUpdates.length > 0) { + const createdFiles = uniq( + mcpGuidelineUpdates.filter((entry) => entry.created).map((entry) => entry.fileName), + ); + const updatedFiles = uniq( + mcpGuidelineUpdates.filter((entry) => !entry.created).map((entry) => entry.fileName), + ); + if (createdFiles.length > 0) { + console.log(` Created MCP reminder file(s): ${createdFiles.join(", ")}`); + } + if (updatedFiles.length > 0) { + console.log(` Added MCP reminder to ${updatedFiles.join(", ")}`); + } + } + + mcpClientSetupSummary = results.join(", "); + break; + } + + break; + } + + if (integrationMode === "none") { + agentFiles = []; + agentInstructionsSkipped = false; + break; + } + } + + let advancedConfig: Partial<BacklogConfig> = { ...defaultAdvancedConfig }; + let advancedConfigured = false; + let installClaudeAgentSelection = false; + let installShellCompletionsSelection = false; + let completionInstallResult: CompletionInstallResult | null = null; + let completionInstallError: string | null = null; + + if (isNonInteractive) { + advancedConfig = applyAdvancedOptionOverrides(); + installClaudeAgentSelection = + integrationMode === "cli" ? parseBoolean(options.installClaudeAgent, false) : false; + } else { + const advancedPrompt = await prompts( + { + type: "confirm", + name: "configureAdvanced", + message: "Configure advanced settings now?", + hint: "Runs the advanced backlog config wizard", + initial: false, + }, + { + onCancel: () => { + console.log("Aborting initialization."); + process.exit(1); + }, + }, + ); + + if (advancedPrompt.configureAdvanced) { + const wizardResult = await runAdvancedConfigWizard({ + existingConfig, + cancelMessage: "Aborting initialization.", + includeClaudePrompt: integrationMode === "cli", + }); + advancedConfig = { ...defaultAdvancedConfig, ...wizardResult.config }; + installClaudeAgentSelection = integrationMode === "cli" ? wizardResult.installClaudeAgent : false; + installShellCompletionsSelection = wizardResult.installShellCompletions; + if (wizardResult.installShellCompletions) { + try { + completionInstallResult = await installCompletion(); + } catch (error) { + completionInstallError = error instanceof Error ? error.message : String(error); + } + } + advancedConfigured = true; + } + } + // Call shared core init function + const initResult = await initializeProject(core, { + projectName: name, + integrationMode: integrationMode || "none", + mcpClients: [], // MCP clients are handled separately in CLI with interactive prompts + agentInstructions: agentFiles, + installClaudeAgent: installClaudeAgentSelection, + advancedConfig: { + checkActiveBranches: advancedConfig.checkActiveBranches, + remoteOperations: advancedConfig.remoteOperations, + activeBranchDays: advancedConfig.activeBranchDays, + bypassGitHooks: advancedConfig.bypassGitHooks, + autoCommit: advancedConfig.autoCommit, + zeroPaddedIds: advancedConfig.zeroPaddedIds, + defaultEditor: advancedConfig.defaultEditor, + defaultPort: advancedConfig.defaultPort, + autoOpenBrowser: advancedConfig.autoOpenBrowser, + }, + existingConfig, + }); + + const config = initResult.config; + + // Show configuration summary + console.log("\nInitialization Summary:"); + console.log(` Project Name: ${config.projectName}`); + if (integrationMode === "cli") { + console.log(" AI Integration: CLI commands (legacy)"); + if (agentFiles.length > 0) { + console.log(` Agent instructions: ${agentFiles.join(", ")}`); + } else if (agentInstructionsSkipped) { + console.log(" Agent instructions: skipped"); + } else { + console.log(" Agent instructions: none"); + } + } else if (integrationMode === "mcp") { + console.log(" AI Integration: MCP connector"); + console.log(" Agent instruction files: guidance is provided through the MCP connector."); + console.log(` MCP server name: ${mcpServerName}`); + console.log(` MCP client setup: ${mcpClientSetupSummary ?? "skipped"}`); + } else { + console.log( + " AI integration skipped. Configure later via `backlog init` or by registering the MCP server manually.", + ); + } + let completionSummary: string; + if (completionInstallResult) { + completionSummary = `installed to ${completionInstallResult.installPath}`; + } else if (installShellCompletionsSelection) { + completionSummary = "installation failed (see warning below)"; + } else if (advancedConfigured) { + completionSummary = "skipped"; + } else { + completionSummary = "not configured"; + } + console.log(` Shell completions: ${completionSummary}`); + if (advancedConfigured) { + console.log(" Advanced settings:"); + console.log(` Check active branches: ${config.checkActiveBranches}`); + console.log(` Remote operations: ${config.remoteOperations}`); + console.log(` Active branch days: ${config.activeBranchDays}`); + console.log(` Bypass git hooks: ${config.bypassGitHooks}`); + console.log(` Auto commit: ${config.autoCommit}`); + console.log(` Zero-padded IDs: ${config.zeroPaddedIds ? `${config.zeroPaddedIds} digits` : "disabled"}`); + console.log(` Web UI port: ${config.defaultPort}`); + console.log(` Auto open browser: ${config.autoOpenBrowser}`); + if (config.defaultEditor) { + console.log(` Default editor: ${config.defaultEditor}`); + } + } else { + console.log(" Advanced settings: unchanged (run `backlog config` to customize)."); + } + console.log(""); + + if (completionInstallResult) { + const instructions = completionInstallResult.instructions.trim(); + console.log( + [ + `Shell completion script installed for ${completionInstallResult.shell}.`, + ` Path: ${completionInstallResult.installPath}`, + instructions, + "", + ].join("\n"), + ); + } else if (completionInstallError) { + const indentedError = completionInstallError + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + console.warn( + `⚠️ Shell completion installation failed:\n${indentedError}\n Run \`backlog completion install\` later to retry.\n`, + ); + } + + // Log init result + if (initResult.isReInitialization) { + console.log(`Updated backlog project configuration: ${name}`); + } else { + console.log(`Initialized backlog project: ${name}`); + } + + // Log agent files result from shared init + if (integrationMode === "cli") { + if (initResult.mcpResults?.agentFiles) { + console.log(`βœ“ ${initResult.mcpResults.agentFiles}`); + } else if (agentInstructionsSkipped) { + console.log("Skipping agent instruction files per selection."); + } + } + + // Log Claude agent result from shared init + if (integrationMode === "cli" && initResult.mcpResults?.claudeAgent) { + console.log(`βœ“ Claude Code Backlog.md agent ${initResult.mcpResults.claudeAgent}`); + } + + // Final warning if remote operations were enabled but no git remotes are configured + try { + if (config.remoteOperations) { + // Ensure git ops are ready (config not strictly required for this check) + const hasRemotes = await core.gitOps.hasAnyRemote(); + if (!hasRemotes) { + console.warn( + [ + "Warning: remoteOperations is enabled but no git remotes are configured.", + "Remote features will be skipped until a remote is added (e.g., 'git remote add origin <url>')", + "or disable remoteOperations via 'backlog config set remoteOperations false'.", + ].join(" "), + ); + } + } + } catch { + // Ignore failures in final advisory warning + } + } catch (err) { + console.error("Failed to initialize project", err); + process.exitCode = 1; + } + }, + ); + +export async function generateNextDocId(core: Core): Promise<string> { + const config = await core.filesystem.loadConfig(); + // Load local documents + const docs = await core.filesystem.listDocuments(); + const allIds: string[] = []; + + try { + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + + // Skip remote operations if disabled + if (config?.remoteOperations === false) { + if (process.env.DEBUG) { + console.log("Remote operations disabled - generating ID from local documents only"); + } + } else { + await core.gitOps.fetch(); + } + + const branches = await core.gitOps.listAllBranches(); + + // Load files from all branches in parallel + const branchFilePromises = branches.map(async (branch) => { + const files = await core.gitOps.listFilesInTree(branch, `${backlogDir}/docs`); + return files + .map((file) => { + const match = file.match(/doc-(\d+)/); + return match ? `doc-${match[1]}` : null; + }) + .filter((id): id is string => id !== null); + }); + + const branchResults = await Promise.all(branchFilePromises); + for (const branchIds of branchResults) { + allIds.push(...branchIds); + } + } catch (error) { + // Suppress errors for offline mode or other git issues + if (process.env.DEBUG) { + console.error("Could not fetch remote document IDs:", error); + } + } + + // Add local document IDs + for (const doc of docs) { + allIds.push(doc.id); + } + + // Find the highest numeric ID + let max = 0; + for (const id of allIds) { + const match = id.match(/^doc-(\d+)$/); + if (match) { + const num = Number.parseInt(match[1] || "0", 10); + if (num > max) max = num; + } + } + + const nextIdNumber = max + 1; + const padding = config?.zeroPaddedIds; + + if (padding && typeof padding === "number" && padding > 0) { + const paddedId = String(nextIdNumber).padStart(padding, "0"); + return `doc-${paddedId}`; + } + + return `doc-${nextIdNumber}`; +} + +export async function generateNextDecisionId(core: Core): Promise<string> { + const config = await core.filesystem.loadConfig(); + // Load local decisions + const decisions = await core.filesystem.listDecisions(); + const allIds: string[] = []; + + try { + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + + // Skip remote operations if disabled + if (config?.remoteOperations === false) { + if (process.env.DEBUG) { + console.log("Remote operations disabled - generating ID from local decisions only"); + } + } else { + await core.gitOps.fetch(); + } + + const branches = await core.gitOps.listAllBranches(); + + // Load files from all branches in parallel + const branchFilePromises = branches.map(async (branch) => { + const files = await core.gitOps.listFilesInTree(branch, `${backlogDir}/decisions`); + return files + .map((file) => { + const match = file.match(/decision-(\d+)/); + return match ? `decision-${match[1]}` : null; + }) + .filter((id): id is string => id !== null); + }); + + const branchResults = await Promise.all(branchFilePromises); + for (const branchIds of branchResults) { + allIds.push(...branchIds); + } + } catch (error) { + // Suppress errors for offline mode or other git issues + if (process.env.DEBUG) { + console.error("Could not fetch remote decision IDs:", error); + } + } + + // Add local decision IDs + for (const decision of decisions) { + allIds.push(decision.id); + } + + // Find the highest numeric ID + let max = 0; + for (const id of allIds) { + const match = id.match(/^decision-(\d+)$/); + if (match) { + const num = Number.parseInt(match[1] || "0", 10); + if (num > max) max = num; + } + } + + const nextIdNumber = max + 1; + const padding = config?.zeroPaddedIds; + + if (padding && typeof padding === "number" && padding > 0) { + const paddedId = String(nextIdNumber).padStart(padding, "0"); + return `decision-${paddedId}`; + } + + return `decision-${nextIdNumber}`; +} + +function normalizeDependencies(dependencies: unknown): string[] { + if (!dependencies) return []; + + const normalizeList = (values: string[]): string[] => + values + .map((value) => value.trim()) + .filter((value): value is string => value.length > 0) + .map((value) => normalizeTaskId(value)); + + if (Array.isArray(dependencies)) { + return normalizeList( + dependencies.flatMap((dep) => + String(dep) + .split(",") + .map((d) => d.trim()), + ), + ); + } + + return normalizeList(String(dependencies).split(",")); +} + +async function validateDependencies( + dependencies: string[], + core: Core, +): Promise<{ valid: string[]; invalid: string[] }> { + const valid: string[] = []; + const invalid: string[] = []; + + if (dependencies.length === 0) { + return { valid, invalid }; + } + + // Load both tasks and drafts to validate dependencies + const [tasks, drafts] = await Promise.all([core.queryTasks(), core.fs.listDrafts()]); + + const knownIds = [...tasks.map((task) => task.id), ...drafts.map((draft) => draft.id)]; + for (const dep of dependencies) { + const match = knownIds.find((id) => taskIdsEqual(dep, id)); + if (match) { + valid.push(match); + } else { + invalid.push(dep); + } + } + + return { valid, invalid }; +} + +function buildTaskFromOptions(id: string, title: string, options: Record<string, unknown>): Task { + const parentInput = options.parent ? String(options.parent) : undefined; + const normalizedParent = parentInput ? normalizeTaskId(parentInput) : undefined; + + const createdDate = new Date().toISOString().slice(0, 16).replace("T", " "); + + // Handle dependencies - they will be validated separately + const dependencies = normalizeDependencies(options.dependsOn || options.dep); + + // Validate priority option + const priority = options.priority ? String(options.priority).toLowerCase() : undefined; + const validPriorities = ["high", "medium", "low"]; + const validatedPriority = + priority && validPriorities.includes(priority) ? (priority as "high" | "medium" | "low") : undefined; + + return { + id, + title, + status: options.status ? String(options.status) : "", + assignee: options.assignee ? [String(options.assignee)] : [], + createdDate, + labels: options.labels + ? String(options.labels) + .split(",") + .map((l: string) => l.trim()) + .filter(Boolean) + : [], + dependencies, + rawContent: "", + ...(options.description || options.desc ? { description: String(options.description || options.desc) } : {}), + ...(normalizedParent && { parentTaskId: normalizedParent }), + ...(validatedPriority && { priority: validatedPriority }), + }; +} + +const taskCmd = program.command("task").aliases(["tasks"]); + +taskCmd + .command("create <title>") + .option( + "-d, --description <text>", + "task description (multi-line: bash $'Line1\\nLine2', POSIX printf, PowerShell \"Line1`nLine2\")", + ) + .option("--desc <text>", "alias for --description") + .option("-a, --assignee <assignee>") + .option("-s, --status <status>") + .option("-l, --labels <labels>") + .option("--priority <priority>", "set task priority (high, medium, low)") + .option("--plain", "use plain text output after creating") + .option("--ac <criteria>", "add acceptance criteria (can be used multiple times)", createMultiValueAccumulator()) + .option( + "--acceptance-criteria <criteria>", + "add acceptance criteria (can be used multiple times)", + createMultiValueAccumulator(), + ) + .option("--plan <text>", "add implementation plan") + .option("--notes <text>", "add implementation notes") + .option("--draft") + .option("-p, --parent <taskId>", "specify parent task ID") + .option( + "--depends-on <taskIds>", + "specify task dependencies (comma-separated or use multiple times)", + (value, previous) => { + const soFar = Array.isArray(previous) ? previous : previous ? [previous] : []; + return [...soFar, value]; + }, + ) + .option("--dep <taskIds>", "specify task dependencies (shortcut for --depends-on)", (value, previous) => { + const soFar = Array.isArray(previous) ? previous : previous ? [previous] : []; + return [...soFar, value]; + }) + .action(async (title: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + await core.ensureConfigLoaded(); + const id = await core.generateNextId(options.parent); + const task = buildTaskFromOptions(id, title, options); + + // Normalize and validate status if provided (case-insensitive) + if (options.status) { + const canonical = await getCanonicalStatus(String(options.status), core); + if (!canonical) { + const configuredStatuses = await getValidStatuses(core); + console.error( + `Invalid status: ${options.status}. Valid statuses are: ${formatValidStatuses(configuredStatuses)}`, + ); + process.exitCode = 1; + return; + } + task.status = canonical; + } + + // Validate dependencies if provided + if (task.dependencies.length > 0) { + const { valid, invalid } = await validateDependencies(task.dependencies, core); + if (invalid.length > 0) { + console.error(`Error: The following dependencies do not exist: ${invalid.join(", ")}`); + console.error("Please create these tasks first or check the task IDs."); + process.exitCode = 1; + return; + } + task.dependencies = valid; + } + + // Handle acceptance criteria for create command (structured only) + const criteria = processAcceptanceCriteriaOptions(options); + if (criteria.length > 0) { + let idx = 1; + task.acceptanceCriteriaItems = criteria.map((text) => ({ index: idx++, text, checked: false })); + } + + // Handle implementation plan + if (options.plan) { + task.implementationPlan = String(options.plan); + } + + // Handle implementation notes + if (options.notes) { + task.implementationNotes = String(options.notes); + } + + // Workaround for bun compile issue with commander options + const isPlainFlag = options.plain || process.argv.includes("--plain"); + + if (options.draft) { + const filepath = await core.createDraft(task); + if (isPlainFlag) { + console.log(formatTaskPlainText(task, { filePathOverride: filepath })); + return; + } + console.log(`Created draft ${id}`); + console.log(`File: ${filepath}`); + } else { + const filepath = await core.createTask(task); + if (isPlainFlag) { + console.log(formatTaskPlainText(task, { filePathOverride: filepath })); + return; + } + console.log(`Created task ${id}`); + console.log(`File: ${filepath}`); + } + }); + +program + .command("search [query]") + .description("search tasks, documents, and decisions using the shared index") + .option("--type <type>", "limit results to type (task, document, decision)", createMultiValueAccumulator()) + .option("--status <status>", "filter task results by status") + .option("--priority <priority>", "filter task results by priority (high, medium, low)") + .option("--limit <number>", "limit total results returned") + .option("--plain", "print plain text output instead of interactive UI") + .action(async (query: string | undefined, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const searchService = await core.getSearchService(); + const contentStore = await core.getContentStore(); + const cleanup = () => { + searchService.dispose(); + contentStore.dispose(); + }; + + const rawTypes = options.type ? (Array.isArray(options.type) ? options.type : [options.type]) : undefined; + const allowedTypes: SearchResultType[] = ["task", "document", "decision"]; + const types = rawTypes + ? rawTypes + .map((value: string) => value.toLowerCase()) + .filter((value: string): value is SearchResultType => { + if (!allowedTypes.includes(value as SearchResultType)) { + console.warn(`Ignoring unsupported type '${value}'. Supported: task, document, decision`); + return false; + } + return true; + }) + : allowedTypes; + + const filters: { status?: string; priority?: SearchPriorityFilter } = {}; + if (options.status) { + filters.status = options.status; + } + if (options.priority) { + const priorityLower = String(options.priority).toLowerCase(); + const validPriorities: SearchPriorityFilter[] = ["high", "medium", "low"]; + if (!validPriorities.includes(priorityLower as SearchPriorityFilter)) { + console.error("Invalid priority. Valid values: high, medium, low"); + cleanup(); + process.exitCode = 1; + return; + } + filters.priority = priorityLower as SearchPriorityFilter; + } + + let limit: number | undefined; + if (options.limit !== undefined) { + const parsed = Number.parseInt(String(options.limit), 10); + if (Number.isNaN(parsed) || parsed <= 0) { + console.error("--limit must be a positive integer"); + cleanup(); + process.exitCode = 1; + return; + } + limit = parsed; + } + + const searchResults = searchService.search({ + query: query ?? "", + limit, + types, + filters, + }); + + const isPlainFlag = options.plain || process.argv.includes("--plain") || !process.stdout.isTTY; + if (isPlainFlag) { + printSearchResults(searchResults); + cleanup(); + return; + } + + const taskResults = searchResults.filter(isTaskSearchResult); + const searchResultTasks = taskResults.map((result) => result.task); + + const allTasks = (await core.queryTasks()).filter( + (task) => task.id && task.id.trim() !== "" && task.id.startsWith("task-"), + ); + + // If no tasks exist at all, show plain text results + if (allTasks.length === 0) { + printSearchResults(searchResults); + cleanup(); + return; + } + + // Use the first search result as the selected task, or first available task if no results + const firstTask = searchResultTasks[0] || allTasks[0]; + const priorityFilter = filters.priority ? filters.priority : undefined; + const statusFilter = filters.status; + const { runUnifiedView } = await import("./ui/unified-view.ts"); + + await runUnifiedView({ + core, + initialView: "task-list", + selectedTask: firstTask, + tasks: allTasks, // Pass ALL tasks, not just search results + filter: { + title: query ? `Search: ${query}` : "Search", + filterDescription: buildSearchFilterDescription({ + status: statusFilter, + priority: priorityFilter, + query: query ?? "", + }), + status: statusFilter, + priority: priorityFilter, + searchQuery: query ?? "", // Pre-populate search with the query + }, + }); + cleanup(); + }); + +function buildSearchFilterDescription(filters: { + status?: string; + priority?: SearchPriorityFilter; + query?: string; +}): string { + const parts: string[] = []; + if (filters.query) { + parts.push(`Query: ${filters.query}`); + } + if (filters.status) { + parts.push(`Status: ${filters.status}`); + } + if (filters.priority) { + parts.push(`Priority: ${filters.priority}`); + } + return parts.join(" β€’ "); +} + +function printSearchResults(results: SearchResult[]): void { + if (results.length === 0) { + console.log("No results found."); + return; + } + + const tasks: TaskSearchResult[] = []; + const documents: DocumentSearchResult[] = []; + const decisions: DecisionSearchResult[] = []; + + for (const result of results) { + if (result.type === "task") { + tasks.push(result); + continue; + } + if (result.type === "document") { + documents.push(result); + continue; + } + decisions.push(result); + } + + const localTasks = tasks.filter((t) => isLocalEditableTask(t.task)); + + let printed = false; + + if (localTasks.length > 0) { + console.log("Tasks:"); + for (const taskResult of localTasks) { + const { task } = taskResult; + const scoreText = formatScore(taskResult.score); + const statusText = task.status ? ` (${task.status})` : ""; + const priorityText = task.priority ? ` [${task.priority.toUpperCase()}]` : ""; + console.log(` ${task.id} - ${task.title}${statusText}${priorityText}${scoreText}`); + } + printed = true; + } + + if (documents.length > 0) { + if (printed) { + console.log(""); + } + console.log("Documents:"); + for (const documentResult of documents) { + const { document } = documentResult; + const scoreText = formatScore(documentResult.score); + console.log(` ${document.id} - ${document.title}${scoreText}`); + } + printed = true; + } + + if (decisions.length > 0) { + if (printed) { + console.log(""); + } + console.log("Decisions:"); + for (const decisionResult of decisions) { + const { decision } = decisionResult; + const scoreText = formatScore(decisionResult.score); + console.log(` ${decision.id} - ${decision.title}${scoreText}`); + } + printed = true; + } + + if (!printed) { + console.log("No results found."); + } +} + +function formatScore(score: number | null): string { + if (score === null || score === undefined) { + return ""; + } + // Invert score so higher is better (Fuse.js uses 0=perfect match, 1=no match) + const invertedScore = 1 - score; + return ` [score ${invertedScore.toFixed(3)}]`; +} + +function isTaskSearchResult(result: SearchResult): result is TaskSearchResult { + return result.type === "task"; +} + +taskCmd + .command("list") + .description("list tasks grouped by status") + .option("-s, --status <status>", "filter tasks by status (case-insensitive)") + .option("-a, --assignee <assignee>", "filter tasks by assignee") + .option("-p, --parent <taskId>", "filter tasks by parent task ID") + .option("--priority <priority>", "filter tasks by priority (high, medium, low)") + .option("--sort <field>", "sort tasks by field (priority, id)") + .option("--plain", "use plain text output instead of interactive UI") + .action(async (options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const cleanup = () => { + core.disposeSearchService(); + core.disposeContentStore(); + }; + const baseFilters: TaskListFilter = {}; + if (options.status) { + baseFilters.status = options.status; + } + if (options.assignee) { + baseFilters.assignee = options.assignee; + } + if (options.priority) { + const priorityLower = options.priority.toLowerCase(); + const validPriorities = ["high", "medium", "low"] as const; + if (!validPriorities.includes(priorityLower as (typeof validPriorities)[number])) { + console.error(`Invalid priority: ${options.priority}. Valid values are: high, medium, low`); + process.exitCode = 1; + cleanup(); + return; + } + baseFilters.priority = priorityLower as (typeof validPriorities)[number]; + } + + let parentId: string | undefined; + if (options.parent) { + const parentInput = String(options.parent); + parentId = normalizeTaskId(parentInput); + baseFilters.parentTaskId = parentInput; + } + + if (options.sort) { + const validSortFields = ["priority", "id"]; + const sortField = options.sort.toLowerCase(); + if (!validSortFields.includes(sortField)) { + console.error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`); + process.exitCode = 1; + cleanup(); + return; + } + } + + const isPlainFlag = options.plain || process.argv.includes("--plain"); + if (isPlainFlag) { + const tasks = await core.queryTasks({ filters: baseFilters, includeCrossBranch: false }); + const config = await core.filesystem.loadConfig(); + + if (parentId) { + const parentExists = (await core.queryTasks({ includeCrossBranch: false })).some((task) => + taskIdsEqual(parentId, task.id), + ); + if (!parentExists) { + console.error(`Parent task ${parentId} not found.`); + process.exitCode = 1; + cleanup(); + return; + } + } + + let sortedTasks = tasks; + if (options.sort) { + const validSortFields = ["priority", "id"]; + const sortField = options.sort.toLowerCase(); + if (!validSortFields.includes(sortField)) { + console.error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`); + process.exitCode = 1; + cleanup(); + return; + } + sortedTasks = sortTasks(tasks, sortField); + } else { + sortedTasks = sortTasks(tasks, "priority"); + } + + let filtered = sortedTasks; + if (parentId) { + filtered = filtered.filter((task) => task.parentTaskId && taskIdsEqual(parentId, task.parentTaskId)); + } + + if (filtered.length === 0) { + if (options.parent) { + const canonicalParent = normalizeTaskId(String(options.parent)); + console.log(`No child tasks found for parent task ${canonicalParent}.`); + } else { + console.log("No tasks found."); + } + cleanup(); + return; + } + + if (options.sort && options.sort.toLowerCase() === "priority") { + const sortedByPriority = sortTasks(filtered, "priority"); + console.log("Tasks (sorted by priority):"); + for (const t of sortedByPriority) { + const priorityIndicator = t.priority ? `[${t.priority.toUpperCase()}] ` : ""; + const statusIndicator = t.status ? ` (${t.status})` : ""; + console.log(` ${priorityIndicator}${t.id} - ${t.title}${statusIndicator}`); + } + cleanup(); + return; + } + + const canonicalByLower = new Map<string, string>(); + const statuses = config?.statuses || []; + for (const status of statuses) { + canonicalByLower.set(status.toLowerCase(), status); + } + + const groups = new Map<string, Task[]>(); + for (const task of filtered) { + const rawStatus = (task.status || "").trim(); + const canonicalStatus = canonicalByLower.get(rawStatus.toLowerCase()) || rawStatus; + const list = groups.get(canonicalStatus) || []; + list.push(task); + groups.set(canonicalStatus, list); + } + + const orderedStatuses = [ + ...statuses.filter((status) => groups.has(status)), + ...Array.from(groups.keys()).filter((status) => !statuses.includes(status)), + ]; + + for (const status of orderedStatuses) { + const list = groups.get(status); + if (!list) continue; + let sortedList = list; + if (options.sort) { + sortedList = sortTasks(list, options.sort.toLowerCase()); + } + console.log(`${status || "No Status"}:`); + sortedList.forEach((task) => { + const priorityIndicator = task.priority ? `[${task.priority.toUpperCase()}] ` : ""; + console.log(` ${priorityIndicator}${task.id} - ${task.title}`); + }); + console.log(); + } + cleanup(); + return; + } + + let filterDescription = ""; + let title = "Tasks"; + const activeFilters: string[] = []; + if (options.status) activeFilters.push(`Status: ${options.status}`); + if (options.assignee) activeFilters.push(`Assignee: ${options.assignee}`); + if (options.parent) { + activeFilters.push(`Parent: ${normalizeTaskId(String(options.parent))}`); + } + if (options.priority) activeFilters.push(`Priority: ${options.priority}`); + if (options.sort) activeFilters.push(`Sort: ${options.sort}`); + + if (activeFilters.length > 0) { + filterDescription = activeFilters.join(", "); + title = `Tasks (${activeFilters.join(" β€’ ")})`; + } + + const { runUnifiedView } = await import("./ui/unified-view.ts"); + await runUnifiedView({ + core, + initialView: "task-list", + tasksLoader: async (updateProgress) => { + updateProgress("Loading configuration..."); + const config = await core.filesystem.loadConfig(); + + updateProgress("Loading tasks..."); + const tasks = await core.queryTasks({ filters: baseFilters, includeCrossBranch: false }); + + if (parentId) { + const parentExists = (await core.queryTasks({ includeCrossBranch: false })).some((task) => + taskIdsEqual(parentId, task.id), + ); + if (!parentExists) { + throw new Error(`Parent task ${parentId} not found.`); + } + } + + let sortedTasks = tasks; + if (options.sort) { + const validSortFields = ["priority", "id"]; + const sortField = options.sort.toLowerCase(); + if (!validSortFields.includes(sortField)) { + throw new Error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`); + } + sortedTasks = sortTasks(tasks, sortField); + } else { + sortedTasks = sortTasks(tasks, "priority"); + } + + let filtered = sortedTasks; + if (parentId) { + filtered = filtered.filter((task) => task.parentTaskId && taskIdsEqual(parentId, task.parentTaskId)); + } + + return { + tasks: filtered, + statuses: config?.statuses || [], + }; + }, + filter: { + status: options.status, + assignee: options.assignee, + priority: options.priority, + sort: options.sort, + title, + filterDescription, + parentTaskId: parentId, + }, + }); + cleanup(); + }); + +taskCmd + .command("edit <taskId>") + .description("edit an existing task") + .option("-t, --title <title>") + .option( + "-d, --description <text>", + "task description (multi-line: bash $'Line1\\nLine2', POSIX printf, PowerShell \"Line1`nLine2\")", + ) + .option("--desc <text>", "alias for --description") + .option("-a, --assignee <assignee>") + .option("-s, --status <status>") + .option("-l, --label <labels>") + .option("--priority <priority>", "set task priority (high, medium, low)") + .option("--ordinal <number>", "set task ordinal for custom ordering") + .option("--plain", "use plain text output after editing") + .option("--add-label <label>") + .option("--remove-label <label>") + .option("--ac <criteria>", "add acceptance criteria (can be used multiple times)", createMultiValueAccumulator()) + .option( + "--remove-ac <index>", + "remove acceptance criterion by index (1-based, can be used multiple times)", + createMultiValueAccumulator(), + ) + .option( + "--check-ac <index>", + "check acceptance criterion by index (1-based, can be used multiple times)", + createMultiValueAccumulator(), + ) + .option( + "--uncheck-ac <index>", + "uncheck acceptance criterion by index (1-based, can be used multiple times)", + createMultiValueAccumulator(), + ) + .option("--acceptance-criteria <criteria>", "set acceptance criteria (comma-separated or use multiple times)") + .option("--plan <text>", "set implementation plan") + .option("--notes <text>", "set implementation notes (replaces existing)") + .option( + "--append-notes <text>", + "append to implementation notes (can be used multiple times)", + createMultiValueAccumulator(), + ) + .option( + "--depends-on <taskIds>", + "set task dependencies (comma-separated or use multiple times)", + (value, previous) => { + const soFar = Array.isArray(previous) ? previous : previous ? [previous] : []; + return [...soFar, value]; + }, + ) + .option("--dep <taskIds>", "set task dependencies (shortcut for --depends-on)", (value, previous) => { + const soFar = Array.isArray(previous) ? previous : previous ? [previous] : []; + return [...soFar, value]; + }) + .action(async (taskId: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const canonicalId = normalizeTaskId(taskId); + const existingTask = await core.loadTaskById(canonicalId); + + if (!existingTask) { + console.error(`Task ${taskId} not found.`); + process.exitCode = 1; + return; + } + + const parseCommaSeparated = (value: unknown): string[] => { + return toStringArray(value) + .flatMap((entry) => String(entry).split(",")) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + }; + + let canonicalStatus: string | undefined; + if (options.status) { + const canonical = await getCanonicalStatus(String(options.status), core); + if (!canonical) { + const configuredStatuses = await getValidStatuses(core); + console.error( + `Invalid status: ${options.status}. Valid statuses are: ${formatValidStatuses(configuredStatuses)}`, + ); + process.exitCode = 1; + return; + } + canonicalStatus = canonical; + } + + let normalizedPriority: "high" | "medium" | "low" | undefined; + if (options.priority) { + const priority = String(options.priority).toLowerCase(); + const validPriorities = ["high", "medium", "low"] as const; + if (!validPriorities.includes(priority as (typeof validPriorities)[number])) { + console.error(`Invalid priority: ${priority}. Valid values are: high, medium, low`); + process.exitCode = 1; + return; + } + normalizedPriority = priority as "high" | "medium" | "low"; + } + + let ordinalValue: number | undefined; + if (options.ordinal !== undefined) { + const parsed = Number(options.ordinal); + if (Number.isNaN(parsed) || parsed < 0) { + console.error(`Invalid ordinal: ${options.ordinal}. Must be a non-negative number.`); + process.exitCode = 1; + return; + } + ordinalValue = parsed; + } + + let removeCriteria: number[] | undefined; + let checkCriteria: number[] | undefined; + let uncheckCriteria: number[] | undefined; + + try { + const removes = parsePositiveIndexList(options.removeAc); + if (removes.length > 0) { + removeCriteria = removes; + } + const checks = parsePositiveIndexList(options.checkAc); + if (checks.length > 0) { + checkCriteria = checks; + } + const unchecks = parsePositiveIndexList(options.uncheckAc); + if (unchecks.length > 0) { + uncheckCriteria = unchecks; + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + const labelValues = parseCommaSeparated(options.label); + const addLabelValues = parseCommaSeparated(options.addLabel); + const removeLabelValues = parseCommaSeparated(options.removeLabel); + const assigneeValues = parseCommaSeparated(options.assignee); + const acceptanceAdditions = processAcceptanceCriteriaOptions(options); + + const combinedDependencies = [...toStringArray(options.dependsOn), ...toStringArray(options.dep)]; + const dependencyValues = combinedDependencies.length > 0 ? normalizeDependencies(combinedDependencies) : undefined; + + const notesAppendValues = toStringArray(options.appendNotes); + + const editArgs: TaskEditArgs = {}; + if (options.title) { + editArgs.title = String(options.title); + } + const descriptionOption = options.description ?? options.desc; + if (descriptionOption !== undefined) { + editArgs.description = String(descriptionOption); + } + if (canonicalStatus) { + editArgs.status = canonicalStatus; + } + if (normalizedPriority) { + editArgs.priority = normalizedPriority; + } + if (ordinalValue !== undefined) { + editArgs.ordinal = ordinalValue; + } + if (labelValues.length > 0) { + editArgs.labels = labelValues; + } + if (addLabelValues.length > 0) { + editArgs.addLabels = addLabelValues; + } + if (removeLabelValues.length > 0) { + editArgs.removeLabels = removeLabelValues; + } + if (assigneeValues.length > 0) { + editArgs.assignee = assigneeValues; + } + if (dependencyValues && dependencyValues.length > 0) { + editArgs.dependencies = dependencyValues; + } + if (typeof options.plan === "string") { + editArgs.planSet = String(options.plan); + } + if (typeof options.notes === "string") { + editArgs.notesSet = String(options.notes); + } + if (notesAppendValues.length > 0) { + editArgs.notesAppend = notesAppendValues; + } + if (acceptanceAdditions.length > 0) { + editArgs.acceptanceCriteriaAdd = acceptanceAdditions; + } + if (removeCriteria) { + editArgs.acceptanceCriteriaRemove = removeCriteria; + } + if (checkCriteria) { + editArgs.acceptanceCriteriaCheck = checkCriteria; + } + if (uncheckCriteria) { + editArgs.acceptanceCriteriaUncheck = uncheckCriteria; + } + + let updatedTask: Task; + try { + const updateInput = buildTaskUpdateInput(editArgs); + updatedTask = await core.editTask(canonicalId, updateInput); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + const isPlainFlag = options.plain || process.argv.includes("--plain"); + if (isPlainFlag) { + console.log(formatTaskPlainText(updatedTask)); + return; + } + + console.log(`Updated task ${updatedTask.id}`); + }); + +// Note: Implementation notes appending is handled via `task edit --append-notes` only. + +taskCmd + .command("view <taskId>") + .description("display task details") + .option("--plain", "use plain text output instead of interactive UI") + .action(async (taskId: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const task = await core.loadTaskById(taskId); + if (!task) { + console.error(`Task ${taskId} not found.`); + return; + } + + // Plain text output for AI agents + if (options && (("plain" in options && options.plain) || process.argv.includes("--plain"))) { + console.log(formatTaskPlainText(task)); + return; + } + + // Use enhanced task viewer with detail focus + await viewTaskEnhanced(task, { startWithDetailFocus: true, core }); + }); + +taskCmd + .command("archive <taskId>") + .description("archive a task") + .action(async (taskId: string) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const success = await core.archiveTask(taskId); + if (success) { + console.log(`Archived task ${taskId}`); + } else { + console.error(`Task ${taskId} not found.`); + } + }); + +taskCmd + .command("demote <taskId>") + .description("move task back to drafts") + .action(async (taskId: string) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const success = await core.demoteTask(taskId); + if (success) { + console.log(`Demoted task ${taskId}`); + } else { + console.error(`Task ${taskId} not found.`); + } + }); + +taskCmd + .argument("[taskId]") + .option("--plain", "use plain text output") + .action(async (taskId: string | undefined, options: { plain?: boolean }) => { + const cwd = process.cwd(); + const core = new Core(cwd); + + // Don't handle commands that should be handled by specific command handlers + const reservedCommands = ["create", "list", "edit", "view", "archive", "demote"]; + if (taskId && reservedCommands.includes(taskId)) { + console.error(`Unknown command: ${taskId}`); + taskCmd.help(); + return; + } + + // Handle single task view only + if (!taskId) { + taskCmd.help(); + return; + } + + const task = await core.loadTaskById(taskId); + if (!task) { + console.error(`Task ${taskId} not found.`); + return; + } + + // Plain text output for AI agents + if (options && (options.plain || process.argv.includes("--plain"))) { + console.log(formatTaskPlainText(task)); + return; + } + + // Use unified view with detail focus and Tab switching support + const allTasks = await core.queryTasks(); + const { runUnifiedView } = await import("./ui/unified-view.ts"); + await runUnifiedView({ + core, + initialView: "task-detail", + selectedTask: task, + tasks: allTasks, + }); + }); + +const draftCmd = program.command("draft"); + +draftCmd + .command("list") + .description("list all drafts") + .option("--sort <field>", "sort drafts by field (priority, id)") + .option("--plain", "use plain text output") + .action(async (options: { plain?: boolean; sort?: string }) => { + const cwd = process.cwd(); + const core = new Core(cwd); + await core.ensureConfigLoaded(); + const drafts = await core.filesystem.listDrafts(); + + if (!drafts || drafts.length === 0) { + console.log("No drafts found."); + return; + } + + // Apply sorting - default to priority sorting like the web UI + const { sortTasks } = await import("./utils/task-sorting.ts"); + let sortedDrafts = drafts; + + if (options.sort) { + const validSortFields = ["priority", "id"]; + const sortField = options.sort.toLowerCase(); + if (!validSortFields.includes(sortField)) { + console.error(`Invalid sort field: ${options.sort}. Valid values are: priority, id`); + process.exitCode = 1; + return; + } + sortedDrafts = sortTasks(drafts, sortField); + } else { + // Default to priority sorting to match web UI behavior + sortedDrafts = sortTasks(drafts, "priority"); + } + + if (options.plain || process.argv.includes("--plain")) { + // Plain text output for AI agents + console.log("Drafts:"); + for (const draft of sortedDrafts) { + const priorityIndicator = draft.priority ? `[${draft.priority.toUpperCase()}] ` : ""; + console.log(` ${priorityIndicator}${draft.id} - ${draft.title}`); + } + } else { + // Interactive UI - use unified view with draft support + const firstDraft = sortedDrafts[0]; + if (!firstDraft) return; + + const { runUnifiedView } = await import("./ui/unified-view.ts"); + await runUnifiedView({ + core, + initialView: "task-list", + selectedTask: firstDraft, + tasks: sortedDrafts, + filter: { + filterDescription: "All Drafts", + }, + title: "Drafts", + }); + } + }); + +draftCmd + .command("create <title>") + .option( + "-d, --description <text>", + "task description (multi-line: bash $'Line1\\nLine2', POSIX printf, PowerShell \"Line1`nLine2\")", + ) + .option("--desc <text>", "alias for --description") + .option("-a, --assignee <assignee>") + .option("-s, --status <status>") + .option("-l, --labels <labels>") + .action(async (title: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + await core.ensureConfigLoaded(); + const id = await core.generateNextId(); + const task = buildTaskFromOptions(id, title, options); + const filepath = await core.createDraft(task); + console.log(`Created draft ${id}`); + console.log(`File: ${filepath}`); + }); + +draftCmd + .command("archive <taskId>") + .description("archive a draft") + .action(async (taskId: string) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const success = await core.archiveDraft(taskId); + if (success) { + console.log(`Archived draft ${taskId}`); + } else { + console.error(`Draft ${taskId} not found.`); + } + }); + +draftCmd + .command("promote <taskId>") + .description("promote draft to task") + .action(async (taskId: string) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const success = await core.promoteDraft(taskId); + if (success) { + console.log(`Promoted draft ${taskId}`); + } else { + console.error(`Draft ${taskId} not found.`); + } + }); + +draftCmd + .command("view <taskId>") + .description("display draft details") + .option("--plain", "use plain text output instead of interactive UI") + .action(async (taskId: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const { getDraftPath } = await import("./utils/task-path.ts"); + const filePath = await getDraftPath(taskId, core); + + if (!filePath) { + console.error(`Draft ${taskId} not found.`); + return; + } + const draft = await core.filesystem.loadDraft(taskId); + + if (!draft) { + console.error(`Draft ${taskId} not found.`); + return; + } + + // Plain text output for AI agents + if (options && (("plain" in options && options.plain) || process.argv.includes("--plain"))) { + console.log(formatTaskPlainText(draft)); + return; + } + + // Use enhanced task viewer with detail focus + await viewTaskEnhanced(draft, { startWithDetailFocus: true, core }); + }); + +draftCmd + .argument("[taskId]") + .option("--plain", "use plain text output") + .action(async (taskId: string | undefined, options: { plain?: boolean }) => { + if (!taskId) { + draftCmd.help(); + return; + } + + const cwd = process.cwd(); + const core = new Core(cwd); + const { getDraftPath } = await import("./utils/task-path.ts"); + const filePath = await getDraftPath(taskId, core); + + if (!filePath) { + console.error(`Draft ${taskId} not found.`); + return; + } + const draft = await core.filesystem.loadDraft(taskId); + + if (!draft) { + console.error(`Draft ${taskId} not found.`); + return; + } + + // Plain text output for AI agents + if (options && (options.plain || process.argv.includes("--plain"))) { + console.log(formatTaskPlainText(draft, { filePathOverride: filePath })); + return; + } + + // Use enhanced task viewer with detail focus + await viewTaskEnhanced(draft, { startWithDetailFocus: true, core }); + }); + +const boardCmd = program.command("board"); + +function addBoardOptions(cmd: Command) { + return cmd + .option("-l, --layout <layout>", "board layout (horizontal|vertical)", "horizontal") + .option("--vertical", "use vertical layout (shortcut for --layout vertical)"); +} + +async function handleBoardView(options: { layout?: string; vertical?: boolean }) { + const cwd = process.cwd(); + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + + const _layout = options.vertical ? "vertical" : (options.layout as "horizontal" | "vertical") || "horizontal"; + const _maxColumnWidth = config?.maxColumnWidth || 20; // Default for terminal display + const statuses = config?.statuses || []; + + // Use unified view for Tab switching support + const { runUnifiedView } = await import("./ui/unified-view.ts"); + await runUnifiedView({ + core, + initialView: "kanban", + tasksLoader: async (updateProgress) => { + const tasks = await core.loadTasks((msg) => { + updateProgress(msg); + }); + return { + tasks: tasks.map((t) => ({ ...t, status: t.status || "" })), + statuses, + }; + }, + }); +} + +addBoardOptions(boardCmd).description("display tasks in a Kanban board").action(handleBoardView); + +addBoardOptions(boardCmd.command("view").description("display tasks in a Kanban board")).action(handleBoardView); + +boardCmd + .command("export [filename]") + .description("export kanban board to markdown file") + .option("--force", "overwrite existing file without confirmation") + .option("--readme", "export to README.md with markers") + .option("--export-version <version>", "version to include in the export") + .action(async (filename, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + // Load tasks with progress tracking + const loadingScreen = await createLoadingScreen("Loading tasks for export"); + + let finalTasks: Task[]; + try { + // Use the shared Core method for loading board tasks + finalTasks = await core.loadTasks((msg) => { + loadingScreen?.update(msg); + }); + + loadingScreen?.update(`Total tasks: ${finalTasks.length}`); + + // Close loading screen before export + loadingScreen?.close(); + + // Get project name from config or use directory name + const { basename } = await import("node:path"); + const projectName = config?.projectName || basename(cwd); + + if (options.readme) { + // Use version from option if provided, otherwise use the CLI version + const exportVersion = options.exportVersion || version; + await updateReadmeWithBoard(finalTasks, statuses, projectName, exportVersion); + console.log("Updated README.md with Kanban board."); + } else { + // Use filename argument or default to Backlog.md + const outputFile = filename || "Backlog.md"; + const outputPath = join(cwd, outputFile as string); + + // Check if file exists and handle overwrite confirmation + const fileExists = await Bun.file(outputPath).exists(); + if (fileExists && !options.force) { + const rl = createInterface({ input }); + try { + const answer = await rl.question(`File "${outputPath}" already exists. Overwrite? (y/N): `); + if (!answer.toLowerCase().startsWith("y")) { + console.log("Export cancelled."); + return; + } + } finally { + rl.close(); + } + } + + await exportKanbanBoardToFile(finalTasks, statuses, outputPath, projectName, options.force || !fileExists); + console.log(`Exported board to ${outputPath}`); + } + } catch (error) { + loadingScreen?.close(); + throw error; + } + }); + +const docCmd = program.command("doc"); + +docCmd + .command("create <title>") + .option("-p, --path <path>") + .option("-t, --type <type>") + .action(async (title: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const id = await generateNextDocId(core); + const document: DocType = { + id, + title: title as string, + type: (options.type || "other") as DocType["type"], + createdDate: new Date().toISOString().slice(0, 16).replace("T", " "), + rawContent: "", + }; + await core.createDocument(document, undefined, options.path || ""); + console.log(`Created document ${id}`); + }); + +docCmd + .command("list") + .option("--plain", "use plain text output instead of interactive UI") + .action(async (options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const docs = await core.filesystem.listDocuments(); + if (docs.length === 0) { + console.log("No docs found."); + return; + } + + // Plain text output + const isPlainFlag = options.plain || process.argv.includes("--plain"); + if (isPlainFlag) { + for (const d of docs) { + console.log(`${d.id} - ${d.title}`); + } + return; + } + + // Interactive UI + const selected = await genericSelectList("Select a document", docs); + if (selected) { + // Show document details (recursive search) + const files = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: core.filesystem.docsDir })); + const docFile = files.find( + (f) => f.startsWith(`${selected.id} -`) || f.endsWith(`/${selected.id}.md`) || f === `${selected.id}.md`, + ); + if (docFile) { + const filePath = join(core.filesystem.docsDir, docFile); + const content = await Bun.file(filePath).text(); + await scrollableViewer(content); + } + } + }); + +// Document view command +docCmd + .command("view <docId>") + .description("view a document") + .action(async (docId: string) => { + const cwd = process.cwd(); + const core = new Core(cwd); + try { + const content = await core.getDocumentContent(docId); + if (content === null) { + console.error(`Document ${docId} not found.`); + return; + } + await scrollableViewer(content); + } catch { + console.error(`Document ${docId} not found.`); + } + }); + +const decisionCmd = program.command("decision"); + +decisionCmd + .command("create <title>") + .option("-s, --status <status>") + .action(async (title: string, options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const id = await generateNextDecisionId(core); + const decision: Decision = { + id, + title: title as string, + date: new Date().toISOString().slice(0, 16).replace("T", " "), + status: (options.status || "proposed") as Decision["status"], + context: "", + decision: "", + consequences: "", + rawContent: "", + }; + await core.createDecision(decision); + console.log(`Created decision ${id}`); + }); + +// Agents command group +const agentsCmd = program.command("agents"); + +agentsCmd + .description("manage agent instruction files") + .option( + "--update-instructions", + "update agent instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md)", + ) + .action(async (options) => { + if (!options.updateInstructions) { + agentsCmd.help(); + return; + } + try { + const cwd = process.cwd(); + const core = new Core(cwd); + + // Check if backlog project is initialized + const config = await core.filesystem.loadConfig(); + if (!config) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + const _agentOptions = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".github/copilot-instructions.md"] as const; + + const { files: selected } = await prompts({ + type: "multiselect", + name: "files", + message: "Select agent instruction files to update", + choices: [ + { title: "CLAUDE.md (Claude Code)", value: "CLAUDE.md" }, + { title: "AGENTS.md (Codex, Jules, Amp, Cursor, Zed, Warp, Aider, GitHub, RooCode)", value: "AGENTS.md" }, + { title: "GEMINI.md (Google CLI)", value: "GEMINI.md" }, + { title: "Copilot (GitHub Copilot)", value: ".github/copilot-instructions.md" }, + ], + hint: "Space to select, Enter to confirm\n", + instructions: false, + }); + + const files: AgentInstructionFile[] = (selected ?? []) as AgentInstructionFile[]; + + if (files.length > 0) { + // Get autoCommit setting from config + const config = await core.filesystem.loadConfig(); + const shouldAutoCommit = config?.autoCommit ?? false; + await addAgentInstructions(cwd, core.gitOps, files, shouldAutoCommit); + console.log(`Updated ${files.length} agent instruction file(s): ${files.join(", ")}`); + } else { + console.log("No files selected for update."); + } + } catch (err) { + console.error("Failed to update agent instructions", err); + process.exitCode = 1; + } + }); + +// Config command group +const configCmd = program + .command("config") + .description("manage backlog configuration") + .action(async () => { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + const existingConfig = await core.filesystem.loadConfig(); + + if (!existingConfig) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + const { + mergedConfig, + installClaudeAgent: shouldInstallClaude, + installShellCompletions: shouldInstallCompletions, + } = await configureAdvancedSettings(core); + + let completionResult: CompletionInstallResult | null = null; + let completionError: string | null = null; + if (shouldInstallCompletions) { + try { + completionResult = await installCompletion(); + } catch (error) { + completionError = error instanceof Error ? error.message : String(error); + } + } + + console.log("\nAdvanced configuration updated."); + console.log(` Check active branches: ${mergedConfig.checkActiveBranches ?? true}`); + console.log(` Remote operations: ${mergedConfig.remoteOperations ?? true}`); + console.log( + ` Zero-padded IDs: ${ + typeof mergedConfig.zeroPaddedIds === "number" ? `${mergedConfig.zeroPaddedIds} digits` : "disabled" + }`, + ); + console.log(` Web UI port: ${mergedConfig.defaultPort ?? 6420}`); + console.log(` Auto open browser: ${mergedConfig.autoOpenBrowser ?? true}`); + console.log(` Bypass git hooks: ${mergedConfig.bypassGitHooks ?? false}`); + console.log(` Auto commit: ${mergedConfig.autoCommit ?? false}`); + if (completionResult) { + console.log(` Shell completions: installed to ${completionResult.installPath}`); + } else if (completionError) { + console.log(" Shell completions: installation failed (see warning below)"); + } else { + console.log(" Shell completions: skipped"); + } + if (mergedConfig.defaultEditor) { + console.log(` Default editor: ${mergedConfig.defaultEditor}`); + } + if (shouldInstallClaude) { + await installClaudeAgent(cwd); + console.log("βœ“ Claude Code Backlog.md agent installed to .claude/agents/"); + } + if (completionResult) { + const instructions = completionResult.instructions.trim(); + console.log( + [ + "", + `Shell completion script installed for ${completionResult.shell}.`, + ` Path: ${completionResult.installPath}`, + instructions, + "", + ].join("\n"), + ); + } else if (completionError) { + const indentedError = completionError + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + console.warn( + `⚠️ Shell completion installation failed:\n${indentedError}\n Run \`backlog completion install\` later to retry.\n`, + ); + } + console.log("\nUse `backlog config list` to review all configuration values."); + } catch (err) { + console.error("Failed to update configuration", err); + process.exitCode = 1; + } + }); + +// Sequences command group +const sequenceCmd = program.command("sequence"); + +sequenceCmd + .description("list and inspect execution sequences computed from task dependencies") + .command("list") + .description("list sequences (interactive by default; use --plain for text output)") + .option("--plain", "use plain text output instead of interactive UI") + .action(async (options) => { + const cwd = process.cwd(); + const core = new Core(cwd); + const tasks = await core.queryTasks(); + // Exclude tasks marked as Done from sequences (case-insensitive) + const activeTasks = tasks.filter((t) => (t.status || "").toLowerCase() !== "done"); + const { unsequenced, sequences } = computeSequences(activeTasks); + + // Workaround for bun compile issue with commander options + const isPlainFlag = options.plain || process.argv.includes("--plain"); + if (isPlainFlag) { + if (unsequenced.length > 0) { + console.log("Unsequenced:"); + for (const t of unsequenced) { + console.log(` ${t.id} - ${t.title}`); + } + console.log(""); + } + for (const seq of sequences) { + console.log(`Sequence ${seq.index}:`); + for (const t of seq.tasks) { + console.log(` ${t.id} - ${t.title}`); + } + console.log(""); + } + return; + } + + // Interactive default: TUI view (215.01 + 215.02 navigation/detail) + const { runSequencesView } = await import("./ui/sequences.ts"); + await runSequencesView({ unsequenced, sequences }, core); + }); + +configCmd + .command("get <key>") + .description("get a configuration value") + .action(async (key: string) => { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + + if (!config) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + // Handle specific config keys + switch (key) { + case "defaultEditor": + if (config.defaultEditor) { + console.log(config.defaultEditor); + } else { + console.log("defaultEditor is not set"); + process.exit(1); + } + break; + case "projectName": + console.log(config.projectName); + break; + case "defaultStatus": + console.log(config.defaultStatus || ""); + break; + case "statuses": + console.log(config.statuses.join(", ")); + break; + case "labels": + console.log(config.labels.join(", ")); + break; + case "milestones": + console.log(config.milestones.join(", ")); + break; + case "dateFormat": + console.log(config.dateFormat); + break; + case "maxColumnWidth": + console.log(config.maxColumnWidth?.toString() || ""); + break; + case "defaultPort": + console.log(config.defaultPort?.toString() || ""); + break; + case "autoOpenBrowser": + console.log(config.autoOpenBrowser?.toString() || ""); + break; + case "remoteOperations": + console.log(config.remoteOperations?.toString() || ""); + break; + case "autoCommit": + console.log(config.autoCommit?.toString() || ""); + break; + case "bypassGitHooks": + console.log(config.bypassGitHooks?.toString() || ""); + break; + case "zeroPaddedIds": + console.log(config.zeroPaddedIds?.toString() || "(disabled)"); + break; + case "checkActiveBranches": + console.log(config.checkActiveBranches?.toString() || "true"); + break; + case "activeBranchDays": + console.log(config.activeBranchDays?.toString() || "30"); + break; + default: + console.error(`Unknown config key: ${key}`); + console.error( + "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + ); + process.exit(1); + } + } catch (err) { + console.error("Failed to get config value", err); + process.exitCode = 1; + } + }); + +configCmd + .command("set <key> <value>") + .description("set a configuration value") + .action(async (key: string, value: string) => { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + + if (!config) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + // Handle specific config keys + switch (key) { + case "defaultEditor": { + // Validate that the editor command exists + const { isEditorAvailable } = await import("./utils/editor.ts"); + const isAvailable = await isEditorAvailable(value); + if (!isAvailable) { + console.error(`Editor command not found: ${value}`); + console.error("Please ensure the editor is installed and available in your PATH"); + process.exit(1); + } + config.defaultEditor = value; + break; + } + case "projectName": + config.projectName = value; + break; + case "defaultStatus": + config.defaultStatus = value; + break; + case "dateFormat": + config.dateFormat = value; + break; + case "maxColumnWidth": { + const width = Number.parseInt(value, 10); + if (Number.isNaN(width) || width <= 0) { + console.error("maxColumnWidth must be a positive number"); + process.exit(1); + } + config.maxColumnWidth = width; + break; + } + case "autoOpenBrowser": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.autoOpenBrowser = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.autoOpenBrowser = false; + } else { + console.error("autoOpenBrowser must be true or false"); + process.exit(1); + } + break; + } + case "defaultPort": { + const port = Number.parseInt(value, 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + console.error("defaultPort must be a valid port number (1-65535)"); + process.exit(1); + } + config.defaultPort = port; + break; + } + case "remoteOperations": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.remoteOperations = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.remoteOperations = false; + } else { + console.error("remoteOperations must be true or false"); + process.exit(1); + } + break; + } + case "autoCommit": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.autoCommit = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.autoCommit = false; + } else { + console.error("autoCommit must be true or false"); + process.exit(1); + } + break; + } + case "bypassGitHooks": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.bypassGitHooks = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.bypassGitHooks = false; + } else { + console.error("bypassGitHooks must be true or false"); + process.exit(1); + } + break; + } + case "zeroPaddedIds": { + const padding = Number.parseInt(value, 10); + if (Number.isNaN(padding) || padding < 0) { + console.error("zeroPaddedIds must be a non-negative number."); + process.exit(1); + } + // Set to undefined if 0 to remove it from config + config.zeroPaddedIds = padding > 0 ? padding : undefined; + break; + } + case "checkActiveBranches": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.checkActiveBranches = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.checkActiveBranches = false; + } else { + console.error("checkActiveBranches must be true or false"); + process.exit(1); + } + break; + } + case "activeBranchDays": { + const days = Number.parseInt(value, 10); + if (Number.isNaN(days) || days < 0) { + console.error("activeBranchDays must be a non-negative number."); + process.exit(1); + } + config.activeBranchDays = days; + break; + } + case "statuses": + case "labels": + case "milestones": + console.error(`${key} cannot be set directly. Use 'backlog config list-${key}' to view current values.`); + console.error("Array values should be edited in the config file directly."); + process.exit(1); + break; + default: + console.error(`Unknown config key: ${key}`); + console.error( + "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + ); + process.exit(1); + } + + await core.filesystem.saveConfig(config); + console.log(`Set ${key} = ${value}`); + } catch (err) { + console.error("Failed to set config value", err); + process.exitCode = 1; + } + }); + +configCmd + .command("list") + .description("list all configuration values") + .action(async () => { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + + if (!config) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + console.log("Configuration:"); + console.log(` projectName: ${config.projectName}`); + console.log(` defaultEditor: ${config.defaultEditor || "(not set)"}`); + console.log(` defaultStatus: ${config.defaultStatus || "(not set)"}`); + console.log(` statuses: [${config.statuses.join(", ")}]`); + console.log(` labels: [${config.labels.join(", ")}]`); + console.log(` milestones: [${config.milestones.join(", ")}]`); + console.log(` dateFormat: ${config.dateFormat}`); + console.log(` maxColumnWidth: ${config.maxColumnWidth || "(not set)"}`); + console.log(` autoOpenBrowser: ${config.autoOpenBrowser ?? "(not set)"}`); + console.log(` defaultPort: ${config.defaultPort ?? "(not set)"}`); + console.log(` remoteOperations: ${config.remoteOperations ?? "(not set)"}`); + console.log(` autoCommit: ${config.autoCommit ?? "(not set)"}`); + console.log(` bypassGitHooks: ${config.bypassGitHooks ?? "(not set)"}`); + console.log(` zeroPaddedIds: ${config.zeroPaddedIds ?? "(disabled)"}`); + console.log(` checkActiveBranches: ${config.checkActiveBranches ?? "true"}`); + console.log(` activeBranchDays: ${config.activeBranchDays ?? "30"}`); + } catch (err) { + console.error("Failed to list config values", err); + process.exitCode = 1; + } + }); + +// Cleanup command for managing completed tasks +program + .command("cleanup") + .description("move completed tasks to completed folder based on age") + .action(async () => { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + + // Check if backlog project is initialized + const config = await core.filesystem.loadConfig(); + if (!config) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + // Get all Done tasks + const tasks = await core.queryTasks(); + const doneTasks = tasks.filter((task) => task.status === "Done"); + + if (doneTasks.length === 0) { + console.log("No completed tasks found to clean up."); + return; + } + + console.log(`Found ${doneTasks.length} tasks marked as Done.`); + + const ageOptions = [ + { title: "1 day", value: 1 }, + { title: "1 week", value: 7 }, + { title: "2 weeks", value: 14 }, + { title: "3 weeks", value: 21 }, + { title: "1 month", value: 30 }, + { title: "3 months", value: 90 }, + { title: "1 year", value: 365 }, + ]; + + const { selectedAge } = await prompts({ + type: "select", + name: "selectedAge", + message: "Move tasks to completed folder if they are older than:", + choices: ageOptions, + hint: "Tasks in completed folder are still accessible but won't clutter the main board", + }); + + if (selectedAge === undefined) { + console.log("Cleanup cancelled."); + return; + } + + // Get tasks older than selected period + const tasksToMove = await core.getDoneTasksByAge(selectedAge); + + if (tasksToMove.length === 0) { + console.log(`No tasks found that are older than ${ageOptions.find((o) => o.value === selectedAge)?.title}.`); + return; + } + + console.log( + `\nFound ${tasksToMove.length} tasks older than ${ageOptions.find((o) => o.value === selectedAge)?.title}:`, + ); + for (const task of tasksToMove.slice(0, 5)) { + const date = task.updatedDate || task.createdDate; + console.log(` - ${task.id}: ${task.title} (${date})`); + } + if (tasksToMove.length > 5) { + console.log(` ... and ${tasksToMove.length - 5} more`); + } + + const { confirmed } = await prompts({ + type: "confirm", + name: "confirmed", + message: `Move ${tasksToMove.length} tasks to completed folder?`, + initial: false, + }); + + if (!confirmed) { + console.log("Cleanup cancelled."); + return; + } + + // Move tasks to completed folder + let successCount = 0; + const shouldAutoCommit = config.autoCommit ?? false; + + console.log("Moving tasks..."); + const movedTasks: Array<{ fromPath: string; toPath: string; taskId: string }> = []; + + for (const task of tasksToMove) { + const fromPath = task.filePath ?? (await core.getTask(task.id))?.filePath ?? null; + + if (!fromPath) { + console.error(`Failed to locate file for task ${task.id}`); + continue; + } + + const taskFilename = basename(fromPath); + const toPath = join(core.filesystem.completedDir, taskFilename); + + const success = await core.completeTask(task.id); + if (success) { + successCount++; + movedTasks.push({ fromPath, toPath, taskId: task.id }); + } else { + console.error(`Failed to move task ${task.id}`); + } + } + + // If autoCommit is disabled, stage the moves so Git recognizes them + if (successCount > 0 && !shouldAutoCommit) { + console.log("Staging file moves for Git..."); + for (const { fromPath, toPath } of movedTasks) { + try { + await core.gitOps.stageFileMove(fromPath, toPath); + } catch (error) { + console.warn(`Warning: Could not stage move for Git: ${error}`); + } + } + } + + console.log(`Successfully moved ${successCount} of ${tasksToMove.length} tasks to completed folder.`); + if (successCount > 0 && !shouldAutoCommit) { + console.log("Files have been staged. To commit: git commit -m 'cleanup: Move completed tasks'"); + } + } catch (err) { + console.error("Failed to run cleanup", err); + process.exitCode = 1; + } + }); + +// Browser command for web UI +program + .command("browser") + .description("open browser interface for task management (press Ctrl+C or Cmd+C to stop)") + .option("-p, --port <port>", "port to run server on") + .option("--no-open", "don't automatically open browser") + .action(async (options) => { + try { + const cwd = process.cwd(); + const { BacklogServer } = await import("./server/index.ts"); + const server = new BacklogServer(cwd); + + // Load config to get default port + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + const defaultPort = config?.defaultPort ?? 6420; + + const port = Number.parseInt(options.port || defaultPort.toString(), 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + console.error("Invalid port number. Must be between 1 and 65535."); + process.exit(1); + } + + await server.start(port, options.open !== false); + + // Graceful shutdown on common termination signals (register once) + let shuttingDown = false; + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`\nReceived ${signal}. Shutting down server...`); + try { + const stopPromise = server.stop(); + const timeout = new Promise<void>((resolve) => setTimeout(resolve, 1500)); + await Promise.race([stopPromise, timeout]); + } finally { + process.exit(0); + } + }; + + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + process.once("SIGQUIT", () => void shutdown("SIGQUIT")); + } catch (err) { + console.error("Failed to start browser interface", err); + process.exitCode = 1; + } + }); + +// Overview command for statistics +program + .command("overview") + .description("display project statistics and metrics") + .action(async () => { + try { + const cwd = process.cwd(); + const core = new Core(cwd); + const config = await core.filesystem.loadConfig(); + + if (!config) { + console.error("No backlog project found. Initialize one first with: backlog init"); + process.exit(1); + } + + // Import and run the overview command + const { runOverviewCommand } = await import("./commands/overview.ts"); + await runOverviewCommand(core); + } catch (err) { + console.error("Failed to display project overview", err); + process.exitCode = 1; + } + }); + +// Completion command group +registerCompletionCommand(program); + +// MCP command group +registerMcpCommand(program); + +program.parseAsync(process.argv).finally(() => { + // Restore BUN_OPTIONS after CLI parsing completes so it's available for subsequent commands + if (originalBunOptions) { + process.env.BUN_OPTIONS = originalBunOptions; + } +}); diff --git a/src/commands/advanced-config-wizard.ts b/src/commands/advanced-config-wizard.ts new file mode 100644 index 0000000..380b67d --- /dev/null +++ b/src/commands/advanced-config-wizard.ts @@ -0,0 +1,257 @@ +import prompts from "prompts"; +import type { BacklogConfig } from "../types/index.ts"; +import { isEditorAvailable } from "../utils/editor.ts"; + +export type PromptRunner = (...args: Parameters<typeof prompts>) => ReturnType<typeof prompts>; + +interface WizardOptions { + existingConfig?: BacklogConfig | null; + cancelMessage: string; + includeClaudePrompt?: boolean; + promptImpl?: PromptRunner; +} + +export interface AdvancedConfigWizardResult { + config: Partial<BacklogConfig>; + installClaudeAgent: boolean; + installShellCompletions: boolean; +} + +function handlePromptCancel(message: string) { + console.log(message); + process.exit(1); +} + +export async function runAdvancedConfigWizard({ + existingConfig, + cancelMessage, + includeClaudePrompt = false, + promptImpl = prompts, +}: WizardOptions): Promise<AdvancedConfigWizardResult> { + const onCancel = () => handlePromptCancel(cancelMessage); + const config = existingConfig ?? null; + + let checkActiveBranches = config?.checkActiveBranches ?? true; + let remoteOperations = config?.remoteOperations ?? true; + let activeBranchDays = config?.activeBranchDays ?? 30; + let bypassGitHooks = config?.bypassGitHooks ?? false; + let autoCommit = config?.autoCommit ?? false; + let zeroPaddedIds = config?.zeroPaddedIds; + let defaultEditor = config?.defaultEditor; + let defaultPort = config?.defaultPort ?? 6420; + let autoOpenBrowser = config?.autoOpenBrowser ?? true; + let installClaudeAgent = false; + let installShellCompletions = false; + + const completionPrompt = await promptImpl( + { + type: "confirm", + name: "installCompletions", + message: "Install shell completions now?", + hint: "Adds TAB completion support for backlog commands in your shell", + initial: true, + }, + { onCancel }, + ); + installShellCompletions = Boolean(completionPrompt?.installCompletions); + + const crossBranchPrompt = await promptImpl( + { + type: "confirm", + name: "checkActiveBranches", + message: "Check task states across active branches?", + hint: "Ensures accurate task tracking across branches (may impact performance on large repos)", + initial: checkActiveBranches, + }, + { onCancel }, + ); + checkActiveBranches = crossBranchPrompt.checkActiveBranches ?? true; + + if (checkActiveBranches) { + const remotePrompt = await promptImpl( + { + type: "confirm", + name: "remoteOperations", + message: "Check task states in remote branches?", + hint: "Required for accessing tasks from feature branches on remote repos", + initial: remoteOperations, + }, + { onCancel }, + ); + remoteOperations = remotePrompt.remoteOperations ?? remoteOperations; + + const daysPrompt = await promptImpl( + { + type: "number", + name: "activeBranchDays", + message: "How many days should a branch be considered active?", + hint: "Lower values improve performance (default: 30 days)", + initial: activeBranchDays, + min: 1, + max: 365, + }, + { onCancel }, + ); + if (typeof daysPrompt.activeBranchDays === "number" && !Number.isNaN(daysPrompt.activeBranchDays)) { + activeBranchDays = daysPrompt.activeBranchDays; + } + } else { + remoteOperations = false; + } + + const gitHooksPrompt = await promptImpl( + { + type: "confirm", + name: "bypassGitHooks", + message: "Bypass git hooks when committing?", + hint: "Use --no-verify flag to skip pre-commit hooks", + initial: bypassGitHooks, + }, + { onCancel }, + ); + bypassGitHooks = gitHooksPrompt.bypassGitHooks ?? bypassGitHooks; + + const autoCommitPrompt = await promptImpl( + { + type: "confirm", + name: "autoCommit", + message: "Enable automatic commits for Backlog operations?", + hint: "Creates commits automatically after CLI changes", + initial: autoCommit, + }, + { onCancel }, + ); + autoCommit = autoCommitPrompt.autoCommit ?? autoCommit; + + const zeroPaddingPrompt = await promptImpl( + { + type: "confirm", + name: "enableZeroPadding", + message: "Enable zero-padded IDs for consistent formatting?", + hint: "Example: task-001, doc-001 instead of task-1, doc-1", + initial: (zeroPaddedIds ?? 0) > 0, + }, + { onCancel }, + ); + + if (zeroPaddingPrompt.enableZeroPadding) { + const paddingPrompt = await promptImpl( + { + type: "number", + name: "paddingWidth", + message: "Number of digits for zero-padding:", + hint: "e.g., 3 creates task-001; 4 creates task-0001", + initial: zeroPaddedIds ?? 3, + min: 1, + max: 10, + }, + { onCancel }, + ); + if (typeof paddingPrompt?.paddingWidth === "number" && !Number.isNaN(paddingPrompt.paddingWidth)) { + zeroPaddedIds = paddingPrompt.paddingWidth; + } + } else { + zeroPaddedIds = undefined; + } + + const editorPrompt = await promptImpl( + { + type: "text", + name: "editor", + message: "Default editor command (leave blank to use system default):", + hint: "e.g., 'code --wait', 'vim', 'nano'", + initial: defaultEditor ?? "", + }, + { onCancel }, + ); + + let editorResult = String(editorPrompt.editor ?? "").trim(); + if (editorResult.length > 0) { + const isAvailable = await isEditorAvailable(editorResult); + if (!isAvailable) { + console.warn(`Warning: Editor command '${editorResult}' not found in PATH`); + const confirmAnyway = await promptImpl( + { + type: "confirm", + name: "confirm", + message: "Editor not found. Set it anyway?", + initial: false, + }, + { onCancel }, + ); + if (!confirmAnyway?.confirm) { + editorResult = ""; + } + } + } + defaultEditor = editorResult.length > 0 ? editorResult : undefined; + + const webUIPrompt = await promptImpl( + { + type: "confirm", + name: "configureWebUI", + message: "Configure web UI settings now?", + hint: "Port and browser auto-open", + initial: false, + }, + { onCancel }, + ); + + if (webUIPrompt.configureWebUI) { + const webUIValues = await promptImpl( + [ + { + type: "number", + name: "defaultPort", + message: "Default web UI port:", + hint: "Port number for the web interface (1-65535)", + initial: defaultPort, + min: 1, + max: 65535, + }, + { + type: "confirm", + name: "autoOpenBrowser", + message: "Automatically open browser when starting web UI?", + hint: "When enabled, 'backlog web' opens your browser", + initial: autoOpenBrowser, + }, + ], + { onCancel }, + ); + if (typeof webUIValues?.defaultPort === "number" && !Number.isNaN(webUIValues.defaultPort)) { + defaultPort = webUIValues.defaultPort; + } + autoOpenBrowser = Boolean(webUIValues?.autoOpenBrowser ?? autoOpenBrowser); + } + + if (includeClaudePrompt) { + const claudePrompt = await promptImpl( + { + type: "confirm", + name: "installClaudeAgent", + message: "Install Claude Code Backlog.md agent?", + hint: "Adds configuration under .claude/agents/", + initial: false, + }, + { onCancel }, + ); + installClaudeAgent = Boolean(claudePrompt?.installClaudeAgent); + } + + return { + config: { + checkActiveBranches, + remoteOperations, + activeBranchDays, + bypassGitHooks, + autoCommit, + zeroPaddedIds, + defaultEditor, + defaultPort, + autoOpenBrowser, + }, + installClaudeAgent, + installShellCompletions, + }; +} diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..5fa3243 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,372 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Command } from "commander"; +import { getCompletions } from "../completions/helper.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export type Shell = "bash" | "zsh" | "fish"; + +export interface CompletionInstallResult { + shell: Shell; + installPath: string; + instructions: string; +} + +/** + * Detect the user's current shell + */ +function detectShell(): Shell | null { + const shell = process.env.SHELL || ""; + + if (shell.includes("bash")) { + return "bash"; + } + if (shell.includes("zsh")) { + return "zsh"; + } + if (shell.includes("fish")) { + return "fish"; + } + + return null; +} + +/** + * Get the completion script content for a shell + */ +async function getCompletionScript(shell: Shell): Promise<string> { + // Try to read from file system first (for development) + const scriptFiles: Record<Shell, string> = { + bash: "backlog.bash", + zsh: "_backlog", + fish: "backlog.fish", + }; + + const scriptPath = join(__dirname, "..", "..", "completions", scriptFiles[shell]); + + try { + if (existsSync(scriptPath)) { + return await readFile(scriptPath, "utf-8"); + } + } catch { + // Fall through to embedded scripts + } + + // Fallback to embedded scripts (for compiled binary) + return getEmbeddedCompletionScript(shell); +} + +/** + * Get embedded completion script (used when files aren't available) + */ +function getEmbeddedCompletionScript(shell: Shell): string { + const scripts: Record<Shell, string> = { + bash: `#!/usr/bin/env bash +# Bash completion script for backlog CLI +# +# Installation: +# - Copy to /etc/bash_completion.d/backlog +# - Or source directly in ~/.bashrc: source /path/to/backlog.bash +# +# Requirements: +# - Bash 4.x or 5.x +# - bash-completion package (optional but recommended) + +# Main completion function for backlog CLI +_backlog() { + # Initialize completion variables using bash-completion helper if available + # Falls back to manual initialization if bash-completion is not installed + local cur prev words cword + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion || return + else + # Manual initialization fallback + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=("\${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi + + # Get the full command line and cursor position + local line="\${COMP_LINE}" + local point="\${COMP_POINT}" + + # Call the CLI's internal completion command + # This delegates all completion logic to the TypeScript implementation + # Output format: one completion per line + local completions + completions=$(backlog completion __complete "$line" "$point" 2>/dev/null) + + # Check if the completion command failed + if [[ $? -ne 0 ]]; then + # Silent failure - completion should never break the shell + return 0 + fi + + # Generate completion replies using compgen + # -W: wordlist - splits completions by whitespace + # --: end of options + # "$cur": current word being completed + COMPREPLY=( $(compgen -W "$completions" -- "$cur") ) + + # Return success + return 0 +} + +# Register the completion function for the 'backlog' command +# -F: use function for completion +# _backlog: name of the completion function +# backlog: command to complete +complete -F _backlog backlog +`, + zsh: `#compdef backlog + +# Zsh completion script for backlog CLI +# +# Installation: +# 1. Copy this file to a directory in your $fpath +# 2. Run: compinit +# +# Or use: backlog completion install --shell zsh + +_backlog() { + # Get the current command line buffer and cursor position + local line=$BUFFER + local point=$CURSOR + + # Call the backlog completion command to get dynamic completions + # The __complete command returns one completion per line + local -a completions + completions=(\${(f)"$(backlog completion __complete "$line" "$point" 2>/dev/null)"}) + + # Check if we got any completions + if (( \${#completions[@]} == 0 )); then + # No completions available + return 1 + fi + + # Present the completions to the user + # _describe shows completions with optional descriptions + # The first argument is the tag name shown in completion groups + _describe 'backlog commands' completions +} + +# Register the completion function for the backlog command +compdef _backlog backlog +`, + fish: `#!/usr/bin/env fish +# Fish completion script for backlog CLI +# +# Installation: +# - Copy to ~/.config/fish/completions/backlog.fish +# - Or use: backlog completion install --shell fish +# +# Requirements: +# - Fish 3.x or later + +# Helper function to get completions from the CLI +# This delegates all completion logic to the TypeScript implementation +function __backlog_complete + # Get the current command line and cursor position + # -cp: get the command line with cursor position preserved + set -l line (commandline -cp) + + # Calculate the cursor position (length of the line up to cursor) + # Fish tracks cursor position differently than bash/zsh + set -l point (string length "$line") + + # Call the CLI's internal completion command + # Output format: one completion per line + # Redirect stderr to /dev/null to suppress error messages + backlog completion __complete "$line" "$point" 2>/dev/null + + # Fish will automatically handle the exit status + # If the command fails, no completions will be shown +end + +# Register completion for the 'backlog' command +# -c: specify the command to complete +# -f: disable file completion (we handle all completions dynamically) +# -a: add completion candidates from the function output +complete -c backlog -f -a '(__backlog_complete)' +`, + }; + + return scripts[shell]; +} + +/** + * Get installation paths for a shell + */ +function getInstallPaths(shell: Shell): { system: string; user: string } { + const home = homedir(); + + const paths: Record<Shell, { system: string; user: string }> = { + bash: { + system: "/etc/bash_completion.d/backlog", + user: join(home, ".local/share/bash-completion/completions/backlog"), + }, + zsh: { + system: "/usr/local/share/zsh/site-functions/_backlog", + user: join(home, ".zsh/completions/_backlog"), + }, + fish: { + system: "/usr/share/fish/vendor_completions.d/backlog.fish", + user: join(home, ".config/fish/completions/backlog.fish"), + }, + }; + + return paths[shell]; +} + +/** + * Get instructions for enabling completions after installation + */ +function getEnableInstructions(shell: Shell, installPath: string): string { + const instructions: Record<Shell, string> = { + bash: ` +To enable completions, add this to your ~/.bashrc: +source ${installPath} + +Then restart your shell or run: +source ~/.bashrc +`, + zsh: ` +To enable completions, ensure the directory is in your fpath. +Add this to your ~/.zshrc: +fpath=(${dirname(installPath)} $fpath) +autoload -Uz compinit && compinit + +Then restart your shell or run: +source ~/.zshrc +`, + fish: ` +Completions should be automatically loaded by fish. +Restart your shell or run: +exec fish +`, + }; + + return instructions[shell]; +} + +/** + * Install completion script + */ +export async function installCompletion(shell?: string): Promise<CompletionInstallResult> { + // Detect shell if not provided + const targetShell = shell as Shell | undefined; + const detectedShell = targetShell || detectShell(); + + if (!detectedShell) { + const message = [ + "Could not detect your shell.", + "Please specify it manually:", + " backlog completion install --shell bash", + " backlog completion install --shell zsh", + " backlog completion install --shell fish", + ].join("\n"); + throw new Error(message); + } + + if (!["bash", "zsh", "fish"].includes(detectedShell)) { + throw new Error(`Unsupported shell: ${detectedShell}\nSupported shells: bash, zsh, fish`); + } + + // Get completion script content + let scriptContent: string; + try { + scriptContent = await getCompletionScript(detectedShell); + } catch (error) { + throw new Error(error instanceof Error ? error.message : String(error)); + } + + // Get installation paths + const paths = getInstallPaths(detectedShell); + + // Try user installation first (no sudo required) + const installPath = paths.user; + const installDir = dirname(installPath); + + try { + // Create directory if it doesn't exist + if (!existsSync(installDir)) { + await mkdir(installDir, { recursive: true }); + } + + // Write the completion script + await writeFile(installPath, scriptContent, "utf-8"); + } catch (error) { + const manualInstructions = [ + "Failed to install completion script automatically.", + "", + "Manual installation options:", + "1. System-wide installation (requires sudo):", + ` sudo cp completions/${detectedShell === "zsh" ? "_backlog" : `backlog.${detectedShell}`} ${paths.system}`, + "", + "2. User installation:", + ` mkdir -p ${installDir}`, + ` cp completions/${detectedShell === "zsh" ? "_backlog" : `backlog.${detectedShell}`} ${installPath}`, + ].join("\n"); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`${errorMessage}\n\n${manualInstructions}`); + } + + return { + shell: detectedShell, + installPath, + instructions: getEnableInstructions(detectedShell, installPath), + }; +} + +/** + * Register the completion command and subcommands + */ +export function registerCompletionCommand(program: Command): void { + const completionCmd = program.command("completion").description("manage shell completion scripts"); + + // Hidden command used by shell completion scripts + completionCmd + .command("__complete <line> <point>") + .description("internal command for shell completion (do not call directly)") + .action(async (line: string, point: string) => { + try { + const pointNum = Number.parseInt(point, 10); + if (Number.isNaN(pointNum)) { + process.exit(1); + } + + const completions = await getCompletions(program, line, pointNum); + for (const completion of completions) { + console.log(completion); + } + process.exit(0); + } catch (_error) { + // Silent failure - completion should never break the shell + process.exit(1); + } + }); + + // Installation command + completionCmd + .command("install") + .description("install shell completion script") + .option("--shell <shell>", "shell type (bash, zsh, fish)") + .action(async (options: { shell?: string }) => { + try { + const result = await installCompletion(options.shell); + console.log(`πŸ“¦ Installed ${result.shell} completion for backlog CLI.`); + console.log(`βœ… Completion script written to ${result.installPath}`); + console.log(result.instructions.trimEnd()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`❌ ${message}`); + process.exit(1); + } + }); +} diff --git a/src/commands/configure-advanced-settings.ts b/src/commands/configure-advanced-settings.ts new file mode 100644 index 0000000..e13303b --- /dev/null +++ b/src/commands/configure-advanced-settings.ts @@ -0,0 +1,34 @@ +import type { Core } from "../core/backlog.ts"; +import type { BacklogConfig } from "../types/index.ts"; +import { type PromptRunner, runAdvancedConfigWizard } from "./advanced-config-wizard.ts"; + +interface ConfigureAdvancedOptions { + promptImpl?: PromptRunner; + cancelMessage?: string; +} + +export async function configureAdvancedSettings( + core: Core, + { promptImpl, cancelMessage = "Aborting configuration." }: ConfigureAdvancedOptions = {}, +): Promise<{ mergedConfig: BacklogConfig; installClaudeAgent: boolean; installShellCompletions: boolean }> { + const existingConfig = await core.filesystem.loadConfig(); + if (!existingConfig) { + throw new Error("No backlog project found. Initialize one first with: backlog init"); + } + + const wizardResult = await runAdvancedConfigWizard({ + existingConfig, + cancelMessage, + includeClaudePrompt: true, + promptImpl, + }); + + const mergedConfig: BacklogConfig = { ...existingConfig, ...wizardResult.config }; + await core.filesystem.saveConfig(mergedConfig); + + return { + mergedConfig, + installClaudeAgent: wizardResult.installClaudeAgent, + installShellCompletions: wizardResult.installShellCompletions, + }; +} diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..7664cde --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,66 @@ +/** + * MCP Command Group - Model Context Protocol CLI commands. + * + * This simplified command set focuses on the stdio transport, which is the + * only supported transport for Backlog.md's local MCP integration. + */ + +import type { Command } from "commander"; +import { createMcpServer } from "../mcp/server.ts"; + +type StartOptions = { + debug?: boolean; +}; + +/** + * Register MCP command group with CLI program. + * + * @param program - Commander program instance + */ +export function registerMcpCommand(program: Command): void { + const mcpCmd = program.command("mcp"); + registerStartCommand(mcpCmd); +} + +/** + * Register 'mcp start' command for stdio transport. + */ +function registerStartCommand(mcpCmd: Command): void { + mcpCmd + .command("start") + .description("Start the MCP server using stdio transport") + .option("-d, --debug", "Enable debug logging", false) + .action(async (options: StartOptions) => { + try { + const server = await createMcpServer(process.cwd(), { debug: options.debug }); + + await server.connect(); + await server.start(); + + if (options.debug) { + console.error("Backlog.md MCP server started (stdio transport)"); + } + + const shutdown = async (signal: string) => { + if (options.debug) { + console.error(`Received ${signal}, shutting down MCP server...`); + } + + try { + await server.stop(); + process.exit(0); + } catch (error) { + console.error("Error during MCP server shutdown:", error); + process.exit(1); + } + }; + + process.once("SIGINT", () => shutdown("SIGINT")); + process.once("SIGTERM", () => shutdown("SIGTERM")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to start MCP server: ${message}`); + process.exit(1); + } + }); +} diff --git a/src/commands/overview.ts b/src/commands/overview.ts new file mode 100644 index 0000000..9d5cb6b --- /dev/null +++ b/src/commands/overview.ts @@ -0,0 +1,45 @@ +import type { Core } from "../core/backlog.ts"; +import { getTaskStatistics } from "../core/statistics.ts"; +import { createLoadingScreen } from "../ui/loading.ts"; +import { renderOverviewTui } from "../ui/overview-tui.ts"; + +function formatTime(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export async function runOverviewCommand(core: Core): Promise<void> { + const startTime = performance.now(); + + // Load tasks with loading screen + const loadingScreen = await createLoadingScreen("Loading project statistics"); + + try { + // Use the shared task loading logic + const loadStart = performance.now(); + const { + tasks: activeTasks, + drafts, + statuses, + } = await core.loadAllTasksForStatistics((msg) => + loadingScreen?.update(`${msg} in ${formatTime(performance.now() - loadStart)}`), + ); + + loadingScreen?.close(); + + // Calculate statistics + const statsStart = performance.now(); + const statistics = getTaskStatistics(activeTasks, drafts, statuses); + const statsTime = Math.round(performance.now() - statsStart); + + // Display the TUI + const totalTime = Math.round(performance.now() - startTime); + console.log(`\nPerformance summary: Total time ${totalTime}ms (stats calculation: ${statsTime}ms)`); + + const config = await core.fs.loadConfig(); + await renderOverviewTui(statistics, config?.projectName || "Project"); + } catch (error) { + loadingScreen?.close(); + throw error; + } +} diff --git a/src/completions/command-structure.ts b/src/completions/command-structure.ts new file mode 100644 index 0000000..75ce1e2 --- /dev/null +++ b/src/completions/command-structure.ts @@ -0,0 +1,185 @@ +import type { Argument, Command, Option } from "commander"; + +export interface CommandInfo { + name: string; + aliases: string[]; + arguments: ArgumentInfo[]; + subcommands: CommandInfo[]; + options: OptionInfo[]; +} + +export interface ArgumentInfo { + name: string; + required: boolean; + variadic: boolean; +} + +export interface OptionInfo { + flags: string; + long?: string; + short?: string; + description: string; +} + +/** + * Extract command structure from a Commander.js program + */ +export function extractCommandStructure(program: Command): CommandInfo { + return { + name: program.name(), + aliases: program.aliases(), + arguments: extractArguments(program), + subcommands: program.commands.map((cmd) => extractCommandInfo(cmd)), + options: program.options.map((opt) => extractOptionInfo(opt)), + }; +} + +/** + * Extract info from a single command + */ +function extractCommandInfo(command: Command): CommandInfo { + return { + name: command.name(), + aliases: command.aliases(), + arguments: extractArguments(command), + subcommands: command.commands.map((cmd) => extractCommandInfo(cmd)), + options: command.options.map((opt) => extractOptionInfo(opt)), + }; +} + +/** + * Extract arguments from a command + */ +function extractArguments(command: Command): ArgumentInfo[] { + // Commander.js v14 has registeredArguments or processedArgs + type CommandWithArgs = Command & { + registeredArguments?: Argument[]; + args?: Argument[]; + }; + + const commandWithArgs = command as CommandWithArgs; + const args = commandWithArgs.registeredArguments || commandWithArgs.args || []; + + return args.map((arg: Argument) => ({ + name: arg.name(), + required: arg.required, + variadic: arg.variadic, + })); +} + +/** + * Extract info from an option + */ +function extractOptionInfo(option: Option): OptionInfo { + return { + flags: option.flags, + long: option.long, + short: option.short, + description: option.description || "", + }; +} + +/** + * Find a command by name (including aliases) + */ +export function findCommand(info: CommandInfo, commandName: string): CommandInfo | null { + return info.subcommands.find((cmd) => cmd.name === commandName || cmd.aliases.includes(commandName)) || null; +} + +/** + * Find a subcommand within a command + */ +export function findSubcommand(info: CommandInfo, commandName: string, subcommandName: string): CommandInfo | null { + const command = findCommand(info, commandName); + if (!command) { + return null; + } + + return command.subcommands.find((sub) => sub.name === subcommandName || sub.aliases.includes(subcommandName)) || null; +} + +/** + * Get all top-level command names (including aliases) + */ +export function getTopLevelCommands(info: CommandInfo): string[] { + const names: string[] = []; + for (const cmd of info.subcommands) { + names.push(cmd.name, ...cmd.aliases); + } + return names; +} + +/** + * Get all subcommand names for a command (including aliases) + */ +export function getSubcommandNames(info: CommandInfo, commandName: string): string[] { + const command = findCommand(info, commandName); + if (!command) { + return []; + } + + const names: string[] = []; + for (const sub of command.subcommands) { + names.push(sub.name, ...sub.aliases); + } + return names; +} + +/** + * Get all option flags for a specific command/subcommand + */ +export function getOptionFlags(info: CommandInfo, commandName?: string, subcommandName?: string): string[] { + let targetCommand = info; + + if (commandName) { + const cmd = findCommand(info, commandName); + if (!cmd) { + return []; + } + targetCommand = cmd; + } + + if (subcommandName) { + const sub = findCommand(targetCommand, subcommandName); + if (!sub) { + return []; + } + targetCommand = sub; + } + + const flags: string[] = []; + for (const opt of targetCommand.options) { + if (opt.long) { + flags.push(opt.long); + } + if (opt.short) { + flags.push(opt.short); + } + } + return flags; +} + +/** + * Get expected arguments for a command/subcommand + */ +export function getExpectedArguments(info: CommandInfo, commandName?: string, subcommandName?: string): ArgumentInfo[] { + let targetCommand = info; + + if (commandName) { + const cmd = findCommand(info, commandName); + if (!cmd) { + return []; + } + targetCommand = cmd; + } + + if (subcommandName) { + const sub = findCommand(targetCommand, subcommandName); + if (!sub) { + return []; + } + targetCommand = sub; + } + + return targetCommand.arguments; +} diff --git a/src/completions/data-providers.ts b/src/completions/data-providers.ts new file mode 100644 index 0000000..7b1e35e --- /dev/null +++ b/src/completions/data-providers.ts @@ -0,0 +1,100 @@ +import { Core } from "../index.ts"; +import type { BacklogConfig } from "../types/index.ts"; + +type CoreCallback<T> = (core: Core) => Promise<T>; + +/** + * Create a Core instance bound to the current working directory. + */ +function createCore(): Core { + return new Core(process.cwd()); +} + +/** + * Execute a callback with a Core instance, returning a fallback value if anything fails. + */ +async function withCore<T>(callback: CoreCallback<T>, fallback: T): Promise<T> { + try { + const core = createCore(); + return await callback(core); + } catch { + return fallback; + } +} + +function getDefaultStatuses(): string[] { + return ["To Do", "In Progress", "Done"]; +} + +/** + * Get all task IDs from the backlog + */ +export async function getTaskIds(): Promise<string[]> { + return await withCore(async (core) => { + const tasks = await core.filesystem.listTasks(); + return tasks.map((task) => task.id).sort(); + }, []); +} + +/** + * Get configured status values + */ +export async function getStatuses(): Promise<string[]> { + return await withCore(async (core) => { + const config: BacklogConfig | null = await core.filesystem.loadConfig(); + const statuses = config?.statuses; + if (Array.isArray(statuses) && statuses.length > 0) { + return statuses; + } + return getDefaultStatuses(); + }, getDefaultStatuses()); +} + +/** + * Get priority values + */ +export function getPriorities(): string[] { + return ["high", "medium", "low"]; +} + +/** + * Get unique labels from all tasks + */ +export async function getLabels(): Promise<string[]> { + return await withCore(async (core) => { + const tasks = await core.filesystem.listTasks(); + const labels = new Set<string>(); + for (const task of tasks) { + for (const label of task.labels) { + labels.add(label); + } + } + return Array.from(labels).sort(); + }, []); +} + +/** + * Get unique assignees from all tasks + */ +export async function getAssignees(): Promise<string[]> { + return await withCore(async (core) => { + const tasks = await core.filesystem.listTasks(); + const assignees = new Set<string>(); + for (const task of tasks) { + for (const assignee of task.assignee) { + assignees.add(assignee); + } + } + return Array.from(assignees).sort(); + }, []); +} + +/** + * Get all document IDs from the backlog + */ +export async function getDocumentIds(): Promise<string[]> { + return await withCore(async (core) => { + const docs = await core.filesystem.listDocuments(); + return docs.map((doc) => doc.id).sort(); + }, []); +} diff --git a/src/completions/helper.test.ts b/src/completions/helper.test.ts new file mode 100644 index 0000000..7882f62 --- /dev/null +++ b/src/completions/helper.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { parseCompletionContext } from "./helper.ts"; + +describe("parseCompletionContext", () => { + test("parses empty command line", () => { + const context = parseCompletionContext("backlog ", 8); + expect(context.command).toBeNull(); + expect(context.subcommand).toBeNull(); + expect(context.partial).toBe(""); + expect(context.lastFlag).toBeNull(); + }); + + test("parses partial command", () => { + const context = parseCompletionContext("backlog tas", 11); + expect(context.command).toBeNull(); + expect(context.partial).toBe("tas"); + }); + + test("parses complete command", () => { + const context = parseCompletionContext("backlog task ", 13); + expect(context.command).toBe("task"); + expect(context.subcommand).toBeNull(); + expect(context.partial).toBe(""); + }); + + test("parses partial subcommand", () => { + const context = parseCompletionContext("backlog task ed", 15); + expect(context.command).toBe("task"); + expect(context.subcommand).toBeNull(); + expect(context.partial).toBe("ed"); + }); + + test("parses complete subcommand", () => { + const context = parseCompletionContext("backlog task edit ", 18); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("edit"); + expect(context.partial).toBe(""); + }); + + test("parses partial argument", () => { + const context = parseCompletionContext("backlog task edit task-", 23); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("edit"); + expect(context.partial).toBe("task-"); + }); + + test("parses flag", () => { + const context = parseCompletionContext("backlog task create --status ", 29); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("create"); + expect(context.lastFlag).toBe("--status"); + expect(context.partial).toBe(""); + }); + + test("parses partial flag value", () => { + const context = parseCompletionContext("backlog task create --status In", 31); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("create"); + expect(context.lastFlag).toBe("--status"); + expect(context.partial).toBe("In"); + }); + + test("handles quoted strings", () => { + const context = parseCompletionContext('backlog task create "test task" --status ', 41); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("create"); + expect(context.lastFlag).toBe("--status"); + expect(context.partial).toBe(""); + }); + + test("handles multiple flags", () => { + const context = parseCompletionContext("backlog task create --priority high --status ", 46); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("create"); + expect(context.lastFlag).toBe("--status"); + expect(context.partial).toBe(""); + }); + + test("parses completion subcommand", () => { + const context = parseCompletionContext("backlog completion install ", 27); + expect(context.command).toBe("completion"); + expect(context.subcommand).toBe("install"); + expect(context.partial).toBe(""); + }); + + test("handles cursor in middle of line", () => { + // Cursor at position 13 is after "backlog task " (space included) + const context = parseCompletionContext("backlog task edit", 13); + expect(context.command).toBe("task"); + expect(context.subcommand).toBeNull(); + expect(context.partial).toBe(""); + }); + + test("counts argument position correctly", () => { + const context = parseCompletionContext("backlog task edit task-1 ", 25); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("edit"); + expect(context.argPosition).toBe(1); + }); + + test("does not count flag values as arguments", () => { + const context = parseCompletionContext("backlog task create --status Done ", 34); + expect(context.command).toBe("task"); + expect(context.subcommand).toBe("create"); + expect(context.argPosition).toBe(0); + }); +}); diff --git a/src/completions/helper.ts b/src/completions/helper.ts new file mode 100644 index 0000000..c7c5f6f --- /dev/null +++ b/src/completions/helper.ts @@ -0,0 +1,172 @@ +import type { Command } from "commander"; +import { + extractCommandStructure, + getExpectedArguments, + getOptionFlags, + getSubcommandNames, + getTopLevelCommands, +} from "./command-structure.ts"; +import { getAssignees, getDocumentIds, getLabels, getPriorities, getStatuses, getTaskIds } from "./data-providers.ts"; + +export interface CompletionContext { + words: string[]; + partial: string; + command: string | null; + subcommand: string | null; + lastFlag: string | null; + argPosition: number; +} + +/** + * Parse the command line to determine completion context + */ +export function parseCompletionContext(line: string, point: number): CompletionContext { + // Extract the portion up to the cursor + const textBeforeCursor = line.slice(0, point); + + // Split into words, handling quotes + const words = textBeforeCursor.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + + // Remove "backlog" from the start + const cleanWords = words.slice(1); + + // Determine if we're completing a partial word or starting a new one + const endsWithSpace = textBeforeCursor.endsWith(" "); + const partial = endsWithSpace ? "" : cleanWords[cleanWords.length - 1] || ""; + + // Remove partial from words if not completing a new word + const completedWords = endsWithSpace ? cleanWords : cleanWords.slice(0, -1); + + // Identify command, subcommand, last flag, and argument position + let command: string | null = null; + let subcommand: string | null = null; + let lastFlag: string | null = null; + let argPosition = 0; + + for (let i = 0; i < completedWords.length; i++) { + const word = completedWords[i]; + if (!word) { + continue; + } + if (word.startsWith("-")) { + lastFlag = word; + } else if (!command) { + command = word; + } else if (!subcommand) { + subcommand = word; + } else { + // Count positional arguments + const prevWord = completedWords[i - 1]; + if (!prevWord || !prevWord.startsWith("-")) { + argPosition++; + } + } + } + + return { + words: completedWords, + partial, + command, + subcommand, + lastFlag, + argPosition, + }; +} + +/** + * Filter completions by partial match + */ +function filterCompletions(completions: string[], partial: string): string[] { + if (!partial) { + return completions; + } + return completions.filter((c) => c.toLowerCase().startsWith(partial.toLowerCase())); +} + +/** + * Get completions based on argument name pattern + */ +async function getArgumentCompletions(argumentName: string): Promise<string[]> { + const lowerName = argumentName.toLowerCase(); + + // Match common patterns + if (lowerName.includes("taskid") || lowerName === "id") { + return await getTaskIds(); + } + if (lowerName.includes("docid") || lowerName.includes("documentid")) { + return await getDocumentIds(); + } + if (lowerName.includes("title") || lowerName.includes("name")) { + return []; // Free-form text, no completions + } + + return []; +} + +/** + * Get completions for flag values based on flag name + */ +async function getFlagValueCompletions(flagName: string): Promise<string[]> { + const cleanFlag = flagName.replace(/^-+/, ""); + + switch (cleanFlag) { + case "status": + return await getStatuses(); + case "priority": + return getPriorities(); + case "labels": + case "label": + return await getLabels(); + case "assignee": + return await getAssignees(); + case "shell": + return ["bash", "zsh", "fish"]; + default: + return []; + } +} + +/** + * Generate completions based on context + */ +export async function getCompletions(program: Command, line: string, point: number): Promise<string[]> { + const context = parseCompletionContext(line, point); + const cmdInfo = extractCommandStructure(program); + + // If completing a flag value + if (context.lastFlag) { + const flagCompletions = await getFlagValueCompletions(context.lastFlag); + return filterCompletions(flagCompletions, context.partial); + } + + // No command yet - complete top-level commands + if (!context.command) { + return filterCompletions(getTopLevelCommands(cmdInfo), context.partial); + } + + // Command but no subcommand - complete subcommands or flags + if (!context.subcommand) { + const subcommands = getSubcommandNames(cmdInfo, context.command); + const flags = getOptionFlags(cmdInfo, context.command); + return filterCompletions([...subcommands, ...flags], context.partial); + } + + // We have command and subcommand - check what arguments are expected + const expectedArgs = getExpectedArguments(cmdInfo, context.command, context.subcommand); + + // If we're at a position where an argument is expected + if (expectedArgs.length > context.argPosition) { + const expectedArg = expectedArgs[context.argPosition]; + if (expectedArg) { + const argCompletions = await getArgumentCompletions(expectedArg.name); + + // Also include flags + const flags = getOptionFlags(cmdInfo, context.command, context.subcommand); + return filterCompletions([...argCompletions, ...flags], context.partial); + } + } + + // No more positional arguments expected, just show flags + const flags = getOptionFlags(cmdInfo, context.command, context.subcommand); + return filterCompletions(flags, context.partial); +} diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..a737192 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,66 @@ +/** + * Default directory structure for backlog projects + */ +export const DEFAULT_DIRECTORIES = { + /** Main backlog directory */ + BACKLOG: "backlog", + /** Active tasks directory */ + TASKS: "tasks", + /** Draft tasks directory */ + DRAFTS: "drafts", + /** Completed tasks directory */ + COMPLETED: "completed", + /** Archive root directory */ + ARCHIVE: "archive", + /** Archived tasks directory */ + ARCHIVE_TASKS: "archive/tasks", + /** Archived drafts directory */ + ARCHIVE_DRAFTS: "archive/drafts", + /** Documentation directory */ + DOCS: "docs", + /** Decision logs directory */ + DECISIONS: "decisions", +} as const; + +/** + * Default configuration file names + */ +export const DEFAULT_FILES = { + /** Main configuration file */ + CONFIG: "config.yml", + /** Local user settings file */ + USER: ".user", +} as const; + +/** + * Default task statuses + */ +export const DEFAULT_STATUSES = ["To Do", "In Progress", "Done"] as const; + +/** + * Fallback status when no default is configured + */ +export const FALLBACK_STATUS = "To Do"; + +/** + * Maximum width for wrapped text lines in UI components + */ +export const WRAP_LIMIT = 72; + +/** + * Default values for advanced configuration options used during project initialization. + * Shared between CLI and browser wizard to ensure consistent defaults. + */ +export const DEFAULT_INIT_CONFIG = { + checkActiveBranches: true, + remoteOperations: true, + activeBranchDays: 30, + bypassGitHooks: false, + autoCommit: false, + zeroPaddedIds: undefined as number | undefined, + defaultEditor: undefined as string | undefined, + defaultPort: 6420, + autoOpenBrowser: true, +} as const; + +export * from "../guidelines/index.ts"; diff --git a/src/core/backlog.ts b/src/core/backlog.ts new file mode 100644 index 0000000..352d366 --- /dev/null +++ b/src/core/backlog.ts @@ -0,0 +1,1702 @@ +import { join } from "node:path"; +import { DEFAULT_DIRECTORIES, DEFAULT_STATUSES, FALLBACK_STATUS } from "../constants/index.ts"; +import { FileSystem } from "../file-system/operations.ts"; +import { GitOperations } from "../git/operations.ts"; +import type { + AcceptanceCriterion, + BacklogConfig, + Decision, + Document, + SearchFilters, + Sequence, + Task, + TaskCreateInput, + TaskListFilter, + TaskUpdateInput, +} from "../types/index.ts"; +import { isLocalEditableTask } from "../types/index.ts"; +import { normalizeAssignee } from "../utils/assignee.ts"; +import { documentIdsEqual } from "../utils/document-id.ts"; +import { openInEditor } from "../utils/editor.ts"; +import { + getCanonicalStatus as resolveCanonicalStatus, + getValidStatuses as resolveValidStatuses, +} from "../utils/status.ts"; +import { executeStatusCallback } from "../utils/status-callback.ts"; +import { + normalizeDependencies, + normalizeStringList, + stringArraysEqual, + validateDependencies, +} from "../utils/task-builders.ts"; +import { getTaskFilename, getTaskPath, normalizeTaskId, taskIdsEqual } from "../utils/task-path.ts"; +import { migrateConfig, needsMigration } from "./config-migration.ts"; +import { ContentStore } from "./content-store.ts"; +import { filterTasksByLatestState, getLatestTaskStatesForIds } from "./cross-branch-tasks.ts"; +import { calculateNewOrdinal, DEFAULT_ORDINAL_STEP, resolveOrdinalConflicts } from "./reorder.ts"; +import { SearchService } from "./search-service.ts"; +import { computeSequences, planMoveToSequence, planMoveToUnsequenced } from "./sequences.ts"; +import { + findTaskInLocalBranches, + findTaskInRemoteBranches, + getTaskLoadingMessage, + loadLocalBranchTasks, + loadRemoteTasks, + resolveTaskConflict, +} from "./task-loader.ts"; + +interface BlessedScreen { + program: { + disableMouse(): void; + enableMouse(): void; + hideCursor(): void; + showCursor(): void; + input: NodeJS.EventEmitter; + pause?: () => (() => void) | undefined; + }; + leave(): void; + enter(): void; + render(): void; + clearRegion(x1: number, x2: number, y1: number, y2: number): void; + width: number; + height: number; + emit(event: string): void; +} + +interface TaskQueryOptions { + filters?: TaskListFilter; + query?: string; + limit?: number; + includeCrossBranch?: boolean; +} + +export class Core { + public fs: FileSystem; + public git: GitOperations; + private contentStore?: ContentStore; + private searchService?: SearchService; + private readonly enableWatchers: boolean; + + constructor(projectRoot: string, options?: { enableWatchers?: boolean }) { + this.fs = new FileSystem(projectRoot); + this.git = new GitOperations(projectRoot); + // Disable watchers by default for CLI commands (non-interactive) + // Interactive modes (TUI, browser, MCP) should explicitly pass enableWatchers: true + this.enableWatchers = options?.enableWatchers ?? false; + // Note: Config is loaded lazily when needed since constructor can't be async + } + + async getContentStore(): Promise<ContentStore> { + if (!this.contentStore) { + // Use loadTasks as the task loader to include cross-branch tasks + this.contentStore = new ContentStore(this.fs, () => this.loadTasks(), this.enableWatchers); + } + await this.contentStore.ensureInitialized(); + return this.contentStore; + } + + async getSearchService(): Promise<SearchService> { + if (!this.searchService) { + const store = await this.getContentStore(); + this.searchService = new SearchService(store); + } + await this.searchService.ensureInitialized(); + return this.searchService; + } + + private applyTaskFilters(tasks: Task[], filters?: TaskListFilter): Task[] { + if (!filters) { + return tasks; + } + let result = tasks; + if (filters.status) { + const statusLower = filters.status.toLowerCase(); + result = result.filter((task) => (task.status ?? "").toLowerCase() === statusLower); + } + if (filters.assignee) { + const assigneeLower = filters.assignee.toLowerCase(); + result = result.filter((task) => (task.assignee ?? []).some((value) => value.toLowerCase() === assigneeLower)); + } + if (filters.priority) { + const priorityLower = String(filters.priority).toLowerCase(); + result = result.filter((task) => (task.priority ?? "").toLowerCase() === priorityLower); + } + if (filters.parentTaskId) { + const parentFilter = filters.parentTaskId; + result = result.filter((task) => task.parentTaskId && taskIdsEqual(parentFilter, task.parentTaskId)); + } + return result; + } + + private filterLocalEditableTasks(tasks: Task[]): Task[] { + return tasks.filter(isLocalEditableTask); + } + + private async requireCanonicalStatus(status: string): Promise<string> { + const canonical = await resolveCanonicalStatus(status, this); + if (canonical) { + return canonical; + } + const validStatuses = await resolveValidStatuses(this); + throw new Error(`Invalid status: ${status}. Valid statuses are: ${validStatuses.join(", ")}`); + } + + private normalizePriority(value: string | undefined): ("high" | "medium" | "low") | undefined { + if (value === undefined || value === "") { + return undefined; + } + const normalized = value.toLowerCase(); + const allowed = ["high", "medium", "low"] as const; + if (!allowed.includes(normalized as (typeof allowed)[number])) { + throw new Error(`Invalid priority: ${value}. Valid values are: high, medium, low`); + } + return normalized as "high" | "medium" | "low"; + } + + async queryTasks(options: TaskQueryOptions = {}): Promise<Task[]> { + const { filters, query, limit } = options; + const trimmedQuery = query?.trim(); + const includeCrossBranch = options.includeCrossBranch ?? true; + + const applyFiltersAndLimit = (collection: Task[]): Task[] => { + let filtered = this.applyTaskFilters(collection, filters); + if (!includeCrossBranch) { + filtered = this.filterLocalEditableTasks(filtered); + } + if (typeof limit === "number" && limit >= 0) { + return filtered.slice(0, limit); + } + return filtered; + }; + + if (!trimmedQuery) { + const store = await this.getContentStore(); + const tasks = store.getTasks(); + return applyFiltersAndLimit(tasks); + } + + const searchService = await this.getSearchService(); + const searchFilters: SearchFilters = {}; + if (filters?.status) { + searchFilters.status = filters.status; + } + if (filters?.priority) { + searchFilters.priority = filters.priority; + } + if (filters?.assignee) { + searchFilters.assignee = filters.assignee; + } + + const searchResults = searchService.search({ + query: trimmedQuery, + limit, + types: ["task"], + filters: Object.keys(searchFilters).length > 0 ? searchFilters : undefined, + }); + + const seen = new Set<string>(); + const tasks: Task[] = []; + for (const result of searchResults) { + if (result.type !== "task") continue; + const task = result.task; + if (seen.has(task.id)) continue; + seen.add(task.id); + tasks.push(task); + } + + return applyFiltersAndLimit(tasks); + } + + async getTask(taskId: string): Promise<Task | null> { + const store = await this.getContentStore(); + const tasks = store.getTasks(); + const match = tasks.find((task) => taskIdsEqual(taskId, task.id)); + if (match) { + return match; + } + + const canonicalId = normalizeTaskId(taskId); + return await this.fs.loadTask(canonicalId); + } + + async loadTaskById(taskId: string): Promise<Task | null> { + const canonicalId = normalizeTaskId(taskId); + + // First try local filesystem + const localTask = await this.fs.loadTask(canonicalId); + if (localTask) return localTask; + + // Check config for remote operations + const config = await this.fs.loadConfig(); + const sinceDays = config?.activeBranchDays ?? 30; + + // Try other local branches first (faster than remote) + const localBranchTask = await findTaskInLocalBranches( + this.git, + canonicalId, + DEFAULT_DIRECTORIES.BACKLOG, + sinceDays, + ); + if (localBranchTask) return localBranchTask; + + // Skip remote if disabled + if (config?.remoteOperations === false) return null; + + // Try remote branches + return await findTaskInRemoteBranches(this.git, canonicalId, DEFAULT_DIRECTORIES.BACKLOG, sinceDays); + } + + async getTaskContent(taskId: string): Promise<string | null> { + const filePath = await getTaskPath(taskId, this); + if (!filePath) return null; + return await Bun.file(filePath).text(); + } + + async getDocument(documentId: string): Promise<Document | null> { + const documents = await this.fs.listDocuments(); + const match = documents.find((doc) => documentIdsEqual(documentId, doc.id)); + return match ?? null; + } + + async getDocumentContent(documentId: string): Promise<string | null> { + const document = await this.getDocument(documentId); + if (!document) return null; + + const relativePath = document.path ?? `${document.id}.md`; + const filePath = join(this.fs.docsDir, relativePath); + try { + return await Bun.file(filePath).text(); + } catch { + return null; + } + } + + disposeSearchService(): void { + if (this.searchService) { + this.searchService.dispose(); + this.searchService = undefined; + } + } + + disposeContentStore(): void { + if (this.contentStore) { + this.contentStore.dispose(); + this.contentStore = undefined; + } + } + + // Backward compatibility aliases + get filesystem() { + return this.fs; + } + + get gitOps() { + return this.git; + } + + async ensureConfigLoaded(): Promise<void> { + try { + const config = await this.fs.loadConfig(); + this.git.setConfig(config); + } catch (error) { + // Config loading failed, git operations will work with null config + if (process.env.DEBUG) { + console.warn("Failed to load config for git operations:", error); + } + } + } + + private async getBacklogDirectoryName(): Promise<string> { + // Always use "backlog" as the directory name + return DEFAULT_DIRECTORIES.BACKLOG; + } + + async shouldAutoCommit(overrideValue?: boolean): Promise<boolean> { + // If override is explicitly provided, use it + if (overrideValue !== undefined) { + return overrideValue; + } + // Otherwise, check config (default to false for safety) + const config = await this.fs.loadConfig(); + return config?.autoCommit ?? false; + } + + async getGitOps() { + await this.ensureConfigLoaded(); + return this.git; + } + + // Config migration + async ensureConfigMigrated(): Promise<void> { + await this.ensureConfigLoaded(); + let config = await this.fs.loadConfig(); + + if (!config || needsMigration(config)) { + config = migrateConfig(config || {}); + await this.fs.saveConfig(config); + } + } + + // ID generation + async generateNextId(parent?: string): Promise<string> { + const config = await this.fs.loadConfig(); + + // Use ContentStore for all tasks (local + cross-branch + remote) + // This is the single source of truth for task IDs + const store = await this.getContentStore(); + const tasks = store.getTasks(); + + // Also include drafts (which aren't in ContentStore yet) + const drafts = await this.fs.listDrafts(); + + const allIds: string[] = []; + + for (const t of tasks) { + allIds.push(t.id); + } + for (const d of drafts) { + allIds.push(d.id); + } + + if (parent) { + const prefix = allIds.find((id) => taskIdsEqual(parent, id)) ?? normalizeTaskId(parent); + let max = 0; + for (const id of allIds) { + if (id.startsWith(`${prefix}.`)) { + const rest = id.slice(prefix.length + 1); + const num = Number.parseInt(rest.split(".")[0] || "0", 10); + if (num > max) max = num; + } + } + const nextSubIdNumber = max + 1; + const padding = config?.zeroPaddedIds; + + if (padding && padding > 0) { + const paddedSubId = String(nextSubIdNumber).padStart(2, "0"); + return `${prefix}.${paddedSubId}`; + } + + return `${prefix}.${nextSubIdNumber}`; + } + + let max = 0; + for (const id of allIds) { + const match = id.match(/^task-(\d+)/); + if (match) { + const num = Number.parseInt(match[1] || "0", 10); + if (num > max) max = num; + } + } + const nextIdNumber = max + 1; + const padding = config?.zeroPaddedIds; + + if (padding && padding > 0) { + const paddedId = String(nextIdNumber).padStart(padding, "0"); + return `task-${paddedId}`; + } + + return `task-${nextIdNumber}`; + } + + // High-level operations that combine filesystem and git + async createTaskFromData( + taskData: { + title: string; + status?: string; + assignee?: string[]; + labels?: string[]; + dependencies?: string[]; + parentTaskId?: string; + priority?: "high" | "medium" | "low"; + // First-party structured fields from Web UI / CLI + description?: string; + acceptanceCriteriaItems?: import("../types/index.ts").AcceptanceCriterion[]; + implementationPlan?: string; + implementationNotes?: string; + }, + autoCommit?: boolean, + ): Promise<Task> { + const id = await this.generateNextId(taskData.parentTaskId); + + const task: Task = { + id, + title: taskData.title, + status: taskData.status || "", + assignee: taskData.assignee || [], + labels: taskData.labels || [], + dependencies: taskData.dependencies || [], + rawContent: "", + createdDate: new Date().toISOString().slice(0, 16).replace("T", " "), + ...(taskData.parentTaskId && { parentTaskId: taskData.parentTaskId }), + ...(taskData.priority && { priority: taskData.priority }), + ...(typeof taskData.description === "string" && { description: taskData.description }), + ...(Array.isArray(taskData.acceptanceCriteriaItems) && + taskData.acceptanceCriteriaItems.length > 0 && { + acceptanceCriteriaItems: taskData.acceptanceCriteriaItems, + }), + ...(typeof taskData.implementationPlan === "string" && { implementationPlan: taskData.implementationPlan }), + ...(typeof taskData.implementationNotes === "string" && { implementationNotes: taskData.implementationNotes }), + }; + + // Check if this should be a draft based on status + if (task.status && task.status.toLowerCase() === "draft") { + await this.createDraft(task, autoCommit); + } else { + await this.createTask(task, autoCommit); + } + + return task; + } + + async createTaskFromInput(input: TaskCreateInput, autoCommit?: boolean): Promise<{ task: Task; filePath?: string }> { + if (!input.title || input.title.trim().length === 0) { + throw new Error("Title is required to create a task."); + } + + const id = await this.generateNextId(input.parentTaskId); + + const normalizedLabels = normalizeStringList(input.labels) ?? []; + const normalizedAssignees = normalizeStringList(input.assignee) ?? []; + const normalizedDependencies = normalizeDependencies(input.dependencies); + + const { valid: validDependencies, invalid: invalidDependencies } = await validateDependencies( + normalizedDependencies, + this, + ); + if (invalidDependencies.length > 0) { + throw new Error( + `The following dependencies do not exist: ${invalidDependencies.join(", ")}. Please create these tasks first or verify the IDs.`, + ); + } + + const requestedStatus = input.status?.trim(); + let status = ""; + if (requestedStatus) { + if (requestedStatus.toLowerCase() === "draft") { + status = "Draft"; + } else { + status = await this.requireCanonicalStatus(requestedStatus); + } + } + + const priority = this.normalizePriority(input.priority); + const createdDate = new Date().toISOString().slice(0, 16).replace("T", " "); + + const acceptanceCriteriaItems = Array.isArray(input.acceptanceCriteria) + ? input.acceptanceCriteria + .map((criterion, index) => ({ + index: index + 1, + text: String(criterion.text ?? "").trim(), + checked: Boolean(criterion.checked), + })) + .filter((criterion) => criterion.text.length > 0) + : []; + + const task: Task = { + id, + title: input.title.trim(), + status, + assignee: normalizedAssignees, + labels: normalizedLabels, + dependencies: validDependencies, + rawContent: input.rawContent ?? "", + createdDate, + ...(input.parentTaskId && { parentTaskId: input.parentTaskId }), + ...(priority && { priority }), + ...(typeof input.description === "string" && { description: input.description }), + ...(typeof input.implementationPlan === "string" && { implementationPlan: input.implementationPlan }), + ...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }), + ...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }), + }; + + const isDraft = (status || "").toLowerCase() === "draft"; + const filePath = isDraft ? await this.createDraft(task, autoCommit) : await this.createTask(task, autoCommit); + + const savedTask = await this.fs.loadTask(id); + return { task: savedTask ?? task, filePath }; + } + + async createTask(task: Task, autoCommit?: boolean): Promise<string> { + if (!task.status) { + const config = await this.fs.loadConfig(); + task.status = config?.defaultStatus || FALLBACK_STATUS; + } + + normalizeAssignee(task); + + const filepath = await this.fs.saveTask(task); + + if (await this.shouldAutoCommit(autoCommit)) { + await this.git.addAndCommitTaskFile(task.id, filepath, "create"); + } + + return filepath; + } + + async createDraft(task: Task, autoCommit?: boolean): Promise<string> { + // Drafts always have status "Draft", regardless of config default + task.status = "Draft"; + normalizeAssignee(task); + + const filepath = await this.fs.saveDraft(task); + + if (await this.shouldAutoCommit(autoCommit)) { + await this.git.addFile(filepath); + await this.git.commitTaskChange(task.id, `Create draft ${task.id}`); + } + + return filepath; + } + + async updateTask(task: Task, autoCommit?: boolean): Promise<void> { + normalizeAssignee(task); + + // Load original task to detect status changes for callbacks + const originalTask = await this.fs.loadTask(task.id); + const oldStatus = originalTask?.status ?? ""; + const newStatus = task.status ?? ""; + const statusChanged = oldStatus !== newStatus; + + // Always set updatedDate when updating a task + task.updatedDate = new Date().toISOString().slice(0, 16).replace("T", " "); + + await this.fs.saveTask(task); + + if (await this.shouldAutoCommit(autoCommit)) { + const filePath = await getTaskPath(task.id, this); + if (filePath) { + await this.git.addAndCommitTaskFile(task.id, filePath, "update"); + } + } + + // Fire status change callback if status changed + if (statusChanged) { + await this.executeStatusChangeCallback(task, oldStatus, newStatus); + } + } + + async updateTaskFromInput(taskId: string, input: TaskUpdateInput, autoCommit?: boolean): Promise<Task> { + const task = await this.fs.loadTask(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + let mutated = false; + + const applyStringField = ( + value: string | undefined, + current: string | undefined, + assign: (next: string) => void, + ) => { + if (typeof value === "string") { + const next = value; + if ((current ?? "") !== next) { + assign(next); + mutated = true; + } + } + }; + + if (input.title !== undefined) { + const trimmed = input.title.trim(); + if (trimmed.length === 0) { + throw new Error("Title cannot be empty."); + } + if (task.title !== trimmed) { + task.title = trimmed; + mutated = true; + } + } + + applyStringField(input.description, task.description, (next) => { + task.description = next; + }); + + if (input.status !== undefined) { + const canonicalStatus = + input.status.trim().toLowerCase() === "draft" ? "Draft" : await this.requireCanonicalStatus(input.status); + if ((task.status ?? "") !== canonicalStatus) { + task.status = canonicalStatus; + mutated = true; + } + } + + if (input.priority !== undefined) { + const normalizedPriority = this.normalizePriority(String(input.priority)); + if (task.priority !== normalizedPriority) { + task.priority = normalizedPriority; + mutated = true; + } + } + + if (input.ordinal !== undefined) { + if (Number.isNaN(input.ordinal) || input.ordinal < 0) { + throw new Error("Ordinal must be a non-negative number."); + } + if (task.ordinal !== input.ordinal) { + task.ordinal = input.ordinal; + mutated = true; + } + } + + if (input.assignee !== undefined) { + const sanitizedAssignee = normalizeStringList(input.assignee) ?? []; + if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) { + task.assignee = sanitizedAssignee; + mutated = true; + } + } + + const resolveLabelChanges = (): void => { + let currentLabels = [...(task.labels ?? [])]; + if (input.labels !== undefined) { + const sanitizedLabels = normalizeStringList(input.labels) ?? []; + if (!stringArraysEqual(sanitizedLabels, currentLabels)) { + task.labels = sanitizedLabels; + mutated = true; + } + currentLabels = sanitizedLabels; + } + + const labelsToAdd = normalizeStringList(input.addLabels) ?? []; + if (labelsToAdd.length > 0) { + const labelSet = new Set(currentLabels.map((label) => label.toLowerCase())); + for (const label of labelsToAdd) { + if (!labelSet.has(label.toLowerCase())) { + currentLabels.push(label); + labelSet.add(label.toLowerCase()); + mutated = true; + } + } + task.labels = currentLabels; + } + + const labelsToRemove = normalizeStringList(input.removeLabels) ?? []; + if (labelsToRemove.length > 0) { + const removalSet = new Set(labelsToRemove.map((label) => label.toLowerCase())); + const filtered = currentLabels.filter((label) => !removalSet.has(label.toLowerCase())); + if (!stringArraysEqual(filtered, currentLabels)) { + task.labels = filtered; + mutated = true; + } + } + }; + + resolveLabelChanges(); + + const resolveDependencies = async (): Promise<void> => { + let currentDependencies = [...(task.dependencies ?? [])]; + + if (input.dependencies !== undefined) { + const normalized = normalizeDependencies(input.dependencies); + const { valid, invalid } = await validateDependencies(normalized, this); + if (invalid.length > 0) { + throw new Error( + `The following dependencies do not exist: ${invalid.join(", ")}. Please create these tasks first or verify the IDs.`, + ); + } + if (!stringArraysEqual(valid, currentDependencies)) { + currentDependencies = valid; + mutated = true; + } + } + + if (input.addDependencies && input.addDependencies.length > 0) { + const additions = normalizeDependencies(input.addDependencies); + const { valid, invalid } = await validateDependencies(additions, this); + if (invalid.length > 0) { + throw new Error( + `The following dependencies do not exist: ${invalid.join(", ")}. Please create these tasks first or verify the IDs.`, + ); + } + const depSet = new Set(currentDependencies); + for (const dep of valid) { + if (!depSet.has(dep)) { + currentDependencies.push(dep); + depSet.add(dep); + mutated = true; + } + } + } + + if (input.removeDependencies && input.removeDependencies.length > 0) { + const removals = new Set(normalizeDependencies(input.removeDependencies)); + const filtered = currentDependencies.filter((dep) => !removals.has(dep)); + if (!stringArraysEqual(filtered, currentDependencies)) { + currentDependencies = filtered; + mutated = true; + } + } + + task.dependencies = currentDependencies; + }; + + await resolveDependencies(); + + const sanitizeAppendInput = (values: string[] | undefined): string[] => { + if (!values) return []; + return values.map((value) => String(value).trim()).filter((value) => value.length > 0); + }; + + const appendBlock = ( + existing: string | undefined, + additions: string[] | undefined, + ): { value?: string; changed: boolean } => { + const sanitizedAdditions = (additions ?? []) + .map((value) => String(value).trim()) + .filter((value) => value.length > 0); + if (sanitizedAdditions.length === 0) { + return { value: existing, changed: false }; + } + const current = (existing ?? "").trim(); + const additionBlock = sanitizedAdditions.join("\n\n"); + if (current.length === 0) { + return { value: additionBlock, changed: true }; + } + return { value: `${current}\n\n${additionBlock}`, changed: true }; + }; + + if (input.clearImplementationPlan) { + if (task.implementationPlan !== undefined) { + delete task.implementationPlan; + mutated = true; + } + } + + applyStringField(input.implementationPlan, task.implementationPlan, (next) => { + task.implementationPlan = next; + }); + + const planAppends = sanitizeAppendInput(input.appendImplementationPlan); + if (planAppends.length > 0) { + const { value, changed } = appendBlock(task.implementationPlan, planAppends); + if (changed) { + task.implementationPlan = value; + mutated = true; + } + } + + if (input.clearImplementationNotes) { + if (task.implementationNotes !== undefined) { + delete task.implementationNotes; + mutated = true; + } + } + + applyStringField(input.implementationNotes, task.implementationNotes, (next) => { + task.implementationNotes = next; + }); + + const notesAppends = sanitizeAppendInput(input.appendImplementationNotes); + if (notesAppends.length > 0) { + const { value, changed } = appendBlock(task.implementationNotes, notesAppends); + if (changed) { + task.implementationNotes = value; + mutated = true; + } + } + + let acceptanceCriteria = Array.isArray(task.acceptanceCriteriaItems) + ? task.acceptanceCriteriaItems.map((criterion) => ({ ...criterion })) + : []; + + const rebuildIndices = () => { + acceptanceCriteria = acceptanceCriteria.map((criterion, index) => ({ + ...criterion, + index: index + 1, + })); + }; + + if (input.acceptanceCriteria !== undefined) { + const sanitized = input.acceptanceCriteria + .map((criterion) => ({ + text: String(criterion.text ?? "").trim(), + checked: Boolean(criterion.checked), + })) + .filter((criterion) => criterion.text.length > 0) + .map((criterion, index) => ({ + index: index + 1, + text: criterion.text, + checked: criterion.checked, + })); + acceptanceCriteria = sanitized; + mutated = true; + } + + if (input.addAcceptanceCriteria && input.addAcceptanceCriteria.length > 0) { + const additions = input.addAcceptanceCriteria + .map((criterion) => (typeof criterion === "string" ? criterion.trim() : String(criterion.text ?? "").trim())) + .filter((text) => text.length > 0); + let index = + acceptanceCriteria.length > 0 ? Math.max(...acceptanceCriteria.map((criterion) => criterion.index)) + 1 : 1; + for (const text of additions) { + acceptanceCriteria.push({ index: index++, text, checked: false }); + mutated = true; + } + } + + if (input.removeAcceptanceCriteria && input.removeAcceptanceCriteria.length > 0) { + const removalSet = new Set(input.removeAcceptanceCriteria); + const beforeLength = acceptanceCriteria.length; + acceptanceCriteria = acceptanceCriteria.filter((criterion) => !removalSet.has(criterion.index)); + if (acceptanceCriteria.length === beforeLength) { + throw new Error( + `Acceptance criterion ${Array.from(removalSet) + .map((index) => `#${index}`) + .join(", ")} not found`, + ); + } + mutated = true; + rebuildIndices(); + } + + const toggleCriteria = (indices: number[] | undefined, checked: boolean) => { + if (!indices || indices.length === 0) return; + const missing: number[] = []; + for (const index of indices) { + const criterion = acceptanceCriteria.find((item) => item.index === index); + if (!criterion) { + missing.push(index); + continue; + } + if (criterion.checked !== checked) { + criterion.checked = checked; + mutated = true; + } + } + if (missing.length > 0) { + const label = missing.map((index) => `#${index}`).join(", "); + throw new Error(`Acceptance criterion ${label} not found`); + } + }; + + toggleCriteria(input.checkAcceptanceCriteria, true); + toggleCriteria(input.uncheckAcceptanceCriteria, false); + + task.acceptanceCriteriaItems = acceptanceCriteria; + + if (!mutated) { + return task; + } + + await this.updateTask(task, autoCommit); + const refreshed = await this.fs.loadTask(taskId); + return refreshed ?? task; + } + + /** + * Execute the onStatusChange callback if configured. + * Per-task callback takes precedence over global config. + * Failures are logged but don't block the status change. + */ + private async executeStatusChangeCallback(task: Task, oldStatus: string, newStatus: string): Promise<void> { + const config = await this.fs.loadConfig(); + + // Per-task callback takes precedence over global config + const callbackCommand = task.onStatusChange ?? config?.onStatusChange; + if (!callbackCommand) { + return; + } + + try { + const result = await executeStatusCallback({ + command: callbackCommand, + taskId: task.id, + oldStatus, + newStatus, + taskTitle: task.title, + cwd: this.fs.rootDir, + }); + + if (!result.success) { + console.error(`Status change callback failed for ${task.id}: ${result.error ?? "Unknown error"}`); + if (result.output) { + console.error(`Callback output: ${result.output}`); + } + } else if (process.env.DEBUG && result.output) { + console.log(`Status change callback output for ${task.id}: ${result.output}`); + } + } catch (error) { + console.error(`Failed to execute status change callback for ${task.id}:`, error); + } + } + + async editTask(taskId: string, input: TaskUpdateInput, autoCommit?: boolean): Promise<Task> { + return await this.updateTaskFromInput(taskId, input, autoCommit); + } + + async updateTasksBulk(tasks: Task[], commitMessage?: string, autoCommit?: boolean): Promise<void> { + // Update all tasks without committing individually + for (const task of tasks) { + await this.updateTask(task, false); // Don't auto-commit each one + } + + // Commit all changes at once if auto-commit is enabled + if (await this.shouldAutoCommit(autoCommit)) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(commitMessage || `Update ${tasks.length} tasks`); + } + } + + async reorderTask(params: { + taskId: string; + targetStatus: string; + orderedTaskIds: string[]; + commitMessage?: string; + autoCommit?: boolean; + defaultStep?: number; + }): Promise<{ updatedTask: Task; changedTasks: Task[] }> { + const taskId = String(params.taskId || "").trim(); + const targetStatus = String(params.targetStatus || "").trim(); + const orderedTaskIds = params.orderedTaskIds.map((id) => String(id || "").trim()).filter(Boolean); + const defaultStep = params.defaultStep ?? DEFAULT_ORDINAL_STEP; + + if (!taskId) throw new Error("taskId is required"); + if (!targetStatus) throw new Error("targetStatus is required"); + if (orderedTaskIds.length === 0) throw new Error("orderedTaskIds must include at least one task"); + if (!orderedTaskIds.includes(taskId)) { + throw new Error("orderedTaskIds must include the task being moved"); + } + + const seen = new Set<string>(); + for (const id of orderedTaskIds) { + if (seen.has(id)) { + throw new Error(`Duplicate task id ${id} in orderedTaskIds`); + } + seen.add(id); + } + + // Load all tasks from the ordered list - use getTask to include cross-branch tasks from the store + const loadedTasks = await Promise.all( + orderedTaskIds.map(async (id) => { + const task = await this.getTask(id); + return task; + }), + ); + + // Filter out any tasks that couldn't be loaded (may have been moved/deleted) + const validTasks = loadedTasks.filter((t): t is Task => t !== null); + + // Verify the moved task itself exists + const movedTask = validTasks.find((t) => t.id === taskId); + if (!movedTask) { + throw new Error(`Task ${taskId} not found while reordering`); + } + + // Reject reordering tasks from other branches - they can only be modified in their source branch + if (movedTask.branch) { + throw new Error( + `Task ${taskId} exists in branch "${movedTask.branch}" and cannot be reordered from the current branch. Switch to that branch to modify it.`, + ); + } + + // Calculate target index within the valid tasks list + const validOrderedIds = orderedTaskIds.filter((id) => validTasks.some((t) => t.id === id)); + const targetIndex = validOrderedIds.indexOf(taskId); + + if (targetIndex === -1) { + throw new Error("Implementation error: Task found in validTasks but index missing"); + } + + const previousTask = targetIndex > 0 ? validTasks[targetIndex - 1] : null; + const nextTask = targetIndex < validTasks.length - 1 ? validTasks[targetIndex + 1] : null; + + const { ordinal: newOrdinal, requiresRebalance } = calculateNewOrdinal({ + previous: previousTask, + next: nextTask, + defaultStep, + }); + + const updatedMoved: Task = { + ...movedTask, + status: targetStatus, + ordinal: newOrdinal, + }; + + const tasksInOrder: Task[] = validTasks.map((task, index) => (index === targetIndex ? updatedMoved : task)); + const resolutionUpdates = resolveOrdinalConflicts(tasksInOrder, { + defaultStep, + startOrdinal: defaultStep, + forceSequential: requiresRebalance, + }); + + const updatesMap = new Map<string, Task>(); + for (const update of resolutionUpdates) { + updatesMap.set(update.id, update); + } + if (!updatesMap.has(updatedMoved.id)) { + updatesMap.set(updatedMoved.id, updatedMoved); + } + + const originalMap = new Map(validTasks.map((task) => [task.id, task])); + const changedTasks = Array.from(updatesMap.values()).filter((task) => { + const original = originalMap.get(task.id); + if (!original) return true; + return (original.ordinal ?? null) !== (task.ordinal ?? null) || (original.status ?? "") !== (task.status ?? ""); + }); + + if (changedTasks.length > 0) { + await this.updateTasksBulk( + changedTasks, + params.commitMessage ?? `Reorder tasks in ${targetStatus}`, + params.autoCommit, + ); + } + + const updatedTask = updatesMap.get(taskId) ?? updatedMoved; + return { updatedTask, changedTasks }; + } + + // Sequences operations (business logic lives in core, not server) + async listActiveSequences(): Promise<{ unsequenced: Task[]; sequences: Sequence[] }> { + const all = await this.fs.listTasks(); + const active = all.filter((t) => (t.status || "").toLowerCase() !== "done"); + return computeSequences(active); + } + + async moveTaskInSequences(params: { + taskId: string; + unsequenced?: boolean; + targetSequenceIndex?: number; + }): Promise<{ unsequenced: Task[]; sequences: Sequence[] }> { + const taskId = String(params.taskId || "").trim(); + if (!taskId) throw new Error("taskId is required"); + + const allTasks = await this.fs.listTasks(); + const exists = allTasks.some((t) => t.id === taskId); + if (!exists) throw new Error(`Task ${taskId} not found`); + + const active = allTasks.filter((t) => (t.status || "").toLowerCase() !== "done"); + const { sequences } = computeSequences(active); + + if (params.unsequenced) { + const res = planMoveToUnsequenced(allTasks, taskId); + if (!res.ok) throw new Error(res.error); + await this.updateTasksBulk(res.changed, `Move ${taskId} to Unsequenced`); + } else { + const targetSequenceIndex = params.targetSequenceIndex; + if (targetSequenceIndex === undefined || Number.isNaN(targetSequenceIndex)) { + throw new Error("targetSequenceIndex must be a number"); + } + if (targetSequenceIndex < 1) throw new Error("targetSequenceIndex must be >= 1"); + const changed = planMoveToSequence(allTasks, sequences, taskId, targetSequenceIndex); + if (changed.length > 0) await this.updateTasksBulk(changed, `Update deps/order for ${taskId}`); + } + + // Return updated sequences + const afterAll = await this.fs.listTasks(); + const afterActive = afterAll.filter((t) => (t.status || "").toLowerCase() !== "done"); + return computeSequences(afterActive); + } + + async archiveTask(taskId: string, autoCommit?: boolean): Promise<boolean> { + // Get paths before moving the file + const taskPath = await getTaskPath(taskId, this); + const taskFilename = await getTaskFilename(taskId, this); + + if (!taskPath || !taskFilename) return false; + + const fromPath = taskPath; + const toPath = join(await this.fs.getArchiveTasksDir(), taskFilename); + + const success = await this.fs.archiveTask(taskId); + + if (success && (await this.shouldAutoCommit(autoCommit))) { + // Stage the file move for proper Git tracking + await this.git.stageFileMove(fromPath, toPath); + await this.git.commitChanges(`backlog: Archive task ${taskId}`); + } + + return success; + } + + async completeTask(taskId: string, autoCommit?: boolean): Promise<boolean> { + // Get paths before moving the file + const completedDir = this.fs.completedDir; + const taskPath = await getTaskPath(taskId, this); + const taskFilename = await getTaskFilename(taskId, this); + + if (!taskPath || !taskFilename) return false; + + const fromPath = taskPath; + const toPath = join(completedDir, taskFilename); + + const success = await this.fs.completeTask(taskId); + + if (success && (await this.shouldAutoCommit(autoCommit))) { + // Stage the file move for proper Git tracking + await this.git.stageFileMove(fromPath, toPath); + await this.git.commitChanges(`backlog: Complete task ${taskId}`); + } + + return success; + } + + async getDoneTasksByAge(olderThanDays: number): Promise<Task[]> { + const tasks = await this.fs.listTasks(); + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + return tasks.filter((task) => { + if (task.status !== "Done") return false; + + // Check updatedDate first, then createdDate as fallback + const taskDate = task.updatedDate || task.createdDate; + if (!taskDate) return false; + + const date = new Date(taskDate); + return date < cutoffDate; + }); + } + + async archiveDraft(taskId: string, autoCommit?: boolean): Promise<boolean> { + const success = await this.fs.archiveDraft(taskId); + + if (success && (await this.shouldAutoCommit(autoCommit))) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(`backlog: Archive draft ${taskId}`); + } + + return success; + } + + async promoteDraft(taskId: string, autoCommit?: boolean): Promise<boolean> { + const success = await this.fs.promoteDraft(taskId); + + if (success && (await this.shouldAutoCommit(autoCommit))) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(`backlog: Promote draft ${taskId}`); + } + + return success; + } + + async demoteTask(taskId: string, autoCommit?: boolean): Promise<boolean> { + const success = await this.fs.demoteTask(taskId); + + if (success && (await this.shouldAutoCommit(autoCommit))) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(`backlog: Demote task ${taskId}`); + } + + return success; + } + + /** + * Add acceptance criteria to a task + */ + async addAcceptanceCriteria(taskId: string, criteria: string[], autoCommit?: boolean): Promise<void> { + const task = await this.fs.loadTask(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + // Get existing criteria or initialize empty array + const current = Array.isArray(task.acceptanceCriteriaItems) ? [...task.acceptanceCriteriaItems] : []; + + // Calculate next index (1-based) + let nextIndex = current.length > 0 ? Math.max(...current.map((c) => c.index)) + 1 : 1; + + // Append new criteria + const newCriteria = criteria.map((text) => ({ index: nextIndex++, text, checked: false })); + task.acceptanceCriteriaItems = [...current, ...newCriteria]; + + // Save the task + await this.updateTask(task, autoCommit); + } + + /** + * Remove acceptance criteria by indices (supports batch operations) + * @returns Array of removed indices + */ + async removeAcceptanceCriteria(taskId: string, indices: number[], autoCommit?: boolean): Promise<number[]> { + const task = await this.fs.loadTask(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + let list = Array.isArray(task.acceptanceCriteriaItems) ? [...task.acceptanceCriteriaItems] : []; + const removed: number[] = []; + + // Sort indices in descending order to avoid index shifting issues + const sortedIndices = [...indices].sort((a, b) => b - a); + + for (const idx of sortedIndices) { + const before = list.length; + list = list.filter((c) => c.index !== idx); + if (list.length < before) { + removed.push(idx); + } + } + + if (removed.length === 0) { + throw new Error("No criteria were removed. Check that the specified indices exist."); + } + + // Re-index remaining items (1-based) + list = list.map((c, i) => ({ ...c, index: i + 1 })); + task.acceptanceCriteriaItems = list; + + // Save the task + await this.updateTask(task, autoCommit); + + return removed.sort((a, b) => a - b); // Return in ascending order + } + + /** + * Check or uncheck acceptance criteria by indices (supports batch operations) + * Silently ignores invalid indices and only updates valid ones. + * @returns Array of updated indices + */ + async checkAcceptanceCriteria( + taskId: string, + indices: number[], + checked: boolean, + autoCommit?: boolean, + ): Promise<number[]> { + const task = await this.fs.loadTask(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + let list = Array.isArray(task.acceptanceCriteriaItems) ? [...task.acceptanceCriteriaItems] : []; + const updated: number[] = []; + + // Filter to only valid indices and update them + for (const idx of indices) { + if (list.some((c) => c.index === idx)) { + list = list.map((c) => { + if (c.index === idx) { + updated.push(idx); + return { ...c, checked }; + } + return c; + }); + } + } + + if (updated.length === 0) { + throw new Error("No criteria were updated."); + } + + task.acceptanceCriteriaItems = list; + + // Save the task + await this.updateTask(task, autoCommit); + + return updated.sort((a, b) => a - b); + } + + /** + * List all acceptance criteria for a task + */ + async listAcceptanceCriteria(taskId: string): Promise<AcceptanceCriterion[]> { + const task = await this.fs.loadTask(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + return task.acceptanceCriteriaItems || []; + } + + async createDecision(decision: Decision, autoCommit?: boolean): Promise<void> { + await this.fs.saveDecision(decision); + + if (await this.shouldAutoCommit(autoCommit)) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(`backlog: Add decision ${decision.id}`); + } + } + + async updateDecisionFromContent(decisionId: string, content: string, autoCommit?: boolean): Promise<void> { + const existingDecision = await this.fs.loadDecision(decisionId); + if (!existingDecision) { + throw new Error(`Decision ${decisionId} not found`); + } + + // Parse the markdown content to extract the decision data + const matter = await import("gray-matter"); + const { data } = matter.default(content); + + const extractSection = (content: string, sectionName: string): string | undefined => { + const regex = new RegExp(`## ${sectionName}\\s*([\\s\\S]*?)(?=## |$)`, "i"); + const match = content.match(regex); + return match ? match[1]?.trim() : undefined; + }; + + const updatedDecision = { + ...existingDecision, + title: data.title || existingDecision.title, + status: data.status || existingDecision.status, + date: data.date || existingDecision.date, + context: extractSection(content, "Context") || existingDecision.context, + decision: extractSection(content, "Decision") || existingDecision.decision, + consequences: extractSection(content, "Consequences") || existingDecision.consequences, + alternatives: extractSection(content, "Alternatives") || existingDecision.alternatives, + }; + + await this.createDecision(updatedDecision, autoCommit); + } + + async createDecisionWithTitle(title: string, autoCommit?: boolean): Promise<Decision> { + // Import the generateNextDecisionId function from CLI + const { generateNextDecisionId } = await import("../cli.js"); + const id = await generateNextDecisionId(this); + + const decision: Decision = { + id, + title, + date: new Date().toISOString().slice(0, 16).replace("T", " "), + status: "proposed", + context: "[Describe the context and problem that needs to be addressed]", + decision: "[Describe the decision that was made]", + consequences: "[Describe the consequences of this decision]", + rawContent: "", + }; + + await this.createDecision(decision, autoCommit); + return decision; + } + + async createDocument(doc: Document, autoCommit?: boolean, subPath = ""): Promise<void> { + const relativePath = await this.fs.saveDocument(doc, subPath); + doc.path = relativePath; + + if (await this.shouldAutoCommit(autoCommit)) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(`backlog: Add document ${doc.id}`); + } + } + + async updateDocument(existingDoc: Document, content: string, autoCommit?: boolean): Promise<void> { + const updatedDoc = { + ...existingDoc, + rawContent: content, + updatedDate: new Date().toISOString().slice(0, 16).replace("T", " "), + }; + + let normalizedSubPath = ""; + if (existingDoc.path) { + const segments = existingDoc.path.split(/[\\/]/).slice(0, -1); + if (segments.length > 0) { + normalizedSubPath = segments.join("/"); + } + } + + await this.createDocument(updatedDoc, autoCommit, normalizedSubPath); + } + + async createDocumentWithId(title: string, content: string, autoCommit?: boolean): Promise<Document> { + // Import the generateNextDocId function from CLI + const { generateNextDocId } = await import("../cli.js"); + const id = await generateNextDocId(this); + + const document: Document = { + id, + title, + type: "other" as const, + createdDate: new Date().toISOString().slice(0, 16).replace("T", " "), + rawContent: content, + }; + + await this.createDocument(document, autoCommit); + return document; + } + + async initializeProject(projectName: string, autoCommit = false): Promise<void> { + await this.fs.ensureBacklogStructure(); + + const config: BacklogConfig = { + projectName: projectName, + statuses: [...DEFAULT_STATUSES], + labels: [], + milestones: [], + defaultStatus: DEFAULT_STATUSES[0], // Use first status as default + dateFormat: "yyyy-mm-dd", + maxColumnWidth: 20, // Default for terminal display + autoCommit: false, // Default to false for user control + }; + + await this.fs.saveConfig(config); + // Update git operations with the new config + await this.ensureConfigLoaded(); + + if (autoCommit) { + const backlogDir = await this.getBacklogDirectoryName(); + await this.git.stageBacklogDirectory(backlogDir); + await this.git.commitChanges(`backlog: Initialize backlog project: ${projectName}`); + } + } + + async listTasksWithMetadata( + includeBranchMeta = false, + ): Promise<Array<Task & { lastModified?: Date; branch?: string }>> { + const tasks = await this.fs.listTasks(); + return await Promise.all( + tasks.map(async (task) => { + const filePath = await getTaskPath(task.id, this); + + if (filePath) { + const bunFile = Bun.file(filePath); + const stats = await bunFile.stat(); + return { + ...task, + lastModified: new Date(stats.mtime), + // Only include branch if explicitly requested + ...(includeBranchMeta && { + branch: (await this.git.getFileLastModifiedBranch(filePath)) || undefined, + }), + }; + } + return task; + }), + ); + } + + /** + * Open a file in the configured editor with minimal interference + * @param filePath - Path to the file to edit + * @param screen - Optional blessed screen to suspend (for TUI contexts) + */ + async openEditor(filePath: string, screen?: BlessedScreen): Promise<boolean> { + const config = await this.fs.loadConfig(); + + // If no screen provided, use simple editor opening + if (!screen) { + return await openInEditor(filePath, config); + } + + // Store all event listeners before removing them + const inputListeners = new Map<string, Array<(...args: unknown[]) => void>>(); + const eventNames = ["keypress", "data", "readable"]; + + for (const eventName of eventNames) { + const listeners = screen.program.input.listeners(eventName) as Array<(...args: unknown[]) => void>; + if (listeners.length > 0) { + inputListeners.set(eventName, [...listeners]); + } + } + + // Properly pause the terminal (raw mode off, normal buffer) if supported + const resume = typeof screen.program.pause === "function" ? screen.program.pause() : undefined; + try { + // Ensure we are out of alt buffer + screen.leave(); + return await openInEditor(filePath, config); + } finally { + // Resume terminal state + if (typeof resume === "function") { + resume(); + } else { + screen.enter(); + } + // Full redraw + screen.clearRegion(0, screen.width, 0, screen.height); + screen.render(); + process.nextTick(() => { + screen.emit("resize"); + }); + } + } + + /** + * Load and process all tasks with the same logic as CLI overview + * This method extracts the common task loading logic for reuse + */ + async loadAllTasksForStatistics( + progressCallback?: (msg: string) => void, + ): Promise<{ tasks: Task[]; drafts: Task[]; statuses: string[] }> { + const config = await this.fs.loadConfig(); + const statuses = (config?.statuses || DEFAULT_STATUSES) as string[]; + const resolutionStrategy = config?.taskResolutionStrategy || "most_progressed"; + + // Load local and completed tasks first + progressCallback?.("Loading local tasks..."); + const [localTasks, completedTasks] = await Promise.all([ + this.listTasksWithMetadata(), + this.fs.listCompletedTasks(), + ]); + + // Load remote tasks and local branch tasks in parallel + const [remoteTasks, localBranchTasks] = await Promise.all([ + loadRemoteTasks(this.git, config, progressCallback, localTasks), + loadLocalBranchTasks(this.git, config, progressCallback, localTasks), + ]); + progressCallback?.("Loaded tasks"); + + // Create map with local tasks + const tasksById = new Map<string, Task>(localTasks.map((t) => [t.id, { ...t, source: "local" }])); + + // Add completed tasks to the map + for (const completedTask of completedTasks) { + if (!tasksById.has(completedTask.id)) { + tasksById.set(completedTask.id, { ...completedTask, source: "completed" }); + } + } + + // Merge tasks from other local branches + progressCallback?.("Merging tasks..."); + for (const branchTask of localBranchTasks) { + const existing = tasksById.get(branchTask.id); + if (!existing) { + tasksById.set(branchTask.id, branchTask); + } else { + const resolved = resolveTaskConflict(existing, branchTask, statuses, resolutionStrategy); + tasksById.set(branchTask.id, resolved); + } + } + + // Merge remote tasks with local tasks + for (const remoteTask of remoteTasks) { + const existing = tasksById.get(remoteTask.id); + if (!existing) { + tasksById.set(remoteTask.id, remoteTask); + } else { + const resolved = resolveTaskConflict(existing, remoteTask, statuses, resolutionStrategy); + tasksById.set(remoteTask.id, resolved); + } + } + + // Get all tasks as array + const tasks = Array.from(tasksById.values()); + let activeTasks: Task[]; + + if (config?.checkActiveBranches === false) { + // Skip cross-branch checking for maximum performance + progressCallback?.("Skipping cross-branch check (disabled in config)..."); + activeTasks = tasks; + } else { + // Get the latest state of each task across all branches + progressCallback?.("Checking task states across branches..."); + const taskIds = tasks.map((t) => t.id); + const latestTaskDirectories = await getLatestTaskStatesForIds( + this.git, + this.fs, + taskIds, + progressCallback || (() => {}), + { + recentBranchesOnly: true, + daysAgo: config?.activeBranchDays ?? 30, + }, + ); + + // Filter tasks based on their latest directory location + activeTasks = filterTasksByLatestState(tasks, latestTaskDirectories); + } + + // Load drafts + progressCallback?.("Loading drafts..."); + const drafts = await this.fs.listDrafts(); + + return { tasks: activeTasks, drafts, statuses: statuses as string[] }; + } + + /** + * Load all tasks with cross-branch support + * This is the single entry point for loading tasks across all interfaces + */ + async loadTasks(progressCallback?: (msg: string) => void, abortSignal?: AbortSignal): Promise<Task[]> { + const config = await this.fs.loadConfig(); + const statuses = config?.statuses || [...DEFAULT_STATUSES]; + const resolutionStrategy = config?.taskResolutionStrategy || "most_progressed"; + + // Check for cancellation + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + // Load local filesystem tasks first (needed for optimization) + const localTasks = await this.listTasksWithMetadata(); + + // Check for cancellation + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + // Load tasks from remote branches and other local branches in parallel + progressCallback?.(getTaskLoadingMessage(config)); + + const [remoteTasks, localBranchTasks] = await Promise.all([ + loadRemoteTasks(this.git, config, progressCallback, localTasks), + loadLocalBranchTasks(this.git, config, progressCallback, localTasks), + ]); + + // Check for cancellation after loading + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + // Create map with local tasks (current branch filesystem) + const tasksById = new Map<string, Task>(localTasks.map((t) => [t.id, { ...t, source: "local" }])); + + // Merge tasks from other local branches + for (const branchTask of localBranchTasks) { + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + const existing = tasksById.get(branchTask.id); + if (!existing) { + tasksById.set(branchTask.id, branchTask); + } else { + const resolved = resolveTaskConflict(existing, branchTask, statuses, resolutionStrategy); + tasksById.set(branchTask.id, resolved); + } + } + + // Merge remote tasks with local tasks + for (const remoteTask of remoteTasks) { + // Check for cancellation during merge + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + const existing = tasksById.get(remoteTask.id); + if (!existing) { + tasksById.set(remoteTask.id, remoteTask); + } else { + const resolved = resolveTaskConflict(existing, remoteTask, statuses, resolutionStrategy); + tasksById.set(remoteTask.id, resolved); + } + } + + // Check for cancellation before cross-branch checking + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + // Get the latest directory location of each task across all branches + const tasks = Array.from(tasksById.values()); + let filteredTasks: Task[]; + + if (config?.checkActiveBranches === false) { + // Skip cross-branch checking for maximum performance + progressCallback?.("Skipping cross-branch check (disabled in config)..."); + filteredTasks = tasks; + } else { + progressCallback?.("Resolving task states across branches..."); + const taskIds = tasks.map((t) => t.id); + const latestTaskDirectories = await getLatestTaskStatesForIds(this.git, this.fs, taskIds, progressCallback, { + recentBranchesOnly: true, + daysAgo: config?.activeBranchDays ?? 30, + }); + + // Check for cancellation before filtering + if (abortSignal?.aborted) { + throw new Error("Loading cancelled"); + } + + // Filter tasks based on their latest directory location + progressCallback?.("Filtering active tasks..."); + filteredTasks = filterTasksByLatestState(tasks, latestTaskDirectories); + } + + return filteredTasks; + } +} diff --git a/src/core/config-migration.ts b/src/core/config-migration.ts new file mode 100644 index 0000000..492e223 --- /dev/null +++ b/src/core/config-migration.ts @@ -0,0 +1,61 @@ +import type { BacklogConfig } from "../types/index.ts"; + +/** + * Migrates config to ensure all required fields exist with default values + */ +export function migrateConfig(config: Partial<BacklogConfig>): BacklogConfig { + const defaultConfig: BacklogConfig = { + projectName: "Untitled Project", + defaultEditor: "", + defaultStatus: "", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + maxColumnWidth: 80, + autoOpenBrowser: true, + defaultPort: 6420, + remoteOperations: true, + autoCommit: false, + bypassGitHooks: false, + checkActiveBranches: true, + activeBranchDays: 30, + }; + + // Merge provided config with defaults, ensuring all fields exist + // Only include fields from config that are not undefined + const filteredConfig = Object.fromEntries(Object.entries(config).filter(([_, value]) => value !== undefined)); + + const migratedConfig: BacklogConfig = { + ...defaultConfig, + ...filteredConfig, + }; + + // Ensure arrays are not undefined + migratedConfig.statuses = config.statuses || defaultConfig.statuses; + migratedConfig.labels = config.labels || defaultConfig.labels; + migratedConfig.milestones = config.milestones || defaultConfig.milestones; + + return migratedConfig; +} + +/** + * Checks if config needs migration (missing any expected fields) + */ +export function needsMigration(config: Partial<BacklogConfig>): boolean { + // Check for all expected fields including new ones + // We need to check not just presence but also that they aren't undefined + const expectedFieldsWithDefaults = [ + { field: "projectName", hasDefault: true }, + { field: "statuses", hasDefault: true }, + { field: "defaultPort", hasDefault: true }, + { field: "autoOpenBrowser", hasDefault: true }, + { field: "remoteOperations", hasDefault: true }, + { field: "autoCommit", hasDefault: true }, + ]; + + return expectedFieldsWithDefaults.some(({ field }) => { + const value = config[field as keyof BacklogConfig]; + return value === undefined; + }); +} diff --git a/src/core/content-store.ts b/src/core/content-store.ts new file mode 100644 index 0000000..e1217a0 --- /dev/null +++ b/src/core/content-store.ts @@ -0,0 +1,899 @@ +import { type FSWatcher, watch } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { basename, join, relative, sep } from "node:path"; +import type { FileSystem } from "../file-system/operations.ts"; +import { parseDecision, parseDocument, parseTask } from "../markdown/parser.ts"; +import type { Decision, Document, Task, TaskListFilter } from "../types/index.ts"; +import { taskIdsEqual } from "../utils/task-path.ts"; +import { sortByTaskId } from "../utils/task-sorting.ts"; + +interface ContentSnapshot { + tasks: Task[]; + documents: Document[]; + decisions: Decision[]; +} + +type ContentStoreEventType = "ready" | "tasks" | "documents" | "decisions"; + +export type ContentStoreEvent = + | { type: "ready"; snapshot: ContentSnapshot; version: number } + | { type: "tasks"; tasks: Task[]; snapshot: ContentSnapshot; version: number } + | { type: "documents"; documents: Document[]; snapshot: ContentSnapshot; version: number } + | { type: "decisions"; decisions: Decision[]; snapshot: ContentSnapshot; version: number }; + +export type ContentStoreListener = (event: ContentStoreEvent) => void; + +interface WatchHandle { + stop(): void; +} + +export class ContentStore { + private initialized = false; + private initializing: Promise<void> | null = null; + private version = 0; + + private readonly tasks = new Map<string, Task>(); + private readonly documents = new Map<string, Document>(); + private readonly decisions = new Map<string, Decision>(); + + private cachedTasks: Task[] = []; + private cachedDocuments: Document[] = []; + private cachedDecisions: Decision[] = []; + + private readonly listeners = new Set<ContentStoreListener>(); + private readonly watchers: WatchHandle[] = []; + private restoreFilesystemPatch?: () => void; + private chainTail: Promise<void> = Promise.resolve(); + private watchersInitialized = false; + private configWatcherActive = false; + + private attachWatcherErrorHandler(watcher: FSWatcher, context: string): void { + watcher.on("error", (error) => { + if (process.env.DEBUG) { + console.warn(`Watcher error (${context})`, error); + } + }); + } + + constructor( + private readonly filesystem: FileSystem, + private readonly taskLoader?: () => Promise<Task[]>, + private readonly enableWatchers = false, + ) { + this.patchFilesystem(); + } + + subscribe(listener: ContentStoreListener): () => void { + this.listeners.add(listener); + + if (this.initialized) { + listener({ type: "ready", snapshot: this.getSnapshot(), version: this.version }); + } else { + void this.ensureInitialized(); + } + + return () => { + this.listeners.delete(listener); + }; + } + + async ensureInitialized(): Promise<ContentSnapshot> { + if (this.initialized) { + return this.getSnapshot(); + } + + if (!this.initializing) { + this.initializing = this.loadInitialData().catch((error) => { + this.initializing = null; + throw error; + }); + } + + await this.initializing; + return this.getSnapshot(); + } + + getTasks(filter?: TaskListFilter): Task[] { + if (!this.initialized) { + throw new Error("ContentStore not initialized. Call ensureInitialized() first."); + } + + let tasks = this.cachedTasks; + if (filter?.status) { + const statusLower = filter.status.toLowerCase(); + tasks = tasks.filter((task) => task.status.toLowerCase() === statusLower); + } + if (filter?.assignee) { + const assignee = filter.assignee; + tasks = tasks.filter((task) => task.assignee.includes(assignee)); + } + if (filter?.priority) { + const priority = filter.priority.toLowerCase(); + tasks = tasks.filter((task) => (task.priority ?? "").toLowerCase() === priority); + } + if (filter?.parentTaskId) { + const parentFilter = filter.parentTaskId; + tasks = tasks.filter((task) => task.parentTaskId && taskIdsEqual(parentFilter, task.parentTaskId)); + } + + return tasks.slice(); + } + + upsertTask(task: Task): void { + if (!this.initialized) { + return; + } + this.tasks.set(task.id, task); + this.cachedTasks = sortByTaskId(Array.from(this.tasks.values())); + this.notify("tasks"); + } + + getDocuments(): Document[] { + if (!this.initialized) { + throw new Error("ContentStore not initialized. Call ensureInitialized() first."); + } + return this.cachedDocuments.slice(); + } + + getDecisions(): Decision[] { + if (!this.initialized) { + throw new Error("ContentStore not initialized. Call ensureInitialized() first."); + } + return this.cachedDecisions.slice(); + } + + getSnapshot(): ContentSnapshot { + return { + tasks: this.cachedTasks.slice(), + documents: this.cachedDocuments.slice(), + decisions: this.cachedDecisions.slice(), + }; + } + + dispose(): void { + if (this.restoreFilesystemPatch) { + this.restoreFilesystemPatch(); + this.restoreFilesystemPatch = undefined; + } + for (const watcher of this.watchers) { + try { + watcher.stop(); + } catch { + // Ignore watcher shutdown errors + } + } + this.watchers.length = 0; + this.watchersInitialized = false; + } + + private emit(event: ContentStoreEvent): void { + for (const listener of [...this.listeners]) { + listener(event); + } + } + + private notify(type: ContentStoreEventType): void { + this.version += 1; + const snapshot = this.getSnapshot(); + + if (type === "tasks") { + this.emit({ type, tasks: snapshot.tasks, snapshot, version: this.version }); + return; + } + + if (type === "documents") { + this.emit({ type, documents: snapshot.documents, snapshot, version: this.version }); + return; + } + + if (type === "decisions") { + this.emit({ type, decisions: snapshot.decisions, snapshot, version: this.version }); + return; + } + + this.emit({ type: "ready", snapshot, version: this.version }); + } + + private async loadInitialData(): Promise<void> { + await this.filesystem.ensureBacklogStructure(); + + // Use custom task loader if provided (e.g., loadTasks for cross-branch support) + // Otherwise fall back to filesystem-only loading + const [tasks, documents, decisions] = await Promise.all([ + this.loadTasksWithLoader(), + this.filesystem.listDocuments(), + this.filesystem.listDecisions(), + ]); + + this.replaceTasks(tasks); + this.replaceDocuments(documents); + this.replaceDecisions(decisions); + + this.initialized = true; + if (this.enableWatchers) { + await this.setupWatchers(); + } + this.notify("ready"); + } + + private async setupWatchers(): Promise<void> { + if (this.watchersInitialized) return; + this.watchersInitialized = true; + + try { + this.watchers.push(this.createTaskWatcher()); + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to initialize task watcher", error); + } + } + + try { + this.watchers.push(this.createDecisionWatcher()); + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to initialize decision watcher", error); + } + } + + try { + const docWatcher = await this.createDocumentWatcher(); + this.watchers.push(docWatcher); + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to initialize document watcher", error); + } + } + + try { + const configWatcher = this.createConfigWatcher(); + if (configWatcher) { + this.watchers.push(configWatcher); + this.configWatcherActive = true; + } + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to initialize config watcher", error); + } + } + } + + /** + * Retry setting up the config watcher after initialization. + * Called when the config file is created after the server started. + */ + ensureConfigWatcher(): void { + if (this.configWatcherActive) { + return; + } + try { + const configWatcher = this.createConfigWatcher(); + if (configWatcher) { + this.watchers.push(configWatcher); + this.configWatcherActive = true; + } + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to setup config watcher after init", error); + } + } + } + + private createConfigWatcher(): WatchHandle | null { + const configPath = this.filesystem.configFilePath; + try { + const watcher: FSWatcher = watch(configPath, (eventType) => { + if (eventType !== "change" && eventType !== "rename") { + return; + } + this.enqueue(async () => { + this.filesystem.invalidateConfigCache(); + this.notify("tasks"); + }); + }); + this.attachWatcherErrorHandler(watcher, "config"); + + return { + stop() { + watcher.close(); + }, + }; + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to watch config file", error); + } + return null; + } + } + + private createTaskWatcher(): WatchHandle { + const tasksDir = this.filesystem.tasksDir; + const watcher: FSWatcher = watch(tasksDir, { recursive: false }, (eventType, filename) => { + const file = this.normalizeFilename(filename); + if (!file || !file.startsWith("task-") || !file.endsWith(".md")) { + this.enqueue(async () => { + await this.refreshTasksFromDisk(); + }); + return; + } + + this.enqueue(async () => { + const [taskId] = file.split(" "); + if (!taskId) return; + + const fullPath = join(tasksDir, file); + const exists = await Bun.file(fullPath).exists(); + + if (!exists && eventType === "rename") { + if (this.tasks.delete(taskId)) { + this.cachedTasks = sortByTaskId(Array.from(this.tasks.values())); + this.notify("tasks"); + } + return; + } + + if (eventType === "rename" && exists) { + await this.refreshTasksFromDisk(); + return; + } + + const previous = this.tasks.get(taskId); + const task = await this.retryRead( + async () => { + const stillExists = await Bun.file(fullPath).exists(); + if (!stillExists) { + return null; + } + const content = await Bun.file(fullPath).text(); + return parseTask(content); + }, + (result) => { + if (!result) { + return false; + } + if (result.id !== taskId) { + return false; + } + if (!previous) { + return true; + } + return this.hasTaskChanged(previous, result); + }, + ); + if (!task) { + await this.refreshTasksFromDisk(taskId, previous); + return; + } + + this.tasks.set(task.id, task); + this.cachedTasks = sortByTaskId(Array.from(this.tasks.values())); + this.notify("tasks"); + }); + }); + this.attachWatcherErrorHandler(watcher, "tasks"); + + return { + stop() { + watcher.close(); + }, + }; + } + + private createDecisionWatcher(): WatchHandle { + const decisionsDir = this.filesystem.decisionsDir; + const watcher: FSWatcher = watch(decisionsDir, { recursive: false }, (eventType, filename) => { + const file = this.normalizeFilename(filename); + if (!file || !file.startsWith("decision-") || !file.endsWith(".md")) { + this.enqueue(async () => { + await this.refreshDecisionsFromDisk(); + }); + return; + } + + this.enqueue(async () => { + const [idPart] = file.split(" - "); + if (!idPart) return; + + const fullPath = join(decisionsDir, file); + const exists = await Bun.file(fullPath).exists(); + + if (!exists && eventType === "rename") { + if (this.decisions.delete(idPart)) { + this.cachedDecisions = sortByTaskId(Array.from(this.decisions.values())); + this.notify("decisions"); + } + return; + } + + if (eventType === "rename" && exists) { + await this.refreshDecisionsFromDisk(); + return; + } + + const previous = this.decisions.get(idPart); + const decision = await this.retryRead( + async () => { + try { + const content = await Bun.file(fullPath).text(); + return parseDecision(content); + } catch { + return null; + } + }, + (result) => { + if (!result) { + return false; + } + if (result.id !== idPart) { + return false; + } + if (!previous) { + return true; + } + return this.hasDecisionChanged(previous, result); + }, + ); + if (!decision) { + await this.refreshDecisionsFromDisk(idPart, previous); + return; + } + this.decisions.set(decision.id, decision); + this.cachedDecisions = sortByTaskId(Array.from(this.decisions.values())); + this.notify("decisions"); + }); + }); + this.attachWatcherErrorHandler(watcher, "decisions"); + + return { + stop() { + watcher.close(); + }, + }; + } + + private async createDocumentWatcher(): Promise<WatchHandle> { + const docsDir = this.filesystem.docsDir; + return this.createDirectoryWatcher(docsDir, async (eventType, absolutePath, relativePath) => { + const base = basename(absolutePath); + if (!base.endsWith(".md")) { + if (relativePath === null) { + await this.refreshDocumentsFromDisk(); + } + return; + } + + if (!base.startsWith("doc-")) { + await this.refreshDocumentsFromDisk(); + return; + } + + const [idPart] = base.split(" - "); + if (!idPart) { + await this.refreshDocumentsFromDisk(); + return; + } + + const exists = await Bun.file(absolutePath).exists(); + + if (!exists && eventType === "rename") { + if (this.documents.delete(idPart)) { + this.cachedDocuments = [...this.documents.values()].sort((a, b) => a.title.localeCompare(b.title)); + this.notify("documents"); + } + return; + } + + if (eventType === "rename" && exists) { + await this.refreshDocumentsFromDisk(); + return; + } + + const previous = this.documents.get(idPart); + const document = await this.retryRead( + async () => { + try { + const content = await Bun.file(absolutePath).text(); + return parseDocument(content); + } catch { + return null; + } + }, + (result) => { + if (!result) { + return false; + } + if (result.id !== idPart) { + return false; + } + if (!previous) { + return true; + } + return this.hasDocumentChanged(previous, result); + }, + ); + if (!document) { + await this.refreshDocumentsFromDisk(idPart, previous); + return; + } + + this.documents.set(document.id, document); + this.cachedDocuments = [...this.documents.values()].sort((a, b) => a.title.localeCompare(b.title)); + this.notify("documents"); + }); + } + + private normalizeFilename(value: string | Buffer | null | undefined): string | null { + if (typeof value === "string") { + return value; + } + if (value instanceof Buffer) { + return value.toString(); + } + return null; + } + + private async createDirectoryWatcher( + rootDir: string, + handler: (eventType: string, absolutePath: string, relativePath: string | null) => Promise<void> | void, + ): Promise<WatchHandle> { + try { + const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => { + const relativePath = this.normalizeFilename(filename); + const absolutePath = relativePath ? join(rootDir, relativePath) : rootDir; + + this.enqueue(async () => { + await handler(eventType, absolutePath, relativePath); + }); + }); + this.attachWatcherErrorHandler(watcher, `dir:${rootDir}`); + + return { + stop() { + watcher.close(); + }, + }; + } catch (error) { + if (this.isRecursiveUnsupported(error)) { + return this.createManualRecursiveWatcher(rootDir, handler); + } + throw error; + } + } + + private isRecursiveUnsupported(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const maybeError = error as { code?: string; message?: string }; + if (maybeError.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") { + return true; + } + return ( + typeof maybeError.message === "string" && + maybeError.message.toLowerCase().includes("recursive") && + maybeError.message.toLowerCase().includes("not supported") + ); + } + + private replaceTasks(tasks: Task[]): void { + this.tasks.clear(); + for (const task of tasks) { + this.tasks.set(task.id, task); + } + this.cachedTasks = sortByTaskId(Array.from(this.tasks.values())); + } + + private replaceDocuments(documents: Document[]): void { + this.documents.clear(); + for (const document of documents) { + this.documents.set(document.id, document); + } + this.cachedDocuments = [...this.documents.values()].sort((a, b) => a.title.localeCompare(b.title)); + } + + private replaceDecisions(decisions: Decision[]): void { + this.decisions.clear(); + for (const decision of decisions) { + this.decisions.set(decision.id, decision); + } + this.cachedDecisions = sortByTaskId(Array.from(this.decisions.values())); + } + + private patchFilesystem(): void { + if (this.restoreFilesystemPatch) { + return; + } + + const originalSaveTask = this.filesystem.saveTask; + const originalSaveDocument = this.filesystem.saveDocument; + const originalSaveDecision = this.filesystem.saveDecision; + + this.filesystem.saveTask = (async (task: Task): Promise<string> => { + const result = await originalSaveTask.call(this.filesystem, task); + await this.handleTaskWrite(task.id); + return result; + }) as FileSystem["saveTask"]; + + this.filesystem.saveDocument = (async (document: Document, subPath = ""): Promise<string> => { + const result = await originalSaveDocument.call(this.filesystem, document, subPath); + await this.handleDocumentWrite(document.id); + return result; + }) as FileSystem["saveDocument"]; + + this.filesystem.saveDecision = (async (decision: Decision): Promise<void> => { + await originalSaveDecision.call(this.filesystem, decision); + await this.handleDecisionWrite(decision.id); + }) as FileSystem["saveDecision"]; + + this.restoreFilesystemPatch = () => { + this.filesystem.saveTask = originalSaveTask; + this.filesystem.saveDocument = originalSaveDocument; + this.filesystem.saveDecision = originalSaveDecision; + }; + } + + private async handleTaskWrite(taskId: string): Promise<void> { + if (!this.initialized) { + return; + } + await this.updateTaskFromDisk(taskId); + } + + private async handleDocumentWrite(documentId: string): Promise<void> { + if (!this.initialized) { + return; + } + await this.refreshDocumentsFromDisk(documentId, this.documents.get(documentId)); + } + + private hasTaskChanged(previous: Task, next: Task): boolean { + return JSON.stringify(previous) !== JSON.stringify(next); + } + + private hasDocumentChanged(previous: Document, next: Document): boolean { + return JSON.stringify(previous) !== JSON.stringify(next); + } + + private hasDecisionChanged(previous: Decision, next: Decision): boolean { + return JSON.stringify(previous) !== JSON.stringify(next); + } + + private async refreshTasksFromDisk(expectedId?: string, previous?: Task): Promise<void> { + const tasks = await this.retryRead( + async () => this.loadTasksWithLoader(), + (expected) => { + if (!expectedId) { + return true; + } + const match = expected.find((task) => task.id === expectedId); + if (!match) { + return false; + } + if (previous && !this.hasTaskChanged(previous, match)) { + return false; + } + return true; + }, + ); + if (!tasks) { + return; + } + this.replaceTasks(tasks); + this.notify("tasks"); + } + + private async refreshDocumentsFromDisk(expectedId?: string, previous?: Document): Promise<void> { + const documents = await this.retryRead( + async () => this.filesystem.listDocuments(), + (expected) => { + if (!expectedId) { + return true; + } + const match = expected.find((doc) => doc.id === expectedId); + if (!match) { + return false; + } + if (previous && !this.hasDocumentChanged(previous, match)) { + return false; + } + return true; + }, + ); + if (!documents) { + return; + } + this.replaceDocuments(documents); + this.notify("documents"); + } + + private async refreshDecisionsFromDisk(expectedId?: string, previous?: Decision): Promise<void> { + const decisions = await this.retryRead( + async () => this.filesystem.listDecisions(), + (expected) => { + if (!expectedId) { + return true; + } + const match = expected.find((decision) => decision.id === expectedId); + if (!match) { + return false; + } + if (previous && !this.hasDecisionChanged(previous, match)) { + return false; + } + return true; + }, + ); + if (!decisions) { + return; + } + this.replaceDecisions(decisions); + this.notify("decisions"); + } + + private async handleDecisionWrite(decisionId: string): Promise<void> { + if (!this.initialized) { + return; + } + await this.updateDecisionFromDisk(decisionId); + } + + private async updateTaskFromDisk(taskId: string): Promise<void> { + const previous = this.tasks.get(taskId); + const task = await this.retryRead( + async () => this.filesystem.loadTask(taskId), + (result) => result !== null && (!previous || this.hasTaskChanged(previous, result)), + ); + if (!task) { + return; + } + this.tasks.set(task.id, task); + this.cachedTasks = sortByTaskId(Array.from(this.tasks.values())); + this.notify("tasks"); + } + + private async updateDecisionFromDisk(decisionId: string): Promise<void> { + const previous = this.decisions.get(decisionId); + const decision = await this.retryRead( + async () => this.filesystem.loadDecision(decisionId), + (result) => result !== null && (!previous || this.hasDecisionChanged(previous, result)), + ); + if (!decision) { + return; + } + this.decisions.set(decision.id, decision); + this.cachedDecisions = sortByTaskId(Array.from(this.decisions.values())); + this.notify("decisions"); + } + + private async createManualRecursiveWatcher( + rootDir: string, + handler: (eventType: string, absolutePath: string, relativePath: string | null) => Promise<void> | void, + ): Promise<WatchHandle> { + const watchers = new Map<string, FSWatcher>(); + let disposed = false; + + const removeSubtreeWatchers = (baseDir: string) => { + const prefix = baseDir.endsWith(sep) ? baseDir : `${baseDir}${sep}`; + for (const path of [...watchers.keys()]) { + if (path === baseDir || path.startsWith(prefix)) { + watchers.get(path)?.close(); + watchers.delete(path); + } + } + }; + + const addWatcher = async (dir: string): Promise<void> => { + if (disposed || watchers.has(dir)) { + return; + } + + const watcher = watch(dir, { recursive: false }, (eventType, filename) => { + if (disposed) { + return; + } + const relativePath = this.normalizeFilename(filename); + const absolutePath = relativePath ? join(dir, relativePath) : dir; + const normalizedRelative = relativePath ? relative(rootDir, absolutePath) : null; + + this.enqueue(async () => { + await handler(eventType, absolutePath, normalizedRelative); + + if (eventType === "rename" && relativePath) { + try { + const stats = await stat(absolutePath); + if (stats.isDirectory()) { + await addWatcher(absolutePath); + } + } catch { + removeSubtreeWatchers(absolutePath); + } + } + }); + }); + this.attachWatcherErrorHandler(watcher, `manual:${dir}`); + + watchers.set(dir, watcher); + + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + await addWatcher(entryPath); + continue; + } + + if (entry.isFile()) { + this.enqueue(async () => { + await handler("change", entryPath, relative(rootDir, entryPath)); + }); + } + } + } catch { + // Ignore transient directory enumeration issues + } + }; + + await addWatcher(rootDir); + + return { + stop() { + disposed = true; + for (const watcher of watchers.values()) { + watcher.close(); + } + watchers.clear(); + }, + }; + } + + private async retryRead<T>( + loader: () => Promise<T>, + isValid: (result: T) => boolean = (value) => value !== null && value !== undefined, + attempts = 12, + delayMs = 75, + ): Promise<T | null> { + let lastError: unknown = null; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const result = await loader(); + if (isValid(result)) { + return result; + } + } catch (error) { + lastError = error; + } + if (attempt < attempts) { + await this.delay(delayMs * attempt); + } + } + + if (lastError && process.env.DEBUG) { + console.error("ContentStore retryRead exhausted attempts", lastError); + } + return null; + } + + private async delay(ms: number): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + private enqueue(fn: () => Promise<void>): void { + this.chainTail = this.chainTail + .then(() => fn()) + .catch((error) => { + if (process.env.DEBUG) { + console.error("ContentStore update failed", error); + } + }); + } + + private async loadTasksWithLoader(): Promise<Task[]> { + if (this.taskLoader) { + return await this.taskLoader(); + } + return await this.filesystem.listTasks(); + } +} + +export type { ContentSnapshot }; diff --git a/src/core/cross-branch-tasks.ts b/src/core/cross-branch-tasks.ts new file mode 100644 index 0000000..0fb4763 --- /dev/null +++ b/src/core/cross-branch-tasks.ts @@ -0,0 +1,248 @@ +/** + * Cross-branch task state resolution + * Determines the latest state of tasks across all git branches + */ + +import { DEFAULT_DIRECTORIES } from "../constants/index.ts"; +import type { FileSystem } from "../file-system/operations.ts"; +import type { GitOperations as GitOps } from "../git/operations.ts"; +import type { Task } from "../types/index.ts"; + +export type TaskDirectoryType = "task" | "draft" | "archived" | "completed"; + +export interface TaskDirectoryInfo { + taskId: string; + type: TaskDirectoryType; + lastModified: Date; + branch: string; + path: string; +} + +/** + * Get the latest directory location of specific task IDs across all branches + * Only checks the provided task IDs for optimal performance + */ +export async function getLatestTaskStatesForIds( + gitOps: GitOps, + _filesystem: FileSystem, + taskIds: string[], + onProgress?: (message: string) => void, + options?: { recentBranchesOnly?: boolean; daysAgo?: number }, +): Promise<Map<string, TaskDirectoryInfo>> { + const taskDirectories = new Map<string, TaskDirectoryInfo>(); + + if (taskIds.length === 0) { + return taskDirectories; + } + + try { + // Get branches - use recent branches by default for performance + const useRecentOnly = options?.recentBranchesOnly ?? true; + const daysAgo = options?.daysAgo ?? 30; // Default to 30 days if not specified + + let branches = useRecentOnly ? await gitOps.listRecentBranches(daysAgo) : await gitOps.listAllBranches(); + + if (branches.length === 0) { + return taskDirectories; + } + + // Use standard backlog directory + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + + // Filter branches that actually have backlog changes + const branchesWithBacklog: string[] = []; + + // Quick check which branches actually have the backlog directory + for (const branch of branches) { + try { + // Just check if the backlog directory exists + const files = await gitOps.listFilesInTree(branch, backlogDir); + if (files.length > 0) { + branchesWithBacklog.push(branch); + } + } catch { + // Branch doesn't have backlog directory + } + } + + // Use filtered branches + branches = branchesWithBacklog; + + // Count local vs remote branches for info + const localBranches = branches.filter((b) => !b.includes("origin/")); + const remoteBranches = branches.filter((b) => b.includes("origin/")); + + const branchMsg = useRecentOnly + ? `${branches.length} branches with backlog (from ${daysAgo} days, ${localBranches.length} local, ${remoteBranches.length} remote)` + : `${branches.length} branches with backlog (${localBranches.length} local, ${remoteBranches.length} remote)`; + onProgress?.(`Checking ${taskIds.length} tasks across ${branchMsg}...`); + + // Create all file path combinations we need to check + const directoryChecks: Array<{ path: string; type: TaskDirectoryType }> = [ + { path: `${backlogDir}/tasks`, type: "task" }, + { path: `${backlogDir}/drafts`, type: "draft" }, + { path: `${backlogDir}/archive/tasks`, type: "archived" }, + { path: `${backlogDir}/completed`, type: "completed" }, + ]; + + // For better performance, prioritize checking current branch and main branch first + const priorityBranches = ["main", "master"]; + const currentBranch = await gitOps.getCurrentBranch(); + if (currentBranch && !priorityBranches.includes(currentBranch)) { + priorityBranches.unshift(currentBranch); + } + + // Check priority branches first + for (const branch of priorityBranches) { + if (!branches.includes(branch)) continue; + + // Remove from main list to avoid duplicate checking + branches = branches.filter((b) => b !== branch); + + // Quick check for all tasks in this branch + for (const { path, type } of directoryChecks) { + try { + const files = await gitOps.listFilesInTree(branch, path); + if (files.length === 0) continue; + + // Get all modification times in one pass + const modTimes = await gitOps.getBranchLastModifiedMap(branch, path); + + // Build file->id map for O(1) lookup + const fileToId = new Map<string, string>(); + for (const f of files) { + const filename = f.substring(f.lastIndexOf("/") + 1); + const match = filename.match(/^(task-\d+(?:\.\d+)?)/); + if (match?.[1]) { + fileToId.set(match[1], f); + } + } + + // Check each task ID + for (const taskId of taskIds) { + const taskFile = fileToId.get(taskId); + + if (taskFile) { + const lastModified = modTimes.get(taskFile); + if (lastModified) { + const existing = taskDirectories.get(taskId); + if (!existing || lastModified > existing.lastModified) { + taskDirectories.set(taskId, { + taskId, + type, + lastModified, + branch, + path: taskFile, + }); + } + } + } + } + } catch { + // Skip directories that don't exist + } + } + } + + // If we found all tasks in priority branches, we can skip other branches + if (taskDirectories.size === taskIds.length) { + onProgress?.(`Found all ${taskIds.length} tasks in priority branches`); + return taskDirectories; + } + + // For remaining tasks, check other branches + const remainingTaskIds = taskIds.filter((id) => !taskDirectories.has(id)); + if (remainingTaskIds.length === 0 || branches.length === 0) { + onProgress?.(`Checked ${taskIds.length} tasks`); + return taskDirectories; + } + + onProgress?.(`Checking ${remainingTaskIds.length} remaining tasks across ${branches.length} branches...`); + + // Check remaining branches in parallel batches + const BRANCH_BATCH_SIZE = 5; // Process 5 branches at a time for better performance + for (let i = 0; i < branches.length; i += BRANCH_BATCH_SIZE) { + const branchBatch = branches.slice(i, i + BRANCH_BATCH_SIZE); + + await Promise.all( + branchBatch.map(async (branch) => { + for (const { path, type } of directoryChecks) { + try { + const files = await gitOps.listFilesInTree(branch, path); + + if (files.length === 0) continue; + + // Get all modification times in one pass + const modTimes = await gitOps.getBranchLastModifiedMap(branch, path); + + // Build file->id map for O(1) lookup + const fileToId = new Map<string, string>(); + for (const f of files) { + const filename = f.substring(f.lastIndexOf("/") + 1); + const match = filename.match(/^(task-\d+(?:\.\d+)?)/); + if (match?.[1]) { + fileToId.set(match[1], f); + } + } + + for (const taskId of remainingTaskIds) { + // Skip if we already found this task + if (taskDirectories.has(taskId)) continue; + + const taskFile = fileToId.get(taskId); + + if (taskFile) { + const lastModified = modTimes.get(taskFile); + if (lastModified) { + const existing = taskDirectories.get(taskId); + if (!existing || lastModified > existing.lastModified) { + taskDirectories.set(taskId, { + taskId, + type, + lastModified, + branch, + path: taskFile, + }); + } + } + } + } + } catch { + // Skip directories that don't exist + } + } + }), + ); + + // Early exit if we found all tasks + if (taskDirectories.size === taskIds.length) { + break; + } + } + + onProgress?.(`Checked ${taskIds.length} tasks`); + } catch (error) { + console.error("Failed to get task directory locations for IDs:", error); + } + + return taskDirectories; +} + +/** + * Filter tasks based on their latest directory location across all branches + * Only returns tasks whose latest directory type is "task" (not draft, archived, or completed) + */ +export function filterTasksByLatestState(tasks: Task[], latestDirectories: Map<string, TaskDirectoryInfo>): Task[] { + return tasks.filter((task) => { + const latestDirectory = latestDirectories.get(task.id); + + // If we don't have directory info, assume it's an active task + if (!latestDirectory) { + return true; + } + + // Only show tasks whose latest directory type is "task" + // Completed, archived, and draft tasks should not appear on the main board + return latestDirectory.type === "task"; + }); +} diff --git a/src/core/init.ts b/src/core/init.ts new file mode 100644 index 0000000..7cc81bb --- /dev/null +++ b/src/core/init.ts @@ -0,0 +1,205 @@ +import { spawn } from "bun"; +import { + type AgentInstructionFile, + addAgentInstructions, + ensureMcpGuidelines, + installClaudeAgent, +} from "../agent-instructions.ts"; +import { DEFAULT_INIT_CONFIG } from "../constants/index.ts"; +import type { BacklogConfig } from "../types/index.ts"; +import type { Core } from "./backlog.ts"; + +export const MCP_SERVER_NAME = "backlog"; +export const MCP_GUIDE_URL = "https://github.com/MrLesk/Backlog.md#-mcp-integration-model-context-protocol"; + +export type IntegrationMode = "mcp" | "cli" | "none"; +export type McpClient = "claude" | "codex" | "gemini" | "guide"; + +export interface InitializeProjectOptions { + projectName: string; + integrationMode: IntegrationMode; + mcpClients?: McpClient[]; + agentInstructions?: AgentInstructionFile[]; + installClaudeAgent?: boolean; + advancedConfig?: { + checkActiveBranches?: boolean; + remoteOperations?: boolean; + activeBranchDays?: number; + bypassGitHooks?: boolean; + autoCommit?: boolean; + zeroPaddedIds?: number; + defaultEditor?: string; + defaultPort?: number; + autoOpenBrowser?: boolean; + }; + /** Existing config for re-initialization */ + existingConfig?: BacklogConfig | null; +} + +export interface InitializeProjectResult { + success: boolean; + projectName: string; + isReInitialization: boolean; + config: BacklogConfig; + mcpResults?: Record<string, string>; +} + +async function runMcpClientCommand(label: string, command: string, args: string[]): Promise<string> { + try { + const child = spawn({ + cmd: [command, ...args], + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`Command exited with code ${exitCode}`); + } + return `Added Backlog MCP server to ${label}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Unable to configure ${label} automatically (${message}). Run manually: ${command} ${args.join(" ")}`, + ); + } +} + +/** + * Core initialization logic shared between CLI and browser. + * Both CLI and browser validate input before calling this function. + */ +export async function initializeProject( + core: Core, + options: InitializeProjectOptions, +): Promise<InitializeProjectResult> { + const { + projectName, + integrationMode, + mcpClients = [], + agentInstructions = [], + installClaudeAgent: installClaudeAgentFlag = false, + advancedConfig = {}, + existingConfig, + } = options; + + const isReInitialization = !!existingConfig; + const projectRoot = core.filesystem.rootDir; + + // Build config, preserving existing values for re-initialization + const d = DEFAULT_INIT_CONFIG; + const config: BacklogConfig = { + projectName, + statuses: existingConfig?.statuses || ["To Do", "In Progress", "Done"], + labels: existingConfig?.labels || [], + milestones: existingConfig?.milestones || [], + defaultStatus: existingConfig?.defaultStatus || "To Do", + dateFormat: existingConfig?.dateFormat || "yyyy-mm-dd", + maxColumnWidth: existingConfig?.maxColumnWidth || 20, + autoCommit: advancedConfig.autoCommit ?? existingConfig?.autoCommit ?? d.autoCommit, + remoteOperations: advancedConfig.remoteOperations ?? existingConfig?.remoteOperations ?? d.remoteOperations, + bypassGitHooks: advancedConfig.bypassGitHooks ?? existingConfig?.bypassGitHooks ?? d.bypassGitHooks, + checkActiveBranches: + advancedConfig.checkActiveBranches ?? existingConfig?.checkActiveBranches ?? d.checkActiveBranches, + activeBranchDays: advancedConfig.activeBranchDays ?? existingConfig?.activeBranchDays ?? d.activeBranchDays, + defaultPort: advancedConfig.defaultPort ?? existingConfig?.defaultPort ?? d.defaultPort, + autoOpenBrowser: advancedConfig.autoOpenBrowser ?? existingConfig?.autoOpenBrowser ?? d.autoOpenBrowser, + taskResolutionStrategy: existingConfig?.taskResolutionStrategy || "most_recent", + ...(advancedConfig.defaultEditor ? { defaultEditor: advancedConfig.defaultEditor } : {}), + ...(typeof advancedConfig.zeroPaddedIds === "number" && advancedConfig.zeroPaddedIds > 0 + ? { zeroPaddedIds: advancedConfig.zeroPaddedIds } + : {}), + }; + + // Create structure and save config + if (isReInitialization) { + await core.filesystem.saveConfig(config); + } else { + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(config); + await core.ensureConfigLoaded(); + } + + const mcpResults: Record<string, string> = {}; + + // Handle MCP integration + if (integrationMode === "mcp" && mcpClients.length > 0) { + for (const client of mcpClients) { + try { + if (client === "claude") { + const result = await runMcpClientCommand("Claude Code", "claude", [ + "mcp", + "add", + "-s", + "user", + MCP_SERVER_NAME, + "--", + "backlog", + "mcp", + "start", + ]); + mcpResults.claude = result; + await ensureMcpGuidelines(projectRoot, "CLAUDE.md"); + } else if (client === "codex") { + const result = await runMcpClientCommand("OpenAI Codex", "codex", [ + "mcp", + "add", + MCP_SERVER_NAME, + "backlog", + "mcp", + "start", + ]); + mcpResults.codex = result; + await ensureMcpGuidelines(projectRoot, "AGENTS.md"); + } else if (client === "gemini") { + const result = await runMcpClientCommand("Gemini CLI", "gemini", [ + "mcp", + "add", + "-s", + "user", + MCP_SERVER_NAME, + "backlog", + "mcp", + "start", + ]); + mcpResults.gemini = result; + await ensureMcpGuidelines(projectRoot, "GEMINI.md"); + } else if (client === "guide") { + mcpResults.guide = `Setup guide: ${MCP_GUIDE_URL}`; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + mcpResults[client] = `Failed: ${message}`; + } + } + } + + // Handle CLI integration - agent instruction files + if (integrationMode === "cli" && agentInstructions.length > 0) { + try { + await addAgentInstructions(projectRoot, core.gitOps, agentInstructions, config.autoCommit); + mcpResults.agentFiles = `Created: ${agentInstructions.join(", ")}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + mcpResults.agentFiles = `Failed: ${message}`; + } + } + + // Handle Claude agent installation + if (integrationMode === "cli" && installClaudeAgentFlag) { + try { + await installClaudeAgent(projectRoot); + mcpResults.claudeAgent = "Installed to .claude/agents/"; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + mcpResults.claudeAgent = `Failed: ${message}`; + } + } + + return { + success: true, + projectName, + isReInitialization, + config, + mcpResults: Object.keys(mcpResults).length > 0 ? mcpResults : undefined, + }; +} diff --git a/src/core/reorder.ts b/src/core/reorder.ts new file mode 100644 index 0000000..95b6cb3 --- /dev/null +++ b/src/core/reorder.ts @@ -0,0 +1,96 @@ +import type { Task } from "../types/index.ts"; + +export const DEFAULT_ORDINAL_STEP = 1000; +const EPSILON = 1e-6; + +export interface CalculateNewOrdinalOptions { + previous?: Pick<Task, "id" | "ordinal"> | null; + next?: Pick<Task, "id" | "ordinal"> | null; + defaultStep?: number; +} + +export interface CalculateNewOrdinalResult { + ordinal: number; + requiresRebalance: boolean; +} + +export function calculateNewOrdinal(options: CalculateNewOrdinalOptions): CalculateNewOrdinalResult { + const { previous, next, defaultStep = DEFAULT_ORDINAL_STEP } = options; + const prevOrdinal = previous?.ordinal; + const nextOrdinal = next?.ordinal; + + if (prevOrdinal === undefined && nextOrdinal === undefined) { + return { ordinal: defaultStep, requiresRebalance: false }; + } + + if (prevOrdinal === undefined) { + if (nextOrdinal === undefined) { + return { ordinal: defaultStep, requiresRebalance: false }; + } + const candidate = nextOrdinal / 2; + const requiresRebalance = !Number.isFinite(candidate) || candidate <= 0 || candidate >= nextOrdinal - EPSILON; + return { ordinal: candidate, requiresRebalance }; + } + + if (nextOrdinal === undefined) { + const candidate = prevOrdinal + defaultStep; + const requiresRebalance = !Number.isFinite(candidate); + return { ordinal: candidate, requiresRebalance }; + } + + const gap = nextOrdinal - prevOrdinal; + if (gap <= EPSILON) { + return { ordinal: prevOrdinal + defaultStep, requiresRebalance: true }; + } + + const candidate = prevOrdinal + gap / 2; + const requiresRebalance = candidate <= prevOrdinal + EPSILON || candidate >= nextOrdinal - EPSILON; + return { ordinal: candidate, requiresRebalance }; +} + +export interface ResolveOrdinalConflictsOptions { + defaultStep?: number; + startOrdinal?: number; + forceSequential?: boolean; +} + +export function resolveOrdinalConflicts<T extends { id: string; ordinal?: number }>( + tasks: T[], + options: ResolveOrdinalConflictsOptions = {}, +): T[] { + const defaultStep = options.defaultStep ?? DEFAULT_ORDINAL_STEP; + const startOrdinal = options.startOrdinal ?? defaultStep; + const forceSequential = options.forceSequential ?? false; + + const updates: T[] = []; + let lastOrdinal: number | undefined; + + for (let index = 0; index < tasks.length; index += 1) { + const task = tasks[index]; + if (!task) { + continue; + } + let assigned: number; + + if (forceSequential) { + assigned = index === 0 ? startOrdinal : (lastOrdinal ?? startOrdinal) + defaultStep; + } else if (task.ordinal === undefined) { + assigned = index === 0 ? startOrdinal : (lastOrdinal ?? startOrdinal) + defaultStep; + } else if (lastOrdinal !== undefined && task.ordinal <= lastOrdinal) { + assigned = lastOrdinal + defaultStep; + } else { + assigned = task.ordinal; + } + + if (assigned !== task.ordinal) { + updates.push({ + ...task, + ordinal: assigned, + }); + } + + lastOrdinal = assigned; + } + + return updates; +} diff --git a/src/core/search-service.ts b/src/core/search-service.ts new file mode 100644 index 0000000..47d1f09 --- /dev/null +++ b/src/core/search-service.ts @@ -0,0 +1,418 @@ +import Fuse, { type FuseResult, type FuseResultMatch } from "fuse.js"; +import type { + Decision, + Document, + SearchFilters, + SearchMatch, + SearchOptions, + SearchPriorityFilter, + SearchResult, + SearchResultType, + Task, +} from "../types/index.ts"; +import type { ContentStore, ContentStoreEvent } from "./content-store.ts"; + +interface BaseSearchEntity { + readonly id: string; + readonly type: SearchResultType; + readonly title: string; + readonly bodyText: string; +} + +interface TaskSearchEntity extends BaseSearchEntity { + readonly type: "task"; + readonly task: Task; + readonly statusLower: string; + readonly priorityLower?: SearchPriorityFilter; + readonly idVariants: string[]; + readonly dependencyIds: string[]; +} + +interface DocumentSearchEntity extends BaseSearchEntity { + readonly type: "document"; + readonly document: Document; +} + +interface DecisionSearchEntity extends BaseSearchEntity { + readonly type: "decision"; + readonly decision: Decision; +} + +type SearchEntity = TaskSearchEntity | DocumentSearchEntity | DecisionSearchEntity; + +type NormalizedFilters = { + statuses?: string[]; + priorities?: SearchPriorityFilter[]; +}; + +const TASK_ID_PREFIX = "task-"; + +function parseTaskIdSegments(value: string): number[] | null { + const withoutPrefix = value.startsWith(TASK_ID_PREFIX) ? value.slice(TASK_ID_PREFIX.length) : value; + if (!/^[0-9]+(?:\.[0-9]+)*$/.test(withoutPrefix)) { + return null; + } + return withoutPrefix.split(".").map((segment) => Number.parseInt(segment, 10)); +} + +function createTaskIdVariants(id: string): string[] { + const segments = parseTaskIdSegments(id); + if (!segments) { + const normalized = id.startsWith(TASK_ID_PREFIX) ? id : `${TASK_ID_PREFIX}${id}`; + return id === normalized ? [normalized] : [normalized, id]; + } + const canonicalSuffix = segments.join("."); + const variants = new Set<string>(); + const normalized = id.startsWith(TASK_ID_PREFIX) ? id : `${TASK_ID_PREFIX}${id}`; + variants.add(normalized); + variants.add(`${TASK_ID_PREFIX}${canonicalSuffix}`); + variants.add(canonicalSuffix); + if (id !== normalized) { + variants.add(id); + } + return Array.from(variants); +} + +export class SearchService { + private initialized = false; + private initializing: Promise<void> | null = null; + private unsubscribe?: () => void; + private fuse: Fuse<SearchEntity> | null = null; + private tasks: TaskSearchEntity[] = []; + private documents: DocumentSearchEntity[] = []; + private decisions: DecisionSearchEntity[] = []; + private collection: SearchEntity[] = []; + private version = 0; + + constructor(private readonly store: ContentStore) {} + + async ensureInitialized(): Promise<void> { + if (this.initialized) { + return; + } + + if (!this.initializing) { + this.initializing = this.initialize().catch((error) => { + this.initializing = null; + throw error; + }); + } + + await this.initializing; + } + + dispose(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = undefined; + } + this.fuse = null; + this.collection = []; + this.tasks = []; + this.documents = []; + this.decisions = []; + this.initialized = false; + this.initializing = null; + } + + search(options: SearchOptions = {}): SearchResult[] { + if (!this.initialized) { + throw new Error("SearchService not initialized. Call ensureInitialized() first."); + } + + const { query = "", limit, types, filters } = options; + + const trimmedQuery = query.trim(); + const allowedTypes = new Set<SearchResultType>( + types && types.length > 0 ? types : ["task", "document", "decision"], + ); + const normalizedFilters = this.normalizeFilters(filters); + + if (trimmedQuery === "") { + return this.collectWithoutQuery(allowedTypes, normalizedFilters, limit); + } + + const fuse = this.fuse; + if (!fuse) { + return []; + } + + const fuseResults = fuse.search(trimmedQuery); + const results: SearchResult[] = []; + + for (const result of fuseResults) { + const entity = result.item; + if (!allowedTypes.has(entity.type)) { + continue; + } + + if (entity.type === "task" && !this.matchesTaskFilters(entity, normalizedFilters)) { + continue; + } + + results.push(this.mapEntityToResult(entity, result)); + if (limit && results.length >= limit) { + break; + } + } + + return results; + } + + private async initialize(): Promise<void> { + const snapshot = await this.store.ensureInitialized(); + this.applySnapshot(snapshot.tasks, snapshot.documents, snapshot.decisions); + + if (!this.unsubscribe) { + this.unsubscribe = this.store.subscribe((event) => { + this.handleStoreEvent(event); + }); + } + + this.initialized = true; + this.initializing = null; + } + + private handleStoreEvent(event: ContentStoreEvent): void { + if (event.version <= this.version) { + return; + } + this.version = event.version; + this.applySnapshot(event.snapshot.tasks, event.snapshot.documents, event.snapshot.decisions); + } + + private applySnapshot(tasks: Task[], documents: Document[], decisions: Decision[]): void { + this.tasks = tasks.map((task) => ({ + id: task.id, + type: "task", + title: task.title, + bodyText: buildTaskBodyText(task), + task, + statusLower: task.status.toLowerCase(), + priorityLower: task.priority ? (task.priority.toLowerCase() as SearchPriorityFilter) : undefined, + idVariants: createTaskIdVariants(task.id), + dependencyIds: (task.dependencies ?? []).flatMap((dependency) => createTaskIdVariants(dependency)), + })); + + this.documents = documents.map((document) => ({ + id: document.id, + type: "document", + title: document.title, + bodyText: document.rawContent ?? "", + document, + })); + + this.decisions = decisions.map((decision) => ({ + id: decision.id, + type: "decision", + title: decision.title, + bodyText: decision.rawContent ?? "", + decision, + })); + + this.collection = [...this.tasks, ...this.documents, ...this.decisions]; + this.rebuildFuse(); + } + + private rebuildFuse(): void { + if (this.collection.length === 0) { + this.fuse = null; + return; + } + + this.fuse = new Fuse(this.collection, { + includeScore: true, + includeMatches: true, + threshold: 0.35, + ignoreLocation: true, + minMatchCharLength: 2, + keys: [ + { name: "title", weight: 0.35 }, + { name: "bodyText", weight: 0.3 }, + { name: "id", weight: 0.2 }, + { name: "idVariants", weight: 0.1 }, + { name: "dependencyIds", weight: 0.05 }, + ], + }); + } + + private collectWithoutQuery( + allowedTypes: Set<SearchResultType>, + filters: NormalizedFilters, + limit?: number, + ): SearchResult[] { + const results: SearchResult[] = []; + + if (allowedTypes.has("task")) { + const tasks = this.applyTaskFilters(this.tasks, filters); + for (const entity of tasks) { + results.push(this.mapEntityToResult(entity)); + if (limit && results.length >= limit) { + return results; + } + } + } + + if (allowedTypes.has("document")) { + for (const entity of this.documents) { + results.push(this.mapEntityToResult(entity)); + if (limit && results.length >= limit) { + return results; + } + } + } + + if (allowedTypes.has("decision")) { + for (const entity of this.decisions) { + results.push(this.mapEntityToResult(entity)); + if (limit && results.length >= limit) { + return results; + } + } + } + + return results; + } + + private applyTaskFilters(tasks: TaskSearchEntity[], filters: NormalizedFilters): TaskSearchEntity[] { + let filtered = tasks; + if (filters.statuses && filters.statuses.length > 0) { + const allowedStatuses = new Set(filters.statuses); + filtered = filtered.filter((task) => allowedStatuses.has(task.statusLower)); + } + if (filters.priorities && filters.priorities.length > 0) { + const allowedPriorities = new Set(filters.priorities); + filtered = filtered.filter((task) => { + if (!task.priorityLower) { + return false; + } + return allowedPriorities.has(task.priorityLower); + }); + } + return filtered; + } + + private matchesTaskFilters(task: TaskSearchEntity, filters: NormalizedFilters): boolean { + if (filters.statuses && filters.statuses.length > 0) { + if (!filters.statuses.includes(task.statusLower)) { + return false; + } + } + + if (filters.priorities && filters.priorities.length > 0) { + if (!task.priorityLower || !filters.priorities.includes(task.priorityLower)) { + return false; + } + } + + return true; + } + + private normalizeFilters(filters?: SearchFilters): NormalizedFilters { + if (!filters) { + return {}; + } + + const statuses = this.normalizeStringArray(filters.status); + const priorities = this.normalizePriorityArray(filters.priority); + + return { + statuses, + priorities, + }; + } + + private normalizeStringArray(value?: string | string[]): string[] | undefined { + if (!value) { + return undefined; + } + + const values = Array.isArray(value) ? value : [value]; + const normalized = values.map((item) => item.trim().toLowerCase()).filter((item) => item.length > 0); + + return normalized.length > 0 ? normalized : undefined; + } + + private normalizePriorityArray( + value?: SearchPriorityFilter | SearchPriorityFilter[], + ): SearchPriorityFilter[] | undefined { + if (!value) { + return undefined; + } + + const values = Array.isArray(value) ? value : [value]; + const normalized = values + .map((item) => item.trim().toLowerCase()) + .filter((item): item is SearchPriorityFilter => { + return item === "high" || item === "medium" || item === "low"; + }); + + return normalized.length > 0 ? normalized : undefined; + } + + private mapEntityToResult(entity: SearchEntity, result?: FuseResult<SearchEntity>): SearchResult { + const score = result?.score ?? null; + const matches = this.mapMatches(result?.matches); + + if (entity.type === "task") { + return { + type: "task", + score, + task: entity.task, + matches, + }; + } + + if (entity.type === "document") { + return { + type: "document", + score, + document: entity.document, + matches, + }; + } + + return { + type: "decision", + score, + decision: entity.decision, + matches, + }; + } + + private mapMatches(matches?: readonly FuseResultMatch[]): SearchMatch[] | undefined { + if (!matches || matches.length === 0) { + return undefined; + } + + return matches.map((match) => ({ + key: match.key, + indices: match.indices.map(([start, end]) => [start, end] as [number, number]), + value: match.value, + })); + } +} +function buildTaskBodyText(task: Task): string { + const parts: string[] = []; + + if (task.description) { + parts.push(task.description); + } + + if (Array.isArray(task.acceptanceCriteriaItems) && task.acceptanceCriteriaItems.length > 0) { + const lines = [...task.acceptanceCriteriaItems] + .sort((a, b) => a.index - b.index) + .map((criterion) => `- [${criterion.checked ? "x" : " "}] ${criterion.text}`); + parts.push(lines.join("\n")); + } + + if (task.implementationPlan) { + parts.push(task.implementationPlan); + } + + if (task.implementationNotes) { + parts.push(task.implementationNotes); + } + + return parts.join("\n\n"); +} diff --git a/src/core/sequences.ts b/src/core/sequences.ts new file mode 100644 index 0000000..79e1fb5 --- /dev/null +++ b/src/core/sequences.ts @@ -0,0 +1,266 @@ +import type { Sequence, Task } from "../types/index.ts"; +import { sortByTaskId } from "../utils/task-sorting.ts"; + +/** + * Compute execution sequences (layers) from task dependencies. + * - Sequence 1 contains tasks with no dependencies among the provided set. + * - Subsequent sequences contain tasks whose dependencies appear in earlier sequences. + * - Dependencies that reference tasks outside the provided set are ignored for layering. + * - If cycles exist, any remaining tasks are emitted in a final sequence to ensure each task + * appears exactly once (consumers may choose to surface a warning in that case). + */ +export function computeSequences(tasks: Task[]): { unsequenced: Task[]; sequences: Sequence[] } { + // Map task id -> task for fast lookups + const byId = new Map<string, Task>(); + for (const t of tasks) byId.set(t.id, t); + + const allIds = new Set(Array.from(byId.keys())); + + // Build adjacency using only edges within provided set + const successors = new Map<string, string[]>(); + const indegree = new Map<string, number>(); + for (const id of allIds) { + successors.set(id, []); + indegree.set(id, 0); + } + for (const t of tasks) { + const deps = Array.isArray(t.dependencies) ? t.dependencies : []; + for (const dep of deps) { + if (!allIds.has(dep)) continue; // ignore external deps for layering + successors.get(dep)?.push(t.id); + indegree.set(t.id, (indegree.get(t.id) || 0) + 1); + } + } + + // Identify isolated tasks: absolutely no dependencies (even external) AND no internal dependents + const hasAnyDeps = (t: Task) => (t.dependencies || []).length > 0; + const hasDependents = (id: string) => (successors.get(id) || []).length > 0; + + const unsequenced = sortByTaskId( + tasks.filter((t) => !hasAnyDeps(t) && !hasDependents(t.id) && t.ordinal === undefined), + ); + + // Build layering set by excluding unsequenced tasks + const layeringIds = new Set(Array.from(allIds).filter((id) => !unsequenced.some((t) => t.id === id))); + + // Kahn-style layered topological grouping on the remainder + const sequences: Sequence[] = []; + const remaining = new Set(layeringIds); + + // Prepare local indegree copy considering only remaining nodes + const indegRem = new Map<string, number>(); + for (const id of remaining) indegRem.set(id, 0); + for (const id of remaining) { + const t = byId.get(id); + if (!t) continue; + for (const dep of t.dependencies || []) { + if (remaining.has(dep)) indegRem.set(id, (indegRem.get(id) || 0) + 1); + } + } + + while (remaining.size > 0) { + const layerIds: string[] = []; + for (const id of remaining) { + if ((indegRem.get(id) || 0) === 0) layerIds.push(id); + } + + if (layerIds.length === 0) { + // Cycle detected; emit all remaining nodes as final layer (deterministic order) + const finalTasks = sortByTaskId( + Array.from(remaining) + .map((id) => byId.get(id)) + .filter((t): t is Task => Boolean(t)), + ); + sequences.push({ index: sequences.length + 1, tasks: finalTasks }); + break; + } + + const layerTasks = sortByTaskId(layerIds.map((id) => byId.get(id)).filter((t): t is Task => Boolean(t))); + sequences.push({ index: sequences.length + 1, tasks: layerTasks }); + + for (const id of layerIds) { + remaining.delete(id); + for (const succ of successors.get(id) || []) { + if (!remaining.has(succ)) continue; + indegRem.set(succ, (indegRem.get(succ) || 0) - 1); + } + } + } + + return { unsequenced, sequences }; +} + +/** + * Return true if the task has no dependencies and no dependents among the provided set. + * Note: Ordinal is intentionally ignored here; computeSequences handles ordinal when grouping. + */ +export function canMoveToUnsequenced(tasks: Task[], taskId: string): boolean { + const byId = new Map<string, Task>(tasks.map((t) => [t.id, t])); + const t = byId.get(taskId); + if (!t) return false; + const allIds = new Set(byId.keys()); + const hasDeps = (t.dependencies || []).some((d) => allIds.has(d)); + if (hasDeps) return false; + const hasDependents = tasks.some((x) => (x.dependencies || []).includes(taskId)); + return !hasDependents; +} + +/** + * Adjust dependencies when moving a task to a target sequence index. + * + * Rules: + * - Set moved task's dependencies to all task IDs from the immediately previous + * sequence (targetIndex - 1). If targetIndex is 1, dependencies become []. + * - Add the moved task as a dependency to all tasks in the immediately next + * sequence (targetIndex + 1). Duplicates are removed. + * - Other dependencies remain unchanged for other tasks. + */ +export function adjustDependenciesForMove( + tasks: Task[], + sequences: Sequence[], + movedTaskId: string, + targetSequenceIndex: number, +): Task[] { + // Join semantics: set moved.dependencies to previous sequence tasks (if any), + // do NOT add moved as a dependency to next-sequence tasks, and do not touch others. + const byId = new Map<string, Task>(tasks.map((t) => [t.id, { ...t }])); + const moved = byId.get(movedTaskId); + if (!moved) return tasks; + + const prevSeq = sequences.find((s) => s.index === targetSequenceIndex - 1); + // Exclude the moved task itself to avoid creating a self-dependency when moving from seq N to N+1 + const prevIds = prevSeq ? prevSeq.tasks.map((t) => t.id).filter((id) => id !== movedTaskId) : []; + + moved.dependencies = [...prevIds]; + byId.set(moved.id, moved); + + return Array.from(byId.values()); +} + +/** + * Insert a new sequence by dropping a task between two existing sequences. + * + * Semantics (K in [0..N]): + * - Dropping between Sequence K and K+1 creates a new Sequence K+1 containing the moved task. + * - Update dependencies so that: + * - moved.dependencies = all task IDs from Sequence K (or [] when K = 0), excluding itself. + * - every task currently in Sequence K+1 adds the moved task ID to its dependencies (deduped). + * - No other tasks are modified. + * - Special case when there is no next sequence (K = N): only moved.dependencies are updated. + * - Special case when K = 0 and there is no next sequence and moved.dependencies remain empty: + * assign moved.ordinal = 0 to ensure it participates in layering (avoids Unsequenced bucket). + */ +export function adjustDependenciesForInsertBetween( + tasks: Task[], + sequences: Sequence[], + movedTaskId: string, + betweenK: number, +): Task[] { + const byId = new Map<string, Task>(tasks.map((t) => [t.id, { ...t }])); + const moved = byId.get(movedTaskId); + if (!moved) return tasks; + + // Normalize K to integer within [0..N] + const maxK = sequences.length; + const K = Math.max(0, Math.min(maxK, Math.floor(betweenK))); + + const prevSeq = sequences.find((s) => s.index === K); + const nextSeq = sequences.find((s) => s.index === K + 1); + + const prevIds = prevSeq ? prevSeq.tasks.map((t) => t.id).filter((id) => id !== movedTaskId) : []; + moved.dependencies = [...prevIds]; + + // Update next sequence tasks to depend on moved task + if (nextSeq) { + for (const t of nextSeq.tasks) { + const orig = byId.get(t.id); + if (!orig) continue; + const deps = Array.isArray(orig.dependencies) ? orig.dependencies : []; + if (!deps.includes(movedTaskId)) orig.dependencies = [...deps, movedTaskId]; + byId.set(orig.id, orig); + } + } else { + // No next sequence; if K = 0 and moved has no deps, ensure it stays sequenced + if (K === 0 && (!moved.dependencies || moved.dependencies.length === 0)) { + if (moved.ordinal === undefined) moved.ordinal = 0; + } + } + + byId.set(moved.id, moved); + return Array.from(byId.values()); +} + +/** + * Reorder tasks within a sequence by assigning ordinal values. + * Does not modify dependencies. Only tasks in the provided sequenceTaskIds are re-assigned ordinals. + */ +export function reorderWithinSequence( + tasks: Task[], + sequenceTaskIds: string[], + movedTaskId: string, + newIndex: number, +): Task[] { + const seqIds = sequenceTaskIds.filter((id) => id && tasks.some((t) => t.id === id)); + const withoutMoved = seqIds.filter((id) => id !== movedTaskId); + const clampedIndex = Math.max(0, Math.min(withoutMoved.length, newIndex)); + const newOrder = [...withoutMoved.slice(0, clampedIndex), movedTaskId, ...withoutMoved.slice(clampedIndex)]; + + const byId = new Map<string, Task>(tasks.map((t) => [t.id, { ...t }])); + newOrder.forEach((id, idx) => { + const t = byId.get(id); + if (t) { + t.ordinal = idx; + byId.set(id, t); + } + }); + return Array.from(byId.values()); +} + +/** + * Plan a move into a target sequence using join semantics. + * Returns only the tasks that changed (dependencies and/or ordinal). + */ +export function planMoveToSequence( + allTasks: Task[], + sequences: Sequence[], + movedTaskId: string, + targetSequenceIndex: number, +): Task[] { + const updated = adjustDependenciesForMove(allTasks, sequences, movedTaskId, targetSequenceIndex); + // If moving to Sequence 1 and resulting deps are empty, anchor with ordinal 0 + if (targetSequenceIndex === 1) { + const movedU = updated.find((x) => x.id === movedTaskId); + if (movedU && (!movedU.dependencies || movedU.dependencies.length === 0)) { + if (movedU.ordinal === undefined) movedU.ordinal = 0; + } + } + const byIdOrig = new Map(allTasks.map((t) => [t.id, t])); + const changed: Task[] = []; + for (const u of updated) { + const orig = byIdOrig.get(u.id); + if (!orig) continue; + const depsChanged = JSON.stringify(orig.dependencies) !== JSON.stringify(u.dependencies); + const ordChanged = (orig.ordinal ?? null) !== (u.ordinal ?? null); + if (depsChanged || ordChanged) changed.push(u); + } + return changed; +} + +/** + * Plan a move to Unsequenced. Returns changed tasks or an error message when not eligible. + */ +export function planMoveToUnsequenced( + allTasks: Task[], + movedTaskId: string, +): { ok: true; changed: Task[] } | { ok: false; error: string } { + if (!canMoveToUnsequenced(allTasks, movedTaskId)) { + return { ok: false, error: "Cannot move to Unsequenced: task has dependencies or dependents" }; + } + const byId = new Map(allTasks.map((t) => [t.id, { ...t }])); + const moved = byId.get(movedTaskId); + if (!moved) return { ok: false, error: "Task not found" }; + moved.dependencies = []; + // Clear ordinal to ensure it is considered Unsequenced (no ordinal) + if (moved.ordinal !== undefined) moved.ordinal = undefined; + return { ok: true, changed: [moved] }; +} diff --git a/src/core/statistics.ts b/src/core/statistics.ts new file mode 100644 index 0000000..b58645a --- /dev/null +++ b/src/core/statistics.ts @@ -0,0 +1,162 @@ +import type { Task } from "../types/index.ts"; + +export interface TaskStatistics { + statusCounts: Map<string, number>; + priorityCounts: Map<string, number>; + totalTasks: number; + completedTasks: number; + completionPercentage: number; + draftCount: number; + recentActivity: { + created: Task[]; + updated: Task[]; + }; + projectHealth: { + averageTaskAge: number; + staleTasks: Task[]; + blockedTasks: Task[]; + }; +} + +/** + * Calculate comprehensive task statistics for the overview + */ +export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: string[]): TaskStatistics { + const statusCounts = new Map<string, number>(); + const priorityCounts = new Map<string, number>(); + + // Initialize status counts + for (const status of statuses) { + statusCounts.set(status, 0); + } + + // Initialize priority counts + priorityCounts.set("high", 0); + priorityCounts.set("medium", 0); + priorityCounts.set("low", 0); + priorityCounts.set("none", 0); + + let completedTasks = 0; + const now = new Date(); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const recentlyCreated: Task[] = []; + const recentlyUpdated: Task[] = []; + const staleTasks: Task[] = []; + const blockedTasks: Task[] = []; + let totalAge = 0; + let taskCount = 0; + + // Process each task + for (const task of tasks) { + // Skip tasks with empty or undefined status + if (!task.status || task.status === "") { + continue; + } + + // Count by status + const currentCount = statusCounts.get(task.status) || 0; + statusCounts.set(task.status, currentCount + 1); + + // Count completed tasks + if (task.status === "Done") { + completedTasks++; + } + + // Count by priority + const priority = task.priority || "none"; + const priorityCount = priorityCounts.get(priority) || 0; + priorityCounts.set(priority, priorityCount + 1); + + // Track recent activity + if (task.createdDate) { + const createdDate = new Date(task.createdDate); + if (createdDate >= oneWeekAgo) { + recentlyCreated.push(task); + } + + // Calculate task age + // For completed tasks, use the time from creation to completion + // For active tasks, use the time from creation to now + let ageInDays: number; + if (task.status === "Done" && task.updatedDate) { + const updatedDate = new Date(task.updatedDate); + ageInDays = Math.floor((updatedDate.getTime() - createdDate.getTime()) / (24 * 60 * 60 * 1000)); + } else { + ageInDays = Math.floor((now.getTime() - createdDate.getTime()) / (24 * 60 * 60 * 1000)); + } + totalAge += ageInDays; + taskCount++; + } + + if (task.updatedDate) { + const updatedDate = new Date(task.updatedDate); + if (updatedDate >= oneWeekAgo) { + recentlyUpdated.push(task); + } + } + + // Identify stale tasks (not updated in 30 days and not done) + if (task.status !== "Done") { + const lastDate = task.updatedDate || task.createdDate; + if (lastDate) { + const date = new Date(lastDate); + if (date < oneMonthAgo) { + staleTasks.push(task); + } + } + } + + // Identify blocked tasks (has dependencies that are not done) + if (task.dependencies && task.dependencies.length > 0 && task.status !== "Done") { + // Check if any dependency is not done + const hasBlockingDependency = task.dependencies.some((depId) => { + const dep = tasks.find((t) => t.id === depId); + return dep && dep.status !== "Done"; + }); + + if (hasBlockingDependency) { + blockedTasks.push(task); + } + } + } + + // Sort recent activity by date + recentlyCreated.sort((a, b) => { + const dateA = new Date(a.createdDate || 0); + const dateB = new Date(b.createdDate || 0); + return dateB.getTime() - dateA.getTime(); + }); + + recentlyUpdated.sort((a, b) => { + const dateA = new Date(a.updatedDate || 0); + const dateB = new Date(b.updatedDate || 0); + return dateB.getTime() - dateA.getTime(); + }); + + // Calculate average task age + const averageTaskAge = taskCount > 0 ? Math.round(totalAge / taskCount) : 0; + + // Calculate completion percentage (only count tasks with valid status) + const totalTasks = Array.from(statusCounts.values()).reduce((sum, count) => sum + count, 0); + const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + return { + statusCounts, + priorityCounts, + totalTasks, + completedTasks, + completionPercentage, + draftCount: drafts.length, + recentActivity: { + created: recentlyCreated.slice(0, 5), // Top 5 most recent + updated: recentlyUpdated.slice(0, 5), // Top 5 most recent + }, + projectHealth: { + averageTaskAge, + staleTasks: staleTasks.slice(0, 5), // Top 5 stale tasks + blockedTasks: blockedTasks.slice(0, 5), // Top 5 blocked tasks + }, + }; +} diff --git a/src/core/task-loader.ts b/src/core/task-loader.ts new file mode 100644 index 0000000..dfb7ee8 --- /dev/null +++ b/src/core/task-loader.ts @@ -0,0 +1,605 @@ +/** + * Task loading with optimized index-first, hydrate-later pattern + * Dramatically reduces git operations for multi-branch task loading + * + * This is the single module for all cross-branch task loading: + * - Local filesystem tasks + * - Other local branch tasks + * - Remote branch tasks + */ + +import { DEFAULT_DIRECTORIES } from "../constants/index.ts"; +import type { GitOperations } from "../git/operations.ts"; +import { parseTask } from "../markdown/parser.ts"; +import type { BacklogConfig, Task } from "../types/index.ts"; + +/** + * Get the appropriate loading message based on remote operations configuration + */ +export function getTaskLoadingMessage(config: BacklogConfig | null): string { + return config?.remoteOperations === false + ? "Loading tasks from local branches..." + : "Loading tasks from local and remote branches..."; +} + +interface RemoteIndexEntry { + id: string; + branch: string; + path: string; // "backlog/tasks/task-123 - title.md" + lastModified: Date; +} + +function normalizeRemoteBranch(branch: string): string | null { + let br = branch.trim(); + if (!br) return null; + br = br.replace(/^refs\/remotes\//, ""); + if (br === "origin" || br === "HEAD" || br === "origin/HEAD") return null; + if (br.startsWith("origin/")) br = br.slice("origin/".length); + // Filter weird cases like "origin" again after stripping prefix + if (!br || br === "HEAD" || br === "origin") return null; + return br; +} + +/** + * Normalize a local branch name, filtering out invalid entries + */ +function normalizeLocalBranch(branch: string, currentBranch: string): string | null { + const br = branch.trim(); + if (!br) return null; + // Skip HEAD, origin refs, and current branch + if (br === "HEAD" || br.includes("HEAD")) return null; + if (br.startsWith("origin/") || br.startsWith("refs/remotes/")) return null; + if (br === "origin") return null; + // Skip current branch - we already have its tasks from filesystem + if (br === currentBranch) return null; + return br; +} + +/** + * Build a cheap index of remote tasks without fetching content + * This is VERY fast as it only lists files and gets modification times in batch + */ +export async function buildRemoteTaskIndex( + git: GitOperations, + branches: string[], + backlogDir = "backlog", + sinceDays?: number, +): Promise<Map<string, RemoteIndexEntry[]>> { + const out = new Map<string, RemoteIndexEntry[]>(); + + const normalized = branches.map(normalizeRemoteBranch).filter((b): b is string => Boolean(b)); + + // Do branches in parallel but not unbounded + const CONCURRENCY = 4; + const queue = [...normalized]; + + const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => { + while (queue.length) { + const br = queue.pop(); + if (!br) break; + + const ref = `origin/${br}`; + + try { + // Get all task files in this branch + const files = await git.listFilesInTree(ref, `${backlogDir}/tasks`); + if (files.length === 0) continue; + + // Get last modified times for all files in one pass + const lm = await git.getBranchLastModifiedMap(ref, `${backlogDir}/tasks`, sinceDays); + + for (const f of files) { + // Extract task ID from filename + // Extract task ID from filename (support subtasks like task-123.01) + const m = f.match(/task-(\d+(?:\.\d+)?)/); + if (!m) continue; + + const id = `task-${m[1]}`; + const lastModified = lm.get(f) ?? new Date(0); + const entry: RemoteIndexEntry = { id, branch: br, path: f, lastModified }; + + const arr = out.get(id); + if (arr) { + arr.push(entry); + } else { + out.set(id, [entry]); + } + } + } catch (error) { + // Branch might not have backlog directory, skip it + console.debug(`Skipping branch ${br}: ${error}`); + } + } + }); + + await Promise.all(workers); + return out; +} + +/** + * Hydrate tasks by fetching their content + * Only call this for the "winner" tasks that we actually need + */ +async function hydrateTasks( + git: GitOperations, + winners: Array<{ id: string; ref: string; path: string }>, +): Promise<Task[]> { + const CONCURRENCY = 8; + const result: Task[] = []; + let i = 0; + + async function worker() { + while (i < winners.length) { + const idx = i++; + if (idx >= winners.length) break; + + const w = winners[idx]; + if (!w) break; + + try { + const content = await git.showFile(w.ref, w.path); + const task = parseTask(content); + if (task) { + // Mark as remote source and branch + task.source = "remote"; + // Extract branch name from ref (e.g., "origin/main" -> "main") + task.branch = w.ref.replace("origin/", ""); + result.push(task); + } + } catch (error) { + console.error(`Failed to hydrate task ${w.id} from ${w.ref}:${w.path}`, error); + } + } + } + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, winners.length) }, worker)); + return result; +} + +/** + * Build a cheap index of tasks from local branches (excluding current branch) + * Similar to buildRemoteTaskIndex but for local refs + */ +export async function buildLocalBranchTaskIndex( + git: GitOperations, + branches: string[], + currentBranch: string, + backlogDir = "backlog", + sinceDays?: number, +): Promise<Map<string, RemoteIndexEntry[]>> { + const out = new Map<string, RemoteIndexEntry[]>(); + + const normalized = branches.map((b) => normalizeLocalBranch(b, currentBranch)).filter((b): b is string => Boolean(b)); + + if (normalized.length === 0) { + return out; + } + + // Do branches in parallel but not unbounded + const CONCURRENCY = 4; + const queue = [...normalized]; + + const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => { + while (queue.length) { + const br = queue.pop(); + if (!br) break; + + try { + // Get all task files in this branch (use branch name directly, not origin/) + const files = await git.listFilesInTree(br, `${backlogDir}/tasks`); + if (files.length === 0) continue; + + // Get last modified times for all files in one pass + const lm = await git.getBranchLastModifiedMap(br, `${backlogDir}/tasks`, sinceDays); + + for (const f of files) { + // Extract task ID from filename (support subtasks like task-123.01) + const m = f.match(/task-(\d+(?:\.\d+)?)/); + if (!m) continue; + + const id = `task-${m[1]}`; + const lastModified = lm.get(f) ?? new Date(0); + const entry: RemoteIndexEntry = { id, branch: br, path: f, lastModified }; + + const arr = out.get(id); + if (arr) { + arr.push(entry); + } else { + out.set(id, [entry]); + } + } + } catch (error) { + // Branch might not have backlog directory, skip it + if (process.env.DEBUG) { + console.debug(`Skipping local branch ${br}: ${error}`); + } + } + } + }); + + await Promise.all(workers); + return out; +} + +/** + * Choose which remote tasks need to be hydrated based on strategy + * Returns only the tasks that are newer or more progressed than local versions + */ +function chooseWinners( + localById: Map<string, Task>, + remoteIndex: Map<string, RemoteIndexEntry[]>, + strategy: "most_recent" | "most_progressed" = "most_progressed", +): Array<{ id: string; ref: string; path: string }> { + const winners: Array<{ id: string; ref: string; path: string }> = []; + + for (const [id, entries] of remoteIndex) { + const local = localById.get(id); + + if (!local) { + // No local version - take the newest remote + const best = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + winners.push({ id, ref: `origin/${best.branch}`, path: best.path }); + continue; + } + + // If strategy is "most_recent", only hydrate if any remote is newer + if (strategy === "most_recent") { + const localTs = local.updatedDate ? new Date(local.updatedDate).getTime() : 0; + const newestRemote = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + + if (newestRemote.lastModified.getTime() > localTs) { + winners.push({ + id, + ref: `origin/${newestRemote.branch}`, + path: newestRemote.path, + }); + } + continue; + } + + // For "most_progressed", we might need to check if remote is newer + // to potentially have a more progressed status + const localTs = local.updatedDate ? new Date(local.updatedDate).getTime() : 0; + const maybeNewer = entries.some((e) => e.lastModified.getTime() > localTs); + + if (maybeNewer) { + // Only hydrate the newest remote to check if it's more progressed + const newestRemote = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + winners.push({ + id, + ref: `origin/${newestRemote.branch}`, + path: newestRemote.path, + }); + } + } + + return winners; +} + +/** + * Find and load a specific task from remote branches + * Searches through recent remote branches for the task and returns the newest version + */ +export async function findTaskInRemoteBranches( + git: GitOperations, + taskId: string, + backlogDir = "backlog", + sinceDays = 30, +): Promise<Task | null> { + try { + // Check if we have any remote + if (!(await git.hasAnyRemote())) return null; + + // Get recent remote branches + const branches = await git.listRecentRemoteBranches(sinceDays); + if (branches.length === 0) return null; + + // Build task index for remote branches + const remoteIndex = await buildRemoteTaskIndex(git, branches, backlogDir, sinceDays); + + // Check if the task exists in the index + const entries = remoteIndex.get(taskId); + if (!entries || entries.length === 0) return null; + + // Get the newest version + const best = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + + // Hydrate the task + const ref = `origin/${best.branch}`; + const content = await git.showFile(ref, best.path); + const task = parseTask(content); + if (task) { + task.source = "remote"; + task.branch = best.branch; + } + return task; + } catch (error) { + if (process.env.DEBUG) { + console.error(`Failed to find task ${taskId} in remote branches:`, error); + } + return null; + } +} + +/** + * Find and load a specific task from local branches (excluding current branch) + * Searches through recent local branches for the task and returns the newest version + */ +export async function findTaskInLocalBranches( + git: GitOperations, + taskId: string, + backlogDir = "backlog", + sinceDays = 30, +): Promise<Task | null> { + try { + const currentBranch = await git.getCurrentBranch(); + if (!currentBranch) return null; + + // Get recent local branches + const allBranches = await git.listRecentBranches(sinceDays); + const localBranches = allBranches.filter( + (b) => !b.startsWith("origin/") && !b.startsWith("refs/remotes/") && b !== "origin", + ); + + if (localBranches.length <= 1) return null; // Only current branch + + // Build task index for local branches + const localIndex = await buildLocalBranchTaskIndex(git, localBranches, currentBranch, backlogDir, sinceDays); + + // Check if the task exists in the index + const entries = localIndex.get(taskId); + if (!entries || entries.length === 0) return null; + + // Get the newest version + const best = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + + // Hydrate the task + const content = await git.showFile(best.branch, best.path); + const task = parseTask(content); + if (task) { + task.source = "local-branch"; + task.branch = best.branch; + } + return task; + } catch (error) { + if (process.env.DEBUG) { + console.error(`Failed to find task ${taskId} in local branches:`, error); + } + return null; + } +} + +/** + * Load all remote tasks using optimized index-first, hydrate-later pattern + * Dramatically reduces git operations by only fetching content for tasks that need it + */ +export async function loadRemoteTasks( + gitOps: GitOperations, + userConfig: BacklogConfig | null = null, + onProgress?: (message: string) => void, + localTasks?: Task[], +): Promise<Task[]> { + try { + // Skip remote operations if disabled + if (userConfig?.remoteOperations === false) { + onProgress?.("Remote operations disabled - skipping remote tasks"); + return []; + } + + // Fetch remote branches + onProgress?.("Fetching remote branches..."); + await gitOps.fetch(); + + // Use recent branches only for better performance + const days = userConfig?.activeBranchDays ?? 30; + const branches = await gitOps.listRecentRemoteBranches(days); + + if (branches.length === 0) { + onProgress?.("No recent remote branches found"); + return []; + } + + onProgress?.(`Indexing ${branches.length} recent remote branches (last ${days} days)...`); + + // Build a cheap index without fetching content + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + const remoteIndex = await buildRemoteTaskIndex(gitOps, branches, backlogDir, days); + + if (remoteIndex.size === 0) { + onProgress?.("No remote tasks found"); + return []; + } + + onProgress?.(`Found ${remoteIndex.size} unique tasks across remote branches`); + + // If we have local tasks, use them to determine which remote tasks to hydrate + let winners: Array<{ id: string; ref: string; path: string }>; + + if (localTasks && localTasks.length > 0) { + // Build local task map for comparison + const localById = new Map(localTasks.map((t) => [t.id, t])); + const strategy = userConfig?.taskResolutionStrategy || "most_progressed"; + + // Only hydrate remote tasks that are newer or missing locally + winners = chooseWinners(localById, remoteIndex, strategy); + onProgress?.(`Hydrating ${winners.length} remote candidates...`); + } else { + // No local tasks, need to hydrate all remote tasks (take newest of each) + winners = []; + for (const [id, entries] of remoteIndex) { + const best = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + winners.push({ id, ref: `origin/${best.branch}`, path: best.path }); + } + onProgress?.(`Hydrating ${winners.length} remote tasks...`); + } + + // Only fetch content for the tasks we actually need + const hydratedTasks = await hydrateTasks(gitOps, winners); + + onProgress?.(`Loaded ${hydratedTasks.length} remote tasks`); + return hydratedTasks; + } catch (error) { + // If fetch fails, we can still work with local tasks + console.error("Failed to fetch remote tasks:", error); + return []; + } +} + +/** + * Resolve conflicts between local and remote tasks based on strategy + */ +function getTaskDate(task: Task): Date { + if (task.updatedDate) { + return new Date(task.updatedDate); + } + return task.lastModified ?? new Date(0); +} + +export function resolveTaskConflict( + existing: Task, + incoming: Task, + statuses: string[], + strategy: "most_recent" | "most_progressed" = "most_progressed", +): Task { + if (strategy === "most_recent") { + const existingDate = getTaskDate(existing); + const incomingDate = getTaskDate(incoming); + return existingDate >= incomingDate ? existing : incoming; + } + + // Default to most_progressed strategy + // Map status to rank (default to 0 for unknown statuses) + const currentIdx = statuses.indexOf(existing.status); + const newIdx = statuses.indexOf(incoming.status); + const currentRank = currentIdx >= 0 ? currentIdx : 0; + const newRank = newIdx >= 0 ? newIdx : 0; + + // If incoming task has a more progressed status, use it + if (newRank > currentRank) { + return incoming; + } + + // If statuses are equal, use the most recent + if (newRank === currentRank) { + const existingDate = getTaskDate(existing); + const incomingDate = getTaskDate(incoming); + return existingDate >= incomingDate ? existing : incoming; + } + + return existing; +} + +/** + * Load tasks from other local branches (not current branch, not remote) + * Uses the same optimized index-first, hydrate-later pattern as remote loading + */ +export async function loadLocalBranchTasks( + gitOps: GitOperations, + userConfig: BacklogConfig | null = null, + onProgress?: (message: string) => void, + localTasks?: Task[], +): Promise<Task[]> { + try { + const currentBranch = await gitOps.getCurrentBranch(); + if (!currentBranch) { + // Not on a branch (detached HEAD), skip local branch loading + return []; + } + + // Get recent local branches (excludes remote refs) + const days = userConfig?.activeBranchDays ?? 30; + const allBranches = await gitOps.listRecentBranches(days); + + // Filter to only local branches (not origin/*) + const localBranches = allBranches.filter( + (b) => !b.startsWith("origin/") && !b.startsWith("refs/remotes/") && b !== "origin", + ); + + if (localBranches.length <= 1) { + // Only current branch or no branches + return []; + } + + onProgress?.(`Indexing ${localBranches.length - 1} other local branches...`); + + // Build index of tasks from other local branches + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + const localBranchIndex = await buildLocalBranchTaskIndex(gitOps, localBranches, currentBranch, backlogDir, days); + + if (localBranchIndex.size === 0) { + return []; + } + + onProgress?.(`Found ${localBranchIndex.size} unique tasks in other local branches`); + + // Determine which tasks to hydrate + let winners: Array<{ id: string; ref: string; path: string }>; + + if (localTasks && localTasks.length > 0) { + // Build local task map for comparison + const localById = new Map(localTasks.map((t) => [t.id, t])); + const strategy = userConfig?.taskResolutionStrategy || "most_progressed"; + + // Only hydrate tasks that are missing locally or potentially newer + winners = []; + for (const [id, entries] of localBranchIndex) { + const local = localById.get(id); + + if (!local) { + // Task doesn't exist locally - take the newest from other branches + const best = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + winners.push({ id, ref: best.branch, path: best.path }); + continue; + } + + // For existing tasks, check if any other branch version is newer + if (strategy === "most_recent") { + const localTs = local.updatedDate ? new Date(local.updatedDate).getTime() : 0; + const newestOther = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + + if (newestOther.lastModified.getTime() > localTs) { + winners.push({ id, ref: newestOther.branch, path: newestOther.path }); + } + } else { + // For most_progressed, we need to hydrate to check status + const localTs = local.updatedDate ? new Date(local.updatedDate).getTime() : 0; + const maybeNewer = entries.some((e) => e.lastModified.getTime() > localTs); + + if (maybeNewer) { + const newestOther = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + winners.push({ id, ref: newestOther.branch, path: newestOther.path }); + } + } + } + } else { + // No local tasks, hydrate all from other branches (take newest of each) + winners = []; + for (const [id, entries] of localBranchIndex) { + const best = entries.reduce((a, b) => (a.lastModified >= b.lastModified ? a : b)); + winners.push({ id, ref: best.branch, path: best.path }); + } + } + + if (winners.length === 0) { + return []; + } + + onProgress?.(`Hydrating ${winners.length} tasks from other local branches...`); + + // Hydrate the tasks - note: ref is the branch name directly (not origin/) + const hydratedTasks = await hydrateTasks(gitOps, winners); + + // Mark these as coming from local branches + for (const task of hydratedTasks) { + task.source = "local-branch"; + } + + onProgress?.(`Loaded ${hydratedTasks.length} tasks from other local branches`); + return hydratedTasks; + } catch (error) { + if (process.env.DEBUG) { + console.error("Failed to load local branch tasks:", error); + } + return []; + } +} diff --git a/src/file-system/operations.ts b/src/file-system/operations.ts new file mode 100644 index 0000000..65ae798 --- /dev/null +++ b/src/file-system/operations.ts @@ -0,0 +1,824 @@ +import { mkdir, rename, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DEFAULT_DIRECTORIES, DEFAULT_FILES, DEFAULT_STATUSES } from "../constants/index.ts"; +import { parseDecision, parseDocument, parseTask } from "../markdown/parser.ts"; +import { serializeDecision, serializeDocument, serializeTask } from "../markdown/serializer.ts"; +import type { BacklogConfig, Decision, Document, Task, TaskListFilter } from "../types/index.ts"; +import { documentIdsEqual, normalizeDocumentId } from "../utils/document-id.ts"; +import { getTaskFilename, getTaskPath, normalizeTaskId } from "../utils/task-path.ts"; +import { sortByTaskId } from "../utils/task-sorting.ts"; + +// Interface for task path resolution context +interface TaskPathContext { + filesystem: { + tasksDir: string; + }; +} + +export class FileSystem { + private readonly backlogDir: string; + private readonly projectRoot: string; + private cachedConfig: BacklogConfig | null = null; + + constructor(projectRoot: string) { + this.projectRoot = projectRoot; + this.backlogDir = join(projectRoot, DEFAULT_DIRECTORIES.BACKLOG); + } + + private async getBacklogDir(): Promise<string> { + // Ensure migration is checked if needed + if (!this.cachedConfig) { + this.cachedConfig = await this.loadConfigDirect(); + } + // Always use "backlog" as the directory name - no configuration needed + return join(this.projectRoot, DEFAULT_DIRECTORIES.BACKLOG); + } + + private async loadConfigDirect(): Promise<BacklogConfig | null> { + try { + // First try the standard "backlog" directory + let configPath = join(this.projectRoot, DEFAULT_DIRECTORIES.BACKLOG, DEFAULT_FILES.CONFIG); + let file = Bun.file(configPath); + let exists = await file.exists(); + + // If not found, check for legacy ".backlog" directory and migrate it + if (!exists) { + const legacyBacklogDir = join(this.projectRoot, ".backlog"); + const legacyConfigPath = join(legacyBacklogDir, DEFAULT_FILES.CONFIG); + const legacyFile = Bun.file(legacyConfigPath); + const legacyExists = await legacyFile.exists(); + + if (legacyExists) { + // Migrate legacy .backlog directory to backlog + const newBacklogDir = join(this.projectRoot, DEFAULT_DIRECTORIES.BACKLOG); + await rename(legacyBacklogDir, newBacklogDir); + + // Update paths to use the new location + configPath = join(this.projectRoot, DEFAULT_DIRECTORIES.BACKLOG, DEFAULT_FILES.CONFIG); + file = Bun.file(configPath); + exists = true; + } + } + + if (!exists) { + return null; + } + + const content = await file.text(); + return this.parseConfig(content); + } catch (_error) { + if (process.env.DEBUG) { + console.error("Error loading config:", _error); + } + return null; + } + } + + // Public accessors for directory paths + get tasksDir(): string { + return join(this.backlogDir, DEFAULT_DIRECTORIES.TASKS); + } + get completedDir(): string { + return join(this.backlogDir, DEFAULT_DIRECTORIES.COMPLETED); + } + + get archiveTasksDir(): string { + return join(this.backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_TASKS); + } + get decisionsDir(): string { + return join(this.backlogDir, DEFAULT_DIRECTORIES.DECISIONS); + } + + get docsDir(): string { + return join(this.backlogDir, DEFAULT_DIRECTORIES.DOCS); + } + + get configFilePath(): string { + return join(this.backlogDir, DEFAULT_FILES.CONFIG); + } + + /** Get the project root directory */ + get rootDir(): string { + return this.projectRoot; + } + + invalidateConfigCache(): void { + this.cachedConfig = null; + } + + private async getTasksDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.TASKS); + } + + async getDraftsDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.DRAFTS); + } + + async getArchiveTasksDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_TASKS); + } + + private async getArchiveDraftsDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_DRAFTS); + } + + private async getDecisionsDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.DECISIONS); + } + + private async getDocsDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.DOCS); + } + + private async getCompletedDir(): Promise<string> { + const backlogDir = await this.getBacklogDir(); + return join(backlogDir, DEFAULT_DIRECTORIES.COMPLETED); + } + + async ensureBacklogStructure(): Promise<void> { + const backlogDir = await this.getBacklogDir(); + const directories = [ + backlogDir, + join(backlogDir, DEFAULT_DIRECTORIES.TASKS), + join(backlogDir, DEFAULT_DIRECTORIES.DRAFTS), + join(backlogDir, DEFAULT_DIRECTORIES.COMPLETED), + join(backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_TASKS), + join(backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_DRAFTS), + join(backlogDir, DEFAULT_DIRECTORIES.DOCS), + join(backlogDir, DEFAULT_DIRECTORIES.DECISIONS), + ]; + + for (const dir of directories) { + await mkdir(dir, { recursive: true }); + } + } + + // Task operations + async saveTask(task: Task): Promise<string> { + const taskId = normalizeTaskId(task.id); + const filename = `${taskId} - ${this.sanitizeFilename(task.title)}.md`; + const tasksDir = await this.getTasksDir(); + const filepath = join(tasksDir, filename); + const content = serializeTask(task); + + // Delete any existing task files with the same ID but different filenames + try { + const core = { filesystem: { tasksDir } }; + const existingPath = await getTaskPath(taskId, core as TaskPathContext); + if (existingPath && !existingPath.endsWith(filename)) { + await unlink(existingPath); + } + } catch { + // Ignore errors if no existing files found + } + + await this.ensureDirectoryExists(dirname(filepath)); + await Bun.write(filepath, content); + return filepath; + } + + async loadTask(taskId: string): Promise<Task | null> { + try { + const tasksDir = await this.getTasksDir(); + const core = { filesystem: { tasksDir } }; + const filepath = await getTaskPath(taskId, core as TaskPathContext); + + if (!filepath) return null; + + const content = await Bun.file(filepath).text(); + const task = parseTask(content); + return { ...task, filePath: filepath }; + } catch (_error) { + return null; + } + } + + async listTasks(filter?: TaskListFilter): Promise<Task[]> { + try { + const tasksDir = await this.getTasksDir(); + const taskFiles = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: tasksDir })); + + let tasks: Task[] = []; + for (const file of taskFiles) { + const filepath = join(tasksDir, file); + const content = await Bun.file(filepath).text(); + const task = parseTask(content); + tasks.push({ ...task, filePath: filepath }); + } + + if (filter?.status) { + const statusLower = filter.status.toLowerCase(); + tasks = tasks.filter((t) => t.status.toLowerCase() === statusLower); + } + + if (filter?.assignee) { + const assignee = filter.assignee; + tasks = tasks.filter((t) => t.assignee.includes(assignee)); + } + + return sortByTaskId(tasks); + } catch (_error) { + return []; + } + } + + async listCompletedTasks(): Promise<Task[]> { + try { + const completedDir = await this.getCompletedDir(); + const taskFiles = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: completedDir })); + + const tasks: Task[] = []; + for (const file of taskFiles) { + const filepath = join(completedDir, file); + const content = await Bun.file(filepath).text(); + const task = parseTask(content); + tasks.push({ ...task, filePath: filepath }); + } + + return sortByTaskId(tasks); + } catch (_error) { + return []; + } + } + + async archiveTask(taskId: string): Promise<boolean> { + try { + const tasksDir = await this.getTasksDir(); + const archiveTasksDir = await this.getArchiveTasksDir(); + const core = { filesystem: { tasksDir } }; + const sourcePath = await getTaskPath(taskId, core as TaskPathContext); + const taskFile = await getTaskFilename(taskId, core as TaskPathContext); + + if (!sourcePath || !taskFile) return false; + + const targetPath = join(archiveTasksDir, taskFile); + + // Ensure target directory exists + await this.ensureDirectoryExists(dirname(targetPath)); + + // Use rename for proper Git move detection + await rename(sourcePath, targetPath); + + return true; + } catch (_error) { + return false; + } + } + + async completeTask(taskId: string): Promise<boolean> { + try { + const tasksDir = await this.getTasksDir(); + const completedDir = await this.getCompletedDir(); + const core = { filesystem: { tasksDir } }; + const sourcePath = await getTaskPath(taskId, core as TaskPathContext); + const taskFile = await getTaskFilename(taskId, core as TaskPathContext); + + if (!sourcePath || !taskFile) return false; + + const targetPath = join(completedDir, taskFile); + + // Ensure target directory exists + await this.ensureDirectoryExists(dirname(targetPath)); + + // Use rename for proper Git move detection + await rename(sourcePath, targetPath); + + return true; + } catch (_error) { + return false; + } + } + + async archiveDraft(taskId: string): Promise<boolean> { + try { + const draftsDir = await this.getDraftsDir(); + const archiveDraftsDir = await this.getArchiveDraftsDir(); + const core = { filesystem: { tasksDir: draftsDir } }; + const sourcePath = await getTaskPath(taskId, core as TaskPathContext); + const taskFile = await getTaskFilename(taskId, core as TaskPathContext); + + if (!sourcePath || !taskFile) return false; + + const targetPath = join(archiveDraftsDir, taskFile); + + const content = await Bun.file(sourcePath).text(); + await this.ensureDirectoryExists(dirname(targetPath)); + await Bun.write(targetPath, content); + + await unlink(sourcePath); + + return true; + } catch { + return false; + } + } + + async promoteDraft(taskId: string): Promise<boolean> { + try { + const draftsDir = await this.getDraftsDir(); + const tasksDir = await this.getTasksDir(); + const core = { filesystem: { tasksDir: draftsDir } }; + const sourcePath = await getTaskPath(taskId, core as TaskPathContext); + const taskFile = await getTaskFilename(taskId, core as TaskPathContext); + + if (!sourcePath || !taskFile) return false; + + const targetPath = join(tasksDir, taskFile); + + const content = await Bun.file(sourcePath).text(); + await this.ensureDirectoryExists(dirname(targetPath)); + await Bun.write(targetPath, content); + + await unlink(sourcePath); + + return true; + } catch { + return false; + } + } + + async demoteTask(taskId: string): Promise<boolean> { + try { + const tasksDir = await this.getTasksDir(); + const draftsDir = await this.getDraftsDir(); + const core = { filesystem: { tasksDir } }; + const sourcePath = await getTaskPath(taskId, core as TaskPathContext); + const taskFile = await getTaskFilename(taskId, core as TaskPathContext); + + if (!sourcePath || !taskFile) return false; + + const targetPath = join(draftsDir, taskFile); + + const content = await Bun.file(sourcePath).text(); + await this.ensureDirectoryExists(dirname(targetPath)); + await Bun.write(targetPath, content); + + await unlink(sourcePath); + + return true; + } catch { + return false; + } + } + + // Draft operations + async saveDraft(task: Task): Promise<string> { + const taskId = normalizeTaskId(task.id); + const filename = `${taskId} - ${this.sanitizeFilename(task.title)}.md`; + const draftsDir = await this.getDraftsDir(); + const filepath = join(draftsDir, filename); + const content = serializeTask(task); + + try { + const core = { filesystem: { tasksDir: draftsDir } }; + const existingPath = await getTaskPath(taskId, core as TaskPathContext); + if (existingPath && !existingPath.endsWith(filename)) { + await unlink(existingPath); + } + } catch { + // Ignore errors if no existing files found + } + + await this.ensureDirectoryExists(dirname(filepath)); + await Bun.write(filepath, content); + return filepath; + } + + async loadDraft(taskId: string): Promise<Task | null> { + try { + const draftsDir = await this.getDraftsDir(); + const core = { filesystem: { tasksDir: draftsDir } }; + const filepath = await getTaskPath(taskId, core as TaskPathContext); + + if (!filepath) return null; + + const content = await Bun.file(filepath).text(); + const task = parseTask(content); + return { ...task, filePath: filepath }; + } catch { + return null; + } + } + + async listDrafts(): Promise<Task[]> { + try { + const draftsDir = await this.getDraftsDir(); + const taskFiles = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: draftsDir })); + + const tasks: Task[] = []; + for (const file of taskFiles) { + const filepath = join(draftsDir, file); + const content = await Bun.file(filepath).text(); + const task = parseTask(content); + tasks.push({ ...task, filePath: filepath }); + } + + return sortByTaskId(tasks); + } catch { + return []; + } + } + + // Decision log operations + async saveDecision(decision: Decision): Promise<void> { + // Normalize ID - remove "decision-" prefix if present + const normalizedId = decision.id.replace(/^decision-/, ""); + const filename = `decision-${normalizedId} - ${this.sanitizeFilename(decision.title)}.md`; + const decisionsDir = await this.getDecisionsDir(); + const filepath = join(decisionsDir, filename); + const content = serializeDecision(decision); + + await this.ensureDirectoryExists(dirname(filepath)); + await Bun.write(filepath, content); + } + + async loadDecision(decisionId: string): Promise<Decision | null> { + try { + const decisionsDir = await this.getDecisionsDir(); + const files = await Array.fromAsync(new Bun.Glob("decision-*.md").scan({ cwd: decisionsDir })); + + // Normalize ID - remove "decision-" prefix if present + const normalizedId = decisionId.replace(/^decision-/, ""); + const decisionFile = files.find((file) => file.startsWith(`decision-${normalizedId} -`)); + + if (!decisionFile) return null; + + const filepath = join(decisionsDir, decisionFile); + const content = await Bun.file(filepath).text(); + return parseDecision(content); + } catch (_error) { + return null; + } + } + + // Document operations + async saveDocument(document: Document, subPath = ""): Promise<string> { + const docsDir = await this.getDocsDir(); + const canonicalId = normalizeDocumentId(document.id); + document.id = canonicalId; + const filename = `${canonicalId} - ${this.sanitizeFilename(document.title)}.md`; + const subPathSegments = subPath + .split(/[\\/]+/) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== "." && segment !== ".."); + const relativePath = subPathSegments.length > 0 ? join(...subPathSegments, filename) : filename; + const filepath = join(docsDir, relativePath); + const content = serializeDocument(document); + + await this.ensureDirectoryExists(dirname(filepath)); + + const glob = new Bun.Glob("**/doc-*.md"); + const existingMatches = await Array.fromAsync(glob.scan({ cwd: docsDir })); + const matchesForId = existingMatches.filter((relative) => { + const base = relative.split("/").pop() || relative; + const [candidateId] = base.split(" - "); + if (!candidateId) return false; + return documentIdsEqual(canonicalId, candidateId); + }); + + let sourceRelativePath = document.path; + if (!sourceRelativePath && matchesForId.length > 0) { + sourceRelativePath = matchesForId[0]; + } + + if (sourceRelativePath && sourceRelativePath !== relativePath) { + const sourcePath = join(docsDir, sourceRelativePath); + try { + await this.ensureDirectoryExists(dirname(filepath)); + await rename(sourcePath, filepath); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + throw error; + } + } + } + + for (const match of matchesForId) { + const matchPath = join(docsDir, match); + if (matchPath === filepath) { + continue; + } + try { + await unlink(matchPath); + } catch { + // Ignore cleanup errors - file may have been removed already + } + } + + await Bun.write(filepath, content); + + document.path = relativePath; + return relativePath; + } + + async listDecisions(): Promise<Decision[]> { + try { + const decisionsDir = await this.getDecisionsDir(); + const decisionFiles = await Array.fromAsync(new Bun.Glob("decision-*.md").scan({ cwd: decisionsDir })); + const decisions: Decision[] = []; + for (const file of decisionFiles) { + // Filter out README files as they're just instruction files + if (file.toLowerCase().match(/^readme\.md$/i)) { + continue; + } + const filepath = join(decisionsDir, file); + const content = await Bun.file(filepath).text(); + decisions.push(parseDecision(content)); + } + return sortByTaskId(decisions); + } catch { + return []; + } + } + + async listDocuments(): Promise<Document[]> { + try { + const docsDir = await this.getDocsDir(); + // Recursively include all markdown files under docs, excluding README.md variants + const glob = new Bun.Glob("**/*.md"); + const docFiles = await Array.fromAsync(glob.scan({ cwd: docsDir })); + const docs: Document[] = []; + for (const file of docFiles) { + const base = file.split("/").pop() || file; + if (base.toLowerCase() === "readme.md") continue; + const filepath = join(docsDir, file); + const content = await Bun.file(filepath).text(); + const parsed = parseDocument(content); + docs.push({ + ...parsed, + path: file, + }); + } + + // Stable sort by title for UI/CLI listing + return docs.sort((a, b) => a.title.localeCompare(b.title)); + } catch { + return []; + } + } + + async loadDocument(id: string): Promise<Document> { + const documents = await this.listDocuments(); + const document = documents.find((doc) => documentIdsEqual(id, doc.id)); + if (!document) { + throw new Error(`Document not found: ${id}`); + } + return document; + } + + // Config operations + async loadConfig(): Promise<BacklogConfig | null> { + // Return cached config if available + if (this.cachedConfig !== null) { + return this.cachedConfig; + } + + try { + const backlogDir = await this.getBacklogDir(); + const configPath = join(backlogDir, DEFAULT_FILES.CONFIG); + + // Check if file exists first to avoid hanging on Windows + const file = Bun.file(configPath); + const exists = await file.exists(); + + if (!exists) { + return null; + } + + const content = await file.text(); + const config = this.parseConfig(content); + + // Cache the loaded config + this.cachedConfig = config; + return config; + } catch (_error) { + return null; + } + } + + async saveConfig(config: BacklogConfig): Promise<void> { + const backlogDir = await this.getBacklogDir(); + const configPath = join(backlogDir, DEFAULT_FILES.CONFIG); + const content = this.serializeConfig(config); + await Bun.write(configPath, content); + this.cachedConfig = config; + } + + async getUserSetting(key: string, global = false): Promise<string | undefined> { + const settings = await this.loadUserSettings(global); + return settings ? settings[key] : undefined; + } + + async setUserSetting(key: string, value: string, global = false): Promise<void> { + const settings = (await this.loadUserSettings(global)) || {}; + settings[key] = value; + await this.saveUserSettings(settings, global); + } + + private async loadUserSettings(global = false): Promise<Record<string, string> | null> { + const primaryPath = global + ? join(homedir(), "backlog", DEFAULT_FILES.USER) + : join(this.projectRoot, DEFAULT_FILES.USER); + const fallbackPath = global ? join(this.projectRoot, "backlog", DEFAULT_FILES.USER) : undefined; + const tryPaths = fallbackPath ? [primaryPath, fallbackPath] : [primaryPath]; + for (const filePath of tryPaths) { + try { + const content = await Bun.file(filePath).text(); + const result: Record<string, string> = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf(":"); + if (idx === -1) continue; + const k = trimmed.substring(0, idx).trim(); + result[k] = trimmed + .substring(idx + 1) + .trim() + .replace(/^['"]|['"]$/g, ""); + } + return result; + } catch { + // Try next path (if any) + } + } + return null; + } + + private async saveUserSettings(settings: Record<string, string>, global = false): Promise<void> { + const primaryPath = global + ? join(homedir(), "backlog", DEFAULT_FILES.USER) + : join(this.projectRoot, DEFAULT_FILES.USER); + const fallbackPath = global ? join(this.projectRoot, "backlog", DEFAULT_FILES.USER) : undefined; + + const lines = Object.entries(settings).map(([k, v]) => `${k}: ${v}`); + const data = `${lines.join("\n")}\n`; + + try { + await this.ensureDirectoryExists(dirname(primaryPath)); + await Bun.write(primaryPath, data); + return; + } catch { + // Fall through to fallback when global write fails (e.g., sandboxed env) + } + + if (fallbackPath) { + await this.ensureDirectoryExists(dirname(fallbackPath)); + await Bun.write(fallbackPath, data); + } + } + + // Utility methods + private sanitizeFilename(filename: string): string { + return filename + .replace(/[<>:"/\\|?*]/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + } + + private async ensureDirectoryExists(dirPath: string): Promise<void> { + try { + await mkdir(dirPath, { recursive: true }); + } catch (_error) { + // Directory creation failed, ignore + } + } + + private parseConfig(content: string): BacklogConfig { + const config: Partial<BacklogConfig> = {}; + const lines = content.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const colonIndex = trimmed.indexOf(":"); + if (colonIndex === -1) continue; + + const key = trimmed.substring(0, colonIndex).trim(); + const value = trimmed.substring(colonIndex + 1).trim(); + + switch (key) { + case "project_name": + config.projectName = value.replace(/['"]/g, ""); + break; + case "default_assignee": + config.defaultAssignee = value.replace(/['"]/g, ""); + break; + case "default_reporter": + config.defaultReporter = value.replace(/['"]/g, ""); + break; + case "default_status": + config.defaultStatus = value.replace(/['"]/g, ""); + break; + case "statuses": + case "labels": + case "milestones": + if (value.startsWith("[") && value.endsWith("]")) { + const arrayContent = value.slice(1, -1); + config[key] = arrayContent + .split(",") + .map((item) => item.trim().replace(/['"]/g, "")) + .filter(Boolean); + } + break; + case "date_format": + config.dateFormat = value.replace(/['"]/g, ""); + break; + case "max_column_width": + config.maxColumnWidth = Number.parseInt(value, 10); + break; + case "default_editor": + config.defaultEditor = value.replace(/["']/g, ""); + break; + case "auto_open_browser": + config.autoOpenBrowser = value.toLowerCase() === "true"; + break; + case "default_port": + config.defaultPort = Number.parseInt(value, 10); + break; + case "remote_operations": + config.remoteOperations = value.toLowerCase() === "true"; + break; + case "auto_commit": + config.autoCommit = value.toLowerCase() === "true"; + break; + case "zero_padded_ids": + config.zeroPaddedIds = Number.parseInt(value, 10); + break; + case "bypass_git_hooks": + config.bypassGitHooks = value.toLowerCase() === "true"; + break; + case "check_active_branches": + config.checkActiveBranches = value.toLowerCase() === "true"; + break; + case "active_branch_days": + config.activeBranchDays = Number.parseInt(value, 10); + break; + case "onStatusChange": + case "on_status_change": + // Remove surrounding quotes if present, but preserve inner content + config.onStatusChange = value.replace(/^['"]|['"]$/g, ""); + break; + } + } + + return { + projectName: config.projectName || "", + defaultAssignee: config.defaultAssignee, + defaultReporter: config.defaultReporter, + statuses: config.statuses || [...DEFAULT_STATUSES], + labels: config.labels || [], + milestones: config.milestones || [], + defaultStatus: config.defaultStatus, + dateFormat: config.dateFormat || "yyyy-mm-dd", + maxColumnWidth: config.maxColumnWidth, + defaultEditor: config.defaultEditor, + autoOpenBrowser: config.autoOpenBrowser, + defaultPort: config.defaultPort, + remoteOperations: config.remoteOperations, + autoCommit: config.autoCommit, + zeroPaddedIds: config.zeroPaddedIds, + bypassGitHooks: config.bypassGitHooks, + checkActiveBranches: config.checkActiveBranches, + activeBranchDays: config.activeBranchDays, + onStatusChange: config.onStatusChange, + }; + } + + private serializeConfig(config: BacklogConfig): string { + const lines = [ + `project_name: "${config.projectName}"`, + ...(config.defaultAssignee ? [`default_assignee: "${config.defaultAssignee}"`] : []), + ...(config.defaultReporter ? [`default_reporter: "${config.defaultReporter}"`] : []), + ...(config.defaultStatus ? [`default_status: "${config.defaultStatus}"`] : []), + `statuses: [${config.statuses.map((s) => `"${s}"`).join(", ")}]`, + `labels: [${config.labels.map((l) => `"${l}"`).join(", ")}]`, + `milestones: [${config.milestones.map((m) => `"${m}"`).join(", ")}]`, + `date_format: ${config.dateFormat}`, + ...(config.maxColumnWidth ? [`max_column_width: ${config.maxColumnWidth}`] : []), + ...(config.defaultEditor ? [`default_editor: "${config.defaultEditor}"`] : []), + ...(typeof config.autoOpenBrowser === "boolean" ? [`auto_open_browser: ${config.autoOpenBrowser}`] : []), + ...(config.defaultPort ? [`default_port: ${config.defaultPort}`] : []), + ...(typeof config.remoteOperations === "boolean" ? [`remote_operations: ${config.remoteOperations}`] : []), + ...(typeof config.autoCommit === "boolean" ? [`auto_commit: ${config.autoCommit}`] : []), + ...(typeof config.zeroPaddedIds === "number" ? [`zero_padded_ids: ${config.zeroPaddedIds}`] : []), + ...(typeof config.bypassGitHooks === "boolean" ? [`bypass_git_hooks: ${config.bypassGitHooks}`] : []), + ...(typeof config.checkActiveBranches === "boolean" + ? [`check_active_branches: ${config.checkActiveBranches}`] + : []), + ...(typeof config.activeBranchDays === "number" ? [`active_branch_days: ${config.activeBranchDays}`] : []), + ...(config.onStatusChange ? [`onStatusChange: '${config.onStatusChange}'`] : []), + ]; + + return `${lines.join("\n")}\n`; + } +} diff --git a/src/formatters/task-plain-text.ts b/src/formatters/task-plain-text.ts new file mode 100644 index 0000000..e89aa85 --- /dev/null +++ b/src/formatters/task-plain-text.ts @@ -0,0 +1,134 @@ +import type { Task } from "../types/index.ts"; +import type { ChecklistItem } from "../ui/checklist.ts"; +import { transformCodePathsPlain } from "../ui/code-path.ts"; +import { formatStatusWithIcon } from "../ui/status-icon.ts"; + +export type TaskPlainTextOptions = { + filePathOverride?: string; +}; + +export function formatDateForDisplay(dateStr: string): string { + if (!dateStr) return ""; + const hasTime = dateStr.includes(" ") || dateStr.includes("T"); + return hasTime ? dateStr : dateStr; +} + +export function buildAcceptanceCriteriaItems(task: Task): ChecklistItem[] { + const items = task.acceptanceCriteriaItems ?? []; + return items + .slice() + .sort((a, b) => a.index - b.index) + .map((criterion, index) => ({ + text: `#${index + 1} ${criterion.text}`, + checked: criterion.checked, + })); +} + +export function formatAcceptanceCriteriaLines(items: ChecklistItem[]): string[] { + if (items.length === 0) return []; + return items.map((item) => { + const prefix = item.checked ? "- [x]" : "- [ ]"; + return `${prefix} ${transformCodePathsPlain(item.text)}`; + }); +} + +function formatPriority(priority?: "high" | "medium" | "low"): string | null { + if (!priority) return null; + const label = priority.charAt(0).toUpperCase() + priority.slice(1); + return label; +} + +function formatAssignees(assignee?: string[]): string | null { + if (!assignee || assignee.length === 0) return null; + return assignee.map((a) => (a.startsWith("@") ? a : `@${a}`)).join(", "); +} + +export function formatTaskPlainText(task: Task, options: TaskPlainTextOptions = {}): string { + const lines: string[] = []; + const filePath = options.filePathOverride ?? task.filePath; + + if (filePath) { + lines.push(`File: ${filePath}`); + lines.push(""); + } + + lines.push(`Task ${task.id} - ${task.title}`); + lines.push("=".repeat(50)); + lines.push(""); + lines.push(`Status: ${formatStatusWithIcon(task.status)}`); + + const priorityLabel = formatPriority(task.priority); + if (priorityLabel) { + lines.push(`Priority: ${priorityLabel}`); + } + + const assigneeText = formatAssignees(task.assignee); + if (assigneeText) { + lines.push(`Assignee: ${assigneeText}`); + } + + if (task.reporter) { + const reporter = task.reporter.startsWith("@") ? task.reporter : `@${task.reporter}`; + lines.push(`Reporter: ${reporter}`); + } + + lines.push(`Created: ${formatDateForDisplay(task.createdDate)}`); + if (task.updatedDate) { + lines.push(`Updated: ${formatDateForDisplay(task.updatedDate)}`); + } + + if (task.labels?.length) { + lines.push(`Labels: ${task.labels.join(", ")}`); + } + + if (task.milestone) { + lines.push(`Milestone: ${task.milestone}`); + } + + if (task.parentTaskId) { + lines.push(`Parent: ${task.parentTaskId}`); + } + + if (task.subtasks?.length) { + lines.push(`Subtasks: ${task.subtasks.length}`); + } + + if (task.dependencies?.length) { + lines.push(`Dependencies: ${task.dependencies.join(", ")}`); + } + + lines.push(""); + lines.push("Description:"); + lines.push("-".repeat(50)); + const description = task.description?.trim(); + lines.push(transformCodePathsPlain(description && description.length > 0 ? description : "No description provided")); + lines.push(""); + + lines.push("Acceptance Criteria:"); + lines.push("-".repeat(50)); + const criteriaItems = buildAcceptanceCriteriaItems(task); + if (criteriaItems.length > 0) { + lines.push(...formatAcceptanceCriteriaLines(criteriaItems)); + } else { + lines.push("No acceptance criteria defined"); + } + lines.push(""); + + const implementationPlan = task.implementationPlan?.trim(); + if (implementationPlan) { + lines.push("Implementation Plan:"); + lines.push("-".repeat(50)); + lines.push(transformCodePathsPlain(implementationPlan)); + lines.push(""); + } + + const implementationNotes = task.implementationNotes?.trim(); + if (implementationNotes) { + lines.push("Implementation Notes:"); + lines.push("-".repeat(50)); + lines.push(transformCodePathsPlain(implementationNotes)); + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/git/operations.ts b/src/git/operations.ts new file mode 100644 index 0000000..c75ee63 --- /dev/null +++ b/src/git/operations.ts @@ -0,0 +1,516 @@ +import { $ } from "bun"; +import type { BacklogConfig } from "../types/index.ts"; + +export class GitOperations { + private projectRoot: string; + private config: BacklogConfig | null = null; + + constructor(projectRoot: string, config: BacklogConfig | null = null) { + this.projectRoot = projectRoot; + this.config = config; + } + + setConfig(config: BacklogConfig | null): void { + this.config = config; + } + + async addFile(filePath: string): Promise<void> { + // Convert absolute paths to relative paths from project root to avoid Windows encoding issues + const { relative } = await import("node:path"); + const relativePath = relative(this.projectRoot, filePath).replace(/\\/g, "/"); + await this.execGit(["add", relativePath]); + } + + async addFiles(filePaths: string[]): Promise<void> { + // Convert absolute paths to relative paths from project root to avoid Windows encoding issues + const { relative } = await import("node:path"); + const relativePaths = filePaths.map((filePath) => relative(this.projectRoot, filePath).replace(/\\/g, "/")); + await this.execGit(["add", ...relativePaths]); + } + + async commitTaskChange(taskId: string, message: string): Promise<void> { + const commitMessage = `${taskId} - ${message}`; + const args = ["commit", "-m", commitMessage]; + if (this.config?.bypassGitHooks) { + args.push("--no-verify"); + } + await this.execGit(args); + } + + async commitChanges(message: string): Promise<void> { + const args = ["commit", "-m", message]; + if (this.config?.bypassGitHooks) { + args.push("--no-verify"); + } + await this.execGit(args); + } + + async resetIndex(): Promise<void> { + // Reset the staging area without affecting working directory + await this.execGit(["reset", "HEAD"]); + } + + async commitStagedChanges(message: string): Promise<void> { + // Check if there are any staged changes before committing + const { stdout: status } = await this.execGit(["status", "--porcelain"]); + const hasStagedChanges = status.split("\n").some((line) => line.match(/^[AMDRC]/)); + + if (!hasStagedChanges) { + throw new Error("No staged changes to commit"); + } + + const args = ["commit", "-m", message]; + if (this.config?.bypassGitHooks) { + args.push("--no-verify"); + } + await this.execGit(args); + } + + async retryGitOperation<T>(operation: () => Promise<T>, operationName: string, maxRetries = 3): Promise<T> { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (process.env.DEBUG) { + console.warn( + `Git operation '${operationName}' failed on attempt ${attempt}/${maxRetries}:`, + lastError.message, + ); + } + + // Don't retry on the last attempt + if (attempt === maxRetries) { + break; + } + + // Wait briefly before retrying (exponential backoff) + await new Promise((resolve) => setTimeout(resolve, 2 ** (attempt - 1) * 100)); + } + } + + throw new Error(`Git operation '${operationName}' failed after ${maxRetries} attempts: ${lastError?.message}`); + } + + async getStatus(): Promise<string> { + const { stdout } = await this.execGit(["status", "--porcelain"], { readOnly: true }); + return stdout; + } + + async isClean(): Promise<boolean> { + const status = await this.getStatus(); + return status.trim() === ""; + } + + async getCurrentBranch(): Promise<string> { + const { stdout } = await this.execGit(["branch", "--show-current"], { readOnly: true }); + return stdout.trim(); + } + async hasUncommittedChanges(): Promise<boolean> { + const status = await this.getStatus(); + return status.trim() !== ""; + } + + async getLastCommitMessage(): Promise<string> { + const { stdout } = await this.execGit(["log", "-1", "--pretty=format:%s"], { readOnly: true }); + return stdout.trim(); + } + + async fetch(remote = "origin"): Promise<void> { + // Check if remote operations are disabled + if (this.config?.remoteOperations === false) { + if (process.env.DEBUG) { + console.warn("Remote operations are disabled in config. Skipping fetch."); + } + return; + } + + // Preflight: skip if repository has no remotes configured + const hasRemotes = await this.hasAnyRemote(); + if (!hasRemotes) { + // No remotes configured; silently skip fetch. A consolidated warning is shown during init if applicable. + return; + } + + try { + // Use --prune to remove dead refs and reduce later scans + await this.execGit(["fetch", remote, "--prune", "--quiet"]); + } catch (error) { + // Check if this is a network-related error + if (this.isNetworkError(error)) { + // Don't show console warnings - let the calling code handle user messaging + if (process.env.DEBUG) { + console.warn(`Network error details: ${error}`); + } + return; + } + // Re-throw non-network errors + throw error; + } + } + + private isNetworkError(error: unknown): boolean { + if (typeof error === "string") { + return this.containsNetworkErrorPattern(error); + } + if (error instanceof Error) { + return this.containsNetworkErrorPattern(error.message); + } + return false; + } + + private containsNetworkErrorPattern(message: string): boolean { + const networkErrorPatterns = [ + "could not resolve host", + "connection refused", + "network is unreachable", + "timeout", + "no route to host", + "connection timed out", + "temporary failure in name resolution", + "operation timed out", + ]; + + const lowerMessage = message.toLowerCase(); + return networkErrorPatterns.some((pattern) => lowerMessage.includes(pattern)); + } + async addAndCommitTaskFile(taskId: string, filePath: string, action: "create" | "update" | "archive"): Promise<void> { + const actionMessages = { + create: `Create task ${taskId}`, + update: `Update task ${taskId}`, + archive: `Archive task ${taskId}`, + }; + + // Retry git operations to handle transient failures + await this.retryGitOperation(async () => { + // Reset index to ensure only the specific file is staged + await this.resetIndex(); + + // Stage only the specific task file + await this.addFile(filePath); + + // Commit only the staged file + await this.commitStagedChanges(actionMessages[action]); + }, `commit task file ${filePath}`); + } + + async stageBacklogDirectory(backlogDir = "backlog"): Promise<void> { + await this.execGit(["add", `${backlogDir}/`]); + } + async stageFileMove(fromPath: string, toPath: string): Promise<void> { + // Stage the deletion of the old file and addition of the new file + // Git will automatically detect this as a rename if the content is similar enough + try { + // First try to stage the removal of the old file (if it still exists) + await this.execGit(["add", "--all", fromPath]); + } catch { + // If the old file doesn't exist, that's okay - it was already moved + } + + // Always stage the new file location + await this.execGit(["add", toPath]); + } + + async listRemoteBranches(remote = "origin"): Promise<string[]> { + try { + // Fast-path: if no remotes, return empty + if (!(await this.hasAnyRemote())) return []; + const { stdout } = await this.execGit(["branch", "-r", "--format=%(refname:short)"], { readOnly: true }); + return stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .filter((branch) => branch.startsWith(`${remote}/`)) + .map((branch) => branch.substring(`${remote}/`.length)); + } catch { + // If remote doesn't exist or other error, return empty array + return []; + } + } + + /** + * List remote branches that have been active within the specified days + * Much faster than listRemoteBranches for filtering old branches + */ + async listRecentRemoteBranches(daysAgo: number, remote = "origin"): Promise<string[]> { + try { + // Fast-path: if no remotes, return empty + if (!(await this.hasAnyRemote())) return []; + const { stdout } = await this.execGit( + ["for-each-ref", "--format=%(refname:short)|%(committerdate:iso8601)", `refs/remotes/${remote}`], + { readOnly: true }, + ); + const since = Date.now() - daysAgo * 24 * 60 * 60 * 1000; + return ( + stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((line) => { + const [ref, iso] = line.split("|"); + return { ref, t: Date.parse(iso || "") }; + }) + .filter((x) => Number.isFinite(x.t) && x.t >= since && x.ref) + .map((x) => x.ref?.replace(`${remote}/`, "")) + // Filter out invalid/ambiguous entries that would normalize to empty or "origin" + .filter((b): b is string => Boolean(b)) + .filter((b) => b !== "HEAD" && b !== remote && b !== `${remote}`) + ); + } catch { + return []; + } + } + + async listRecentBranches(daysAgo: number): Promise<string[]> { + try { + // Get all branches with their last commit date + // Using for-each-ref which is more efficient than multiple branch commands + const since = new Date(); + since.setDate(since.getDate() - daysAgo); + + // Build refs to check based on remoteOperations config + const refs = ["refs/heads"]; + if (this.config?.remoteOperations !== false) { + refs.push("refs/remotes/origin"); + } + + // Get local and remote branches with commit dates + const { stdout } = await this.execGit( + ["for-each-ref", "--format=%(refname:short)|%(committerdate:iso8601)", ...refs], + { readOnly: true }, + ); + + const recentBranches: string[] = []; + const lines = stdout.split("\n").filter(Boolean); + + for (const line of lines) { + const [branch, dateStr] = line.split("|"); + if (!branch || !dateStr) continue; + + const commitDate = new Date(dateStr); + if (commitDate >= since) { + // Keep the full branch name including origin/ prefix + // This allows cross-branch checking to distinguish local vs remote + if (!recentBranches.includes(branch)) { + recentBranches.push(branch); + } + } + } + + return recentBranches; + } catch { + // Fallback to all branches if the command fails + return this.listAllBranches(); + } + } + + async listLocalBranches(): Promise<string[]> { + try { + const { stdout } = await this.execGit(["branch", "--format=%(refname:short)"], { readOnly: true }); + return stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + } catch { + return []; + } + } + + async listAllBranches(_remote = "origin"): Promise<string[]> { + try { + // Use -a flag only if remote operations are enabled + const branchArgs = + this.config?.remoteOperations === false + ? ["branch", "--format=%(refname:short)"] + : ["branch", "-a", "--format=%(refname:short)"]; + + const { stdout } = await this.execGit(branchArgs, { readOnly: true }); + return stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .filter((b) => !b.includes("HEAD")); + } catch { + return []; + } + } + + /** + * Returns true if the current repository has any remotes configured + */ + async hasAnyRemote(): Promise<boolean> { + try { + const { stdout } = await this.execGit(["remote"], { readOnly: true }); + return ( + stdout + .split("\n") + .map((s) => s.trim()) + .filter(Boolean).length > 0 + ); + } catch { + return false; + } + } + + /** + * Returns true if a specific remote exists (default: origin) + */ + async hasRemote(remote = "origin"): Promise<boolean> { + try { + const { stdout } = await this.execGit(["remote"], { readOnly: true }); + return stdout.split("\n").some((r) => r.trim() === remote); + } catch { + return false; + } + } + + async listFilesInTree(ref: string, path: string): Promise<string[]> { + const { stdout } = await this.execGit(["ls-tree", "-r", "--name-only", "-z", ref, "--", path], { readOnly: true }); + return stdout.split("\0").filter(Boolean); + } + async showFile(ref: string, filePath: string): Promise<string> { + const { stdout } = await this.execGit(["show", `${ref}:${filePath}`], { readOnly: true }); + return stdout; + } + /** + * Build a map of file -> last modified date for all files in a directory in one git log pass + * Much more efficient than individual getFileLastModifiedTime calls + * Returns a Map of filePath -> Date + */ + async getBranchLastModifiedMap(ref: string, dir: string, sinceDays?: number): Promise<Map<string, Date>> { + const out = new Map<string, Date>(); + + try { + // Build args with optional --since filter + const args = [ + "log", + "--pretty=format:%ct%x00", // Unix timestamp + NUL for bulletproof parsing + "--name-only", + "-z", // Null-delimited for safety + ]; + + if (sinceDays) { + args.push(`--since=${sinceDays}.days`); + } + + args.push(ref, "--", dir); + + // Null-delimited to be safe with filenames + const { stdout } = await this.execGit(args, { readOnly: true }); + + // Parse null-delimited output + // Format is: timestamp\0 file1\0 file2\0 ... timestamp\0 file1\0 ... + const parts = stdout.split("\0").filter(Boolean); + let i = 0; + + while (i < parts.length) { + const timestampStr = parts[i]?.trim(); + if (timestampStr && /^\d+$/.test(timestampStr)) { + // This is a timestamp, files follow until next timestamp + const epoch = Number(timestampStr); + const date = new Date(epoch * 1000); + i++; + + // Process files until we hit another timestamp or end + // Check if next part looks like a timestamp (digits only) + while (i < parts.length && parts[i] && !/^\d+$/.test(parts[i]?.trim() || "")) { + const file = parts[i]?.trim(); + // First time we see a file is its last modification + if (file && !out.has(file)) { + out.set(file, date); + } + i++; + } + } else { + // Skip unexpected content + i++; + } + } + } catch (error) { + // If the command fails, return empty map + console.error(`Failed to get branch last modified map for ${ref}:${dir}`, error); + } + + return out; + } + + async getFileLastModifiedBranch(filePath: string): Promise<string | null> { + try { + // Get the hash of the last commit that touched the file + const { stdout: commitHash } = await this.execGit(["log", "-1", "--format=%H", "--", filePath], { + readOnly: true, + }); + if (!commitHash) return null; + + // Find all branches that contain this commit + const { stdout: branches } = await this.execGit([ + "branch", + "-a", + "--contains", + commitHash.trim(), + "--format=%(refname:short)", + ]); + + if (!branches) return "main"; // Default to main if no specific branch found + + // Prefer non-remote branches and 'main' or 'master' + const branchList = branches + .split("\n") + .map((b) => b.trim()) + .filter(Boolean); + const mainBranch = branchList.find((b) => b === "main" || b === "master"); + if (mainBranch) return mainBranch; + + const nonRemote = branchList.find((b) => !b.startsWith("remotes/")); + return nonRemote || branchList[0] || "main"; + } catch { + return null; + } + } + + private async execGit(args: string[], options?: { readOnly?: boolean }): Promise<{ stdout: string; stderr: string }> { + // Use Bun.spawn so we can explicitly control stdio behaviour on Windows. When running + // under the MCP stdio transport, delegating to git with inherited stdin can deadlock. + const env = options?.readOnly + ? ({ ...process.env, GIT_OPTIONAL_LOCKS: "0" } as Record<string, string>) + : (process.env as Record<string, string>); + + const subprocess = Bun.spawn(["git", ...args], { + cwd: this.projectRoot, + stdin: "ignore", // avoid inheriting MCP stdio pipes which can block on Windows + stdout: "pipe", + stderr: "pipe", + env, + }); + + const stdoutPromise = subprocess.stdout ? new Response(subprocess.stdout).text() : Promise.resolve(""); + const stderrPromise = subprocess.stderr ? new Response(subprocess.stderr).text() : Promise.resolve(""); + const [exitCode, stdout, stderr] = await Promise.all([subprocess.exited, stdoutPromise, stderrPromise]); + + if (exitCode !== 0) { + throw new Error(`Git command failed (exit code ${exitCode}): git ${args.join(" ")}\n${stderr}`); + } + + return { stdout, stderr }; + } +} + +export async function isGitRepository(projectRoot: string): Promise<boolean> { + try { + await $`git rev-parse --git-dir`.cwd(projectRoot).quiet(); + return true; + } catch { + return false; + } +} + +export async function initializeGitRepository(projectRoot: string): Promise<void> { + try { + await $`git init`.cwd(projectRoot).quiet(); + } catch (error) { + throw new Error(`Failed to initialize git repository: ${error}`); + } +} diff --git a/src/guidelines/agent-guidelines.md b/src/guidelines/agent-guidelines.md new file mode 100644 index 0000000..3a74b76 --- /dev/null +++ b/src/guidelines/agent-guidelines.md @@ -0,0 +1,529 @@ +# Instructions for the usage of Backlog.md CLI Tool + +## Backlog.md: Comprehensive Project Management Tool via CLI + +### Assistant Objective + +Efficiently manage all project tasks, status, and documentation using the Backlog.md CLI, ensuring all project metadata +remains fully synchronized and up-to-date. + +### Core Capabilities + +- βœ… **Task Management**: Create, edit, assign, prioritize, and track tasks with full metadata +- βœ… **Search**: Fuzzy search across tasks, documents, and decisions with `backlog search` +- βœ… **Acceptance Criteria**: Granular control with add/remove/check/uncheck by index +- βœ… **Board Visualization**: Terminal-based Kanban board (`backlog board`) and web UI (`backlog browser`) +- βœ… **Git Integration**: Automatic tracking of task states across branches +- βœ… **Dependencies**: Task relationships and subtask hierarchies +- βœ… **Documentation & Decisions**: Structured docs and architectural decision records +- βœ… **Export & Reporting**: Generate markdown reports and board snapshots +- βœ… **AI-Optimized**: `--plain` flag provides clean text output for AI processing + +### Why This Matters to You (AI Agent) + +1. **Comprehensive system** - Full project management capabilities through CLI +2. **The CLI is the interface** - All operations go through `backlog` commands +3. **Unified interaction model** - You can use CLI for both reading (`backlog task 1 --plain`) and writing ( + `backlog task edit 1`) +4. **Metadata stays synchronized** - The CLI handles all the complex relationships + +### Key Understanding + +- **Tasks** live in `backlog/tasks/` as `task-<id> - <title>.md` files +- **You interact via CLI only**: `backlog task create`, `backlog task edit`, etc. +- **Use `--plain` flag** for AI-friendly output when viewing/listing +- **Never bypass the CLI** - It handles Git, metadata, file naming, and relationships + +--- + +# ⚠️ CRITICAL: NEVER EDIT TASK FILES DIRECTLY. Edit Only via CLI + +**ALL task operations MUST use the Backlog.md CLI commands** + +- βœ… **DO**: Use `backlog task edit` and other CLI commands +- βœ… **DO**: Use `backlog task create` to create new tasks +- βœ… **DO**: Use `backlog task edit <id> --check-ac <index>` to mark acceptance criteria +- ❌ **DON'T**: Edit markdown files directly +- ❌ **DON'T**: Manually change checkboxes in files +- ❌ **DON'T**: Add or modify text in task files without using CLI + +**Why?** Direct file editing breaks metadata synchronization, Git tracking, and task relationships. + +--- + +## 1. Source of Truth & File Structure + +### πŸ“– **UNDERSTANDING** (What you'll see when reading) + +- Markdown task files live under **`backlog/tasks/`** (drafts under **`backlog/drafts/`**) +- Files are named: `task-<id> - <title>.md` (e.g., `task-42 - Add GraphQL resolver.md`) +- Project documentation is in **`backlog/docs/`** +- Project decisions are in **`backlog/decisions/`** + +### πŸ”§ **ACTING** (How to change things) + +- **All task operations MUST use the Backlog.md CLI tool** +- This ensures metadata is correctly updated and the project stays in sync +- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output + +--- + +## 2. Common Mistakes to Avoid + +### ❌ **WRONG: Direct File Editing** + +```markdown +# DON'T DO THIS: + +1. Open backlog/tasks/task-7 - Feature.md in editor +2. Change "- [ ]" to "- [x]" manually +3. Add notes directly to the file +4. Save the file +``` + +### βœ… **CORRECT: Using CLI Commands** + +```bash +# DO THIS INSTEAD: +backlog task edit 7 --check-ac 1 # Mark AC #1 as complete +backlog task edit 7 --notes "Implementation complete" # Add notes +backlog task edit 7 -s "In Progress" -a @agent-k # Multiple commands: change status and assign the task when you start working on the task +``` + +--- + +## 3. Understanding Task Format (Read-Only Reference) + +⚠️ **FORMAT REFERENCE ONLY** - The following sections show what you'll SEE in task files. +**Never edit these directly! Use CLI commands to make changes.** + +### Task Structure You'll See + +```markdown +--- +id: task-42 +title: Add GraphQL resolver +status: To Do +assignee: [@sara] +labels: [backend, api] +--- + +## Description + +Brief explanation of the task purpose. + +## Acceptance Criteria + +<!-- AC:BEGIN --> + +- [ ] #1 First criterion +- [x] #2 Second criterion (completed) +- [ ] #3 Third criterion + +<!-- AC:END --> + +## Implementation Plan + +1. Research approach +2. Implement solution + +## Implementation Notes + +Summary of what was done. +``` + +### How to Modify Each Section + +| What You Want to Change | CLI Command to Use | +|-------------------------|----------------------------------------------------------| +| Title | `backlog task edit 42 -t "New Title"` | +| Status | `backlog task edit 42 -s "In Progress"` | +| Assignee | `backlog task edit 42 -a @sara` | +| Labels | `backlog task edit 42 -l backend,api` | +| Description | `backlog task edit 42 -d "New description"` | +| Add AC | `backlog task edit 42 --ac "New criterion"` | +| Check AC #1 | `backlog task edit 42 --check-ac 1` | +| Uncheck AC #2 | `backlog task edit 42 --uncheck-ac 2` | +| Remove AC #3 | `backlog task edit 42 --remove-ac 3` | +| Add Plan | `backlog task edit 42 --plan "1. Step one\n2. Step two"` | +| Add Notes (replace) | `backlog task edit 42 --notes "What I did"` | +| Append Notes | `backlog task edit 42 --append-notes "Another note"` | + +--- + +## 4. Defining Tasks + +### Creating New Tasks + +**Always use CLI to create tasks:** + +```bash +# Example +backlog task create "Task title" -d "Description" --ac "First criterion" --ac "Second criterion" +``` + +### Title (one liner) + +Use a clear brief title that summarizes the task. + +### Description (The "why") + +Provide a concise summary of the task purpose and its goal. Explains the context without implementation details. + +### Acceptance Criteria (The "what") + +**Understanding the Format:** + +- Acceptance criteria appear as numbered checkboxes in the markdown files +- Format: `- [ ] #1 Criterion text` (unchecked) or `- [x] #1 Criterion text` (checked) + +**Managing Acceptance Criteria via CLI:** + +⚠️ **IMPORTANT: How AC Commands Work** + +- **Adding criteria (`--ac`)** accepts multiple flags: `--ac "First" --ac "Second"` βœ… +- **Checking/unchecking/removing** accept multiple flags too: `--check-ac 1 --check-ac 2` βœ… +- **Mixed operations** work in a single command: `--check-ac 1 --uncheck-ac 2 --remove-ac 3` βœ… + +```bash +# Examples + +# Add new criteria (MULTIPLE values allowed) +backlog task edit 42 --ac "User can login" --ac "Session persists" + +# Check specific criteria by index (MULTIPLE values supported) +backlog task edit 42 --check-ac 1 --check-ac 2 --check-ac 3 # Check multiple ACs +# Or check them individually if you prefer: +backlog task edit 42 --check-ac 1 # Mark #1 as complete +backlog task edit 42 --check-ac 2 # Mark #2 as complete + +# Mixed operations in single command +backlog task edit 42 --check-ac 1 --uncheck-ac 2 --remove-ac 3 + +# ❌ STILL WRONG - These formats don't work: +# backlog task edit 42 --check-ac 1,2,3 # No comma-separated values +# backlog task edit 42 --check-ac 1-3 # No ranges +# backlog task edit 42 --check 1 # Wrong flag name + +# Multiple operations of same type +backlog task edit 42 --uncheck-ac 1 --uncheck-ac 2 # Uncheck multiple ACs +backlog task edit 42 --remove-ac 2 --remove-ac 4 # Remove multiple ACs (processed high-to-low) +``` + +**Key Principles for Good ACs:** + +- **Outcome-Oriented:** Focus on the result, not the method. +- **Testable/Verifiable:** Each criterion should be objectively testable +- **Clear and Concise:** Unambiguous language +- **Complete:** Collectively cover the task scope +- **User-Focused:** Frame from end-user or system behavior perspective + +Good Examples: + +- "User can successfully log in with valid credentials" +- "System processes 1000 requests per second without errors" +- "CLI preserves literal newlines in description/plan/notes; `\\n` sequences are not auto‑converted" + +Bad Example (Implementation Step): + +- "Add a new function handleLogin() in auth.ts" +- "Define expected behavior and document supported input patterns" + +### Task Breakdown Strategy + +1. Identify foundational components first +2. Create tasks in dependency order (foundations before features) +3. Ensure each task delivers value independently +4. Avoid creating tasks that block each other + +### Task Requirements + +- Tasks must be **atomic** and **testable** or **verifiable** +- Each task should represent a single unit of work for one PR +- **Never** reference future tasks (only tasks with id < current task id) +- Ensure tasks are **independent** and don't depend on future work + +--- + +## 5. Implementing Tasks + +### 5.1. First step when implementing a task + +The very first things you must do when you take over a task are: + +* set the task in progress +* assign it to yourself + +```bash +# Example +backlog task edit 42 -s "In Progress" -a @{myself} +``` + +### 5.2. Create an Implementation Plan (The "how") + +Previously created tasks contain the why and the what. Once you are familiar with that part you should think about a +plan on **HOW** to tackle the task and all its acceptance criteria. This is your **Implementation Plan**. +First do a quick check to see if all the tools that you are planning to use are available in the environment you are +working in. +When you are ready, write it down in the task so that you can refer to it later. + +```bash +# Example +backlog task edit 42 --plan "1. Research codebase for references\n2Research on internet for similar cases\n3. Implement\n4. Test" +``` + +## 5.3. Implementation + +Once you have a plan, you can start implementing the task. This is where you write code, run tests, and make sure +everything works as expected. Follow the acceptance criteria one by one and MARK THEM AS COMPLETE as soon as you +finish them. + +### 5.4 Implementation Notes (PR description) + +When you are done implementing a tasks you need to prepare a PR description for it. +Because you cannot create PRs directly, write the PR as a clean description in the task notes. +Append notes progressively during implementation using `--append-notes`: + +``` +backlog task edit 42 --append-notes "Implemented X" --append-notes "Added tests" +``` + +```bash +# Example +backlog task edit 42 --notes "Implemented using pattern X because Reason Y, modified files Z and W" +``` + +**IMPORTANT**: Do NOT include an Implementation Plan when creating a task. The plan is added only after you start the +implementation. + +- Creation phase: provide Title, Description, Acceptance Criteria, and optionally labels/priority/assignee. +- When you begin work, switch to edit, set the task in progress and assign to yourself + `backlog task edit <id> -s "In Progress" -a "..."`. +- Think about how you would solve the task and add the plan: `backlog task edit <id> --plan "..."`. +- After updating the plan, share it with the user and ask for confirmation. Do not begin coding until the user approves the plan or explicitly tells you to skip the review. +- Add Implementation Notes only after completing the work: `backlog task edit <id> --notes "..."` (replace) or append progressively using `--append-notes`. + +## Phase discipline: What goes where + +- Creation: Title, Description, Acceptance Criteria, labels/priority/assignee. +- Implementation: Implementation Plan (after moving to In Progress and assigning to yourself). +- Wrap-up: Implementation Notes (Like a PR description), AC and Definition of Done checks. + +**IMPORTANT**: Only implement what's in the Acceptance Criteria. If you need to do more, either: + +1. Update the AC first: `backlog task edit 42 --ac "New requirement"` +2. Or create a new follow up task: `backlog task create "Additional feature"` + +--- + +## 6. Typical Workflow + +```bash +# 1. Identify work +backlog task list -s "To Do" --plain + +# 2. Read task details +backlog task 42 --plain + +# 3. Start work: assign yourself & change status +backlog task edit 42 -s "In Progress" -a @myself + +# 4. Add implementation plan +backlog task edit 42 --plan "1. Analyze\n2. Refactor\n3. Test" + +# 5. Share the plan with the user and wait for approval (do not write code yet) + +# 6. Work on the task (write code, test, etc.) + +# 7. Mark acceptance criteria as complete (supports multiple in one command) +backlog task edit 42 --check-ac 1 --check-ac 2 --check-ac 3 # Check all at once +# Or check them individually if preferred: +# backlog task edit 42 --check-ac 1 +# backlog task edit 42 --check-ac 2 +# backlog task edit 42 --check-ac 3 + +# 8. Add implementation notes (PR Description) +backlog task edit 42 --notes "Refactored using strategy pattern, updated tests" + +# 9. Mark task as done +backlog task edit 42 -s Done +``` + +--- + +## 7. Definition of Done (DoD) + +A task is **Done** only when **ALL** of the following are complete: + +### βœ… Via CLI Commands: + +1. **All acceptance criteria checked**: Use `backlog task edit <id> --check-ac <index>` for each +2. **Implementation notes added**: Use `backlog task edit <id> --notes "..."` +3. **Status set to Done**: Use `backlog task edit <id> -s Done` + +### βœ… Via Code/Testing: + +4. **Tests pass**: Run test suite and linting +5. **Documentation updated**: Update relevant docs if needed +6. **Code reviewed**: Self-review your changes +7. **No regressions**: Performance, security checks pass + +⚠️ **NEVER mark a task as Done without completing ALL items above** + +--- + +## 8. Finding Tasks and Content with Search + +When users ask you to find tasks related to a topic, use the `backlog search` command with `--plain` flag: + +```bash +# Search for tasks about authentication +backlog search "auth" --plain + +# Search only in tasks (not docs/decisions) +backlog search "login" --type task --plain + +# Search with filters +backlog search "api" --status "In Progress" --plain +backlog search "bug" --priority high --plain +``` + +**Key points:** +- Uses fuzzy matching - finds "authentication" when searching "auth" +- Searches task titles, descriptions, and content +- Also searches documents and decisions unless filtered with `--type task` +- Always use `--plain` flag for AI-readable output + +--- + +## 9. Quick Reference: DO vs DON'T + +### Viewing and Finding Tasks + +| Task | βœ… DO | ❌ DON'T | +|--------------|-----------------------------|---------------------------------| +| View task | `backlog task 42 --plain` | Open and read .md file directly | +| List tasks | `backlog task list --plain` | Browse backlog/tasks folder | +| Check status | `backlog task 42 --plain` | Look at file content | +| Find by topic| `backlog search "auth" --plain` | Manually grep through files | + +### Modifying Tasks + +| Task | βœ… DO | ❌ DON'T | +|---------------|--------------------------------------|-----------------------------------| +| Check AC | `backlog task edit 42 --check-ac 1` | Change `- [ ]` to `- [x]` in file | +| Add notes | `backlog task edit 42 --notes "..."` | Type notes into .md file | +| Change status | `backlog task edit 42 -s Done` | Edit status in frontmatter | +| Add AC | `backlog task edit 42 --ac "New"` | Add `- [ ] New` to file | + +--- + +## 10. Complete CLI Command Reference + +### Task Creation + +| Action | Command | +|------------------|-------------------------------------------------------------------------------------| +| Create task | `backlog task create "Title"` | +| With description | `backlog task create "Title" -d "Description"` | +| With AC | `backlog task create "Title" --ac "Criterion 1" --ac "Criterion 2"` | +| With all options | `backlog task create "Title" -d "Desc" -a @sara -s "To Do" -l auth --priority high` | +| Create draft | `backlog task create "Title" --draft` | +| Create subtask | `backlog task create "Title" -p 42` | + +### Task Modification + +| Action | Command | +|------------------|---------------------------------------------| +| Edit title | `backlog task edit 42 -t "New Title"` | +| Edit description | `backlog task edit 42 -d "New description"` | +| Change status | `backlog task edit 42 -s "In Progress"` | +| Assign | `backlog task edit 42 -a @sara` | +| Add labels | `backlog task edit 42 -l backend,api` | +| Set priority | `backlog task edit 42 --priority high` | + +### Acceptance Criteria Management + +| Action | Command | +|---------------------|-----------------------------------------------------------------------------| +| Add AC | `backlog task edit 42 --ac "New criterion" --ac "Another"` | +| Remove AC #2 | `backlog task edit 42 --remove-ac 2` | +| Remove multiple ACs | `backlog task edit 42 --remove-ac 2 --remove-ac 4` | +| Check AC #1 | `backlog task edit 42 --check-ac 1` | +| Check multiple ACs | `backlog task edit 42 --check-ac 1 --check-ac 3` | +| Uncheck AC #3 | `backlog task edit 42 --uncheck-ac 3` | +| Mixed operations | `backlog task edit 42 --check-ac 1 --uncheck-ac 2 --remove-ac 3 --ac "New"` | + +### Task Content + +| Action | Command | +|------------------|----------------------------------------------------------| +| Add plan | `backlog task edit 42 --plan "1. Step one\n2. Step two"` | +| Add notes | `backlog task edit 42 --notes "Implementation details"` | +| Add dependencies | `backlog task edit 42 --dep task-1 --dep task-2` | + +### Multi‑line Input (Description/Plan/Notes) + +The CLI preserves input literally. Shells do not convert `\n` inside normal quotes. Use one of the following to insert real newlines: + +- Bash/Zsh (ANSI‑C quoting): + - Description: `backlog task edit 42 --desc $'Line1\nLine2\n\nFinal'` + - Plan: `backlog task edit 42 --plan $'1. A\n2. B'` + - Notes: `backlog task edit 42 --notes $'Done A\nDoing B'` + - Append notes: `backlog task edit 42 --append-notes $'Progress update line 1\nLine 2'` +- POSIX portable (printf): + - `backlog task edit 42 --notes "$(printf 'Line1\nLine2')"` +- PowerShell (backtick n): + - `backlog task edit 42 --notes "Line1`nLine2"` + +Do not expect `"...\n..."` to become a newline. That passes the literal backslash + n to the CLI by design. + +Descriptions support literal newlines; shell examples may show escaped `\\n`, but enter a single `\n` to create a newline. + +### Implementation Notes Formatting + +- Keep implementation notes human-friendly and PR-ready: use short paragraphs or + bullet lists instead of a single long line. +- Lead with the outcome, then add supporting details (e.g., testing, follow-up + actions) on separate lines or bullets. +- Prefer Markdown bullets (`-` for unordered, `1.` for ordered) so Maintainers + can paste notes straight into GitHub without additional formatting. +- When using CLI flags like `--append-notes`, remember to include explicit + newlines. Example: + + ```bash + backlog task edit 42 --append-notes $'- Added new API endpoint\n- Updated tests\n- TODO: monitor staging deploy' + ``` + +### Task Operations + +| Action | Command | +|--------------------|----------------------------------------------| +| View task | `backlog task 42 --plain` | +| List tasks | `backlog task list --plain` | +| Search tasks | `backlog search "topic" --plain` | +| Search with filter | `backlog search "api" --status "To Do" --plain` | +| Filter by status | `backlog task list -s "In Progress" --plain` | +| Filter by assignee | `backlog task list -a @sara --plain` | +| Archive task | `backlog task archive 42` | +| Demote to draft | `backlog task demote 42` | + +--- + +## Common Issues + +| Problem | Solution | +|----------------------|--------------------------------------------------------------------| +| Task not found | Check task ID with `backlog task list --plain` | +| AC won't check | Use correct index: `backlog task 42 --plain` to see AC numbers | +| Changes not saving | Ensure you're using CLI, not editing files | +| Metadata out of sync | Re-edit via CLI to fix: `backlog task edit 42 -s <current-status>` | + +--- + +## Remember: The Golden Rule + +**🎯 If you want to change ANYTHING in a task, use the `backlog task edit` command.** +**πŸ“– Use CLI to read tasks, exceptionally READ task files directly, never WRITE to them.** + +Full help available: `backlog --help` diff --git a/src/guidelines/index.ts b/src/guidelines/index.ts new file mode 100644 index 0000000..cd0b7fd --- /dev/null +++ b/src/guidelines/index.ts @@ -0,0 +1,12 @@ +import agentGuidelinesContent from "./agent-guidelines.md" with { type: "text" }; +import mcpAgentNudgeContent from "./mcp/agent-nudge.md" with { type: "text" }; +import claudeAgentContent from "./project-manager-backlog.md" with { type: "text" }; + +export const AGENT_GUIDELINES = agentGuidelinesContent; +export const CLAUDE_GUIDELINES = agentGuidelinesContent; +export const CURSOR_GUIDELINES = agentGuidelinesContent; +export const GEMINI_GUIDELINES = agentGuidelinesContent; +export const COPILOT_GUIDELINES = agentGuidelinesContent; +export const README_GUIDELINES = `## AI Agent Guidelines\n\n${agentGuidelinesContent.replace(/^#.*\n/, "")}`; +export const CLAUDE_AGENT_CONTENT = claudeAgentContent; +export const MCP_AGENT_NUDGE = mcpAgentNudgeContent; diff --git a/src/guidelines/mcp/agent-nudge.md b/src/guidelines/mcp/agent-nudge.md new file mode 100644 index 0000000..ce856df --- /dev/null +++ b/src/guidelines/mcp/agent-nudge.md @@ -0,0 +1,25 @@ + +<CRITICAL_INSTRUCTION> + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management activities. + +**CRITICAL GUIDANCE** + +- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project. +- If your client only supports tools or the above request fails, call `backlog.get_workflow_overview()` tool to load the tool-oriented overview (it lists the matching guide tools). + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +These guides cover: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and completion +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + +</CRITICAL_INSTRUCTION> diff --git a/src/guidelines/mcp/index.ts b/src/guidelines/mcp/index.ts new file mode 100644 index 0000000..40fe007 --- /dev/null +++ b/src/guidelines/mcp/index.ts @@ -0,0 +1,13 @@ +import initRequired from "./init-required.md" with { type: "text" }; +import overviewResources from "./overview.md" with { type: "text" }; +import overviewTools from "./overview-tools.md" with { type: "text" }; +import taskCompletion from "./task-completion.md" with { type: "text" }; +import taskCreation from "./task-creation.md" with { type: "text" }; +import taskExecution from "./task-execution.md" with { type: "text" }; + +export const MCP_WORKFLOW_OVERVIEW = overviewResources.trim(); +export const MCP_WORKFLOW_OVERVIEW_TOOLS = overviewTools.trim(); +export const MCP_TASK_CREATION_GUIDE = taskCreation.trim(); +export const MCP_TASK_EXECUTION_GUIDE = taskExecution.trim(); +export const MCP_TASK_COMPLETION_GUIDE = taskCompletion.trim(); +export const MCP_INIT_REQUIRED_GUIDE = initRequired.trim(); diff --git a/src/guidelines/mcp/init-required.md b/src/guidelines/mcp/init-required.md new file mode 100644 index 0000000..4207926 --- /dev/null +++ b/src/guidelines/mcp/init-required.md @@ -0,0 +1,24 @@ +# Backlog.md Not Initialized + +This directory does not have Backlog.md initialized. + +**To set up task management for this project, run:** + +```bash +backlog init +``` + +This will create the necessary `backlog/` directory structure and configuration file. + +## What is Backlog.md? + +Backlog.md is a task management system that uses markdown files to track features, bugs, and structured work. It integrates with AI coding agents to help you manage your project tasks effectively. + +## Next Steps + +1. Run `backlog init` in your project directory +2. Follow the interactive setup prompts +3. Choose your preferred AI agent integration (Claude Code, Codex, or Gemini) +4. Start creating and managing tasks! + +For more information, visit: https://backlog.md diff --git a/src/guidelines/mcp/overview-tools.md b/src/guidelines/mcp/overview-tools.md new file mode 100644 index 0000000..8353ea2 --- /dev/null +++ b/src/guidelines/mcp/overview-tools.md @@ -0,0 +1,52 @@ +## Backlog.md Overview (Tools) + +Your client is using Backlog.md via tools. Use the following MCP tools to retrieve guidance and manage tasks. + +### When to Use Backlog + +**Create a task if the work requires planning or decision-making.** Ask yourself: "Do I need to think about HOW to do this?" + +- **YES** β†’ Search for existing task first, create if needed +- **NO** β†’ Just do it (the change is trivial/mechanical) + +**Examples of work that needs tasks:** +- "Fix the authentication bug" β†’ need to investigate, understand root cause, choose fix +- "Add error handling to the API" β†’ need to decide what errors, how to handle them +- "Refactor UserService" β†’ need to plan new structure, migration path + +**Examples of work that doesn't need tasks:** +- "Fix typo in README" β†’ obvious mechanical change +- "Update version number to 2.0" β†’ straightforward edit +- "Add missing semicolon" β†’ clear what to do + +**Always skip tasks for:** questions, exploratory requests, or knowledge transfer only. + +### Core Workflow Tools + +Use these tools to retrieve the required Backlog.md guidance in markdown form: + +- `get_workflow_overview` β€” Overview of when and how to use Backlog +- `get_task_creation_guide` β€” Detailed instructions for creating tasks (scope, acceptance criteria, structure) +- `get_task_execution_guide` β€” Planning and executing tasks (implementation plans, approvals, scope changes) +- `get_task_completion_guide` β€” Definition of Done, completion workflow, next steps + +Each tool returns the same content that resource-capable clients read via `backlog://workflow/...` URIs. + +### Typical Workflow (Tools) + +1. **Search first:** call `task_search` or `task_list` with filters to find existing work +2. **If found:** read details via `task_view`; follow execution/plan guidance from the retrieved markdown +3. **If not found:** consult `get_task_creation_guide`, then create tasks with `task_create` +4. **Execute & complete:** use the execution/completion guides to manage status, plans, notes, and acceptance criteria (`task_edit`, `task_archive`) + +### Core Principle + +Backlog tracks **commitments** (what will be built). Use your judgment to distinguish between "help me understand X" (no task) vs "add feature Y" (create tasks). + +### MCP Tools Quick Reference + +- `get_workflow_overview`, `get_task_creation_guide`, `get_task_execution_guide`, `get_task_completion_guide` +- `task_list`, `task_search`, `task_view`, `task_create`, `task_edit`, `task_archive` +- `document_list`, `document_view`, `document_create`, `document_update`, `document_search` + +**Always operate through the MCP tools above. Never edit markdown files directly; use the tools so relationships, metadata, and history stay consistent.** diff --git a/src/guidelines/mcp/overview.md b/src/guidelines/mcp/overview.md new file mode 100644 index 0000000..9924f17 --- /dev/null +++ b/src/guidelines/mcp/overview.md @@ -0,0 +1,60 @@ +## Backlog.md Overview (MCP) + +This project uses Backlog.md to track features, bugs, and structured work as tasks. + +### When to Use Backlog + +**Create a task if the work requires planning or decision-making:** + +Ask yourself: "Do I need to think about HOW to do this?" +- **YES** β†’ Search for existing task first, create if needed +- **NO** β†’ Just do it (the change is trivial/mechanical) + +**Examples of work that needs tasks:** +- "Fix the authentication bug" β†’ need to investigate, understand root cause, choose fix +- "Add error handling to the API" β†’ need to decide what errors, how to handle them +- "Refactor UserService" β†’ need to plan new structure, migration path + +**Examples of work that doesn't need tasks:** +- "Fix typo in README" β†’ obvious mechanical change +- "Update version number to 2.0" β†’ straightforward edit +- "Add missing semicolon" β†’ clear what to do + +**Always skip tasks for:** +- Questions and informational requests +- Reading/exploring/explaining code, issues, or concepts + +### Typical Workflow + +When the user requests non-trivial work: +1. **Search first:** Use `task_search` or `task_list` (with status filters) - work might already be tracked +2. **If found:** Work on the existing task. Check task-execution workflow to know how to proceed +3. **If not found:** Create task(s) based on scope (single task or present breakdown for approval). Check task-creation workflow for details +4. **Execute:** Follow task-execution guidelines + +Searching first avoids duplicate tasks and helps you understand existing context. + +### Detailed Guidance (Required) + +Read these resources to get essential instructions when: + +- **Creating tasks** β†’ `backlog://workflow/task-creation` - Scope assessment, acceptance criteria, parent/subtasks structure +- **Planning & executing work** β†’ `backlog://workflow/task-execution` - Planning workflow, implementation discipline, scope changes +- **Completing & reviewing tasks** β†’ `backlog://workflow/task-completion` - Definition of Done, completion checklist, next steps + +These guides contain critical workflows you need to follow for proper task management. + +### Core Principle + +Backlog tracks **commitments** (what will be built). Use your judgment to distinguish between "help me understand X" (no tracking) vs "add feature Y" (track in Backlog). + +### MCP Tools Quick Reference + +- `task_list` β€” list tasks with optional filtering by status, assignee, or labels +- `task_search` β€” search tasks by title and description +- `task_view` β€” read full task context (description, plan, notes, acceptance criteria) +- `task_create` β€” create new tasks with description and acceptance criteria +- `task_edit` β€” update task metadata, status, plan, notes, acceptance criteria, and dependencies +- `task_archive` β€” archive completed tasks + +**Always operate through MCP tools. Never edit markdown files directly so relationships, metadata, and history stay consistent.** diff --git a/src/guidelines/mcp/task-completion.md b/src/guidelines/mcp/task-completion.md new file mode 100644 index 0000000..6ab32f9 --- /dev/null +++ b/src/guidelines/mcp/task-completion.md @@ -0,0 +1,52 @@ +## Task Completion Guide + +### Completion Workflow + +1. **Verify all acceptance criteria** - Confirm every criterion is satisfied (use `task_view` to see current status) +2. **Run the Definition of Done checklist** (see below) +3. **Summarize the work** - Use `task_edit` (notesAppend field) to document what changed and why (treat it like a PR description) +4. **Confirm the implementation plan is captured and current** - Update the plan in Backlog if the executed approach deviated +5. **Update task status** - Set status to "Done" via `task_edit` +6. **Propose next steps** - Never autonomously create or start new tasks + +### Definition of Done Checklist + +- Implementation plan exists in the task record (`task_edit` planSet/planAppend) and reflects the final solution +- Acceptance criteria are all checked via `task_edit` (acceptanceCriteriaCheck field) +- Automated and relevant manual tests pass; no new warnings or regressions introduced +- Documentation or configuration updates completed when required +- Implementation notes capture what changed and why via `task_edit` (notesAppend field) +- Status transitions to "Done" via `task_edit` + +### After Completion + +**Never autonomously create or start new tasks.** Instead: + +- **If follow-up work is needed**: Present the idea to the user and ask whether to create a follow-up task +- **If this was a subtask**: + - Check if user explicitly told you to work on "parent task and all subtasks" + - If YES: Proceed directly to the next subtask without asking + - If NO: Ask user: "Subtask X is complete. Should I proceed with subtask Y, or would you like to review first?" +- **If all subtasks in a series are complete**: Update parent task status if appropriate, then ask user what to do next + +### Working with Subtasks + +- When completing a subtask, check all its acceptance criteria individually +- Update subtask status to "Done" via `task_edit` +- Document subtask-specific outcomes in the subtask's notes +- Only update parent task status when ALL subtasks are complete (or when explicitly instructed) + +### Implementation notes (PR summary) + +The implementation notes are often used as the summary of changes made, similar to a pull request description. + +Use `task_edit` (notesAppend field) to record: +- Implementation decisions and rationale +- Blockers encountered and how they were resolved +- Technical debt or future improvements identified +- Testing approach and results + +These notes help future developers (including AI agents) understand the context. +Do not repeat the same information that is clearly understandable from the code. + +Write a structured summary that highlights the key points of the implementation. diff --git a/src/guidelines/mcp/task-creation.md b/src/guidelines/mcp/task-creation.md new file mode 100644 index 0000000..7d2ed44 --- /dev/null +++ b/src/guidelines/mcp/task-creation.md @@ -0,0 +1,92 @@ +## Task Creation Guide + +This guide provides detailed instructions for creating well-structured tasks. You should already know WHEN to create tasks (from the overview). + +### Step 1: Search for existing work + +**IMPORTANT - Always use filters when searching:** +- Use `task_search` with query parameter (e.g., query="desktop app") +- Use `task_list` with status filter to exclude completed work (e.g., status="To Do" or status="In Progress") +- Never list all tasks including "Done" status without explicit user request +- Never search without a query or limit - this can overwhelm the context window + +Use `task_view` to read full context of related tasks. + +### Step 2: Assess scope BEFORE creating tasks + +**CRITICAL**: Before creating any tasks, assess whether the user's request is: +- **Single atomic task** (single focused PR): Create one task immediately +- **Multi-task feature or initiative** (multiple PRs, or parent task with subtasks): Create appropriate task structure + +**Scope assessment checklist** - Answer these questions FIRST: +1. Can this be completed in a single focused pull request? +2. Would a code reviewer be comfortable reviewing all changes in one sitting? +3. Are there natural breaking points where work could be independently delivered and tested? +4. Does the request span multiple subsystems, layers, or architectural concerns? +5. Are multiple tasks working on the same component or closely related functionality? + +If the work requires multiple tasks, proceed to choose the appropriate task structure (subtasks vs separate tasks). + +### Step 3: Choose task structure + +**When to use subtasks vs separate tasks:** + +**Use subtasks** (parent-child relationship) when: +- Multiple tasks all modify the same component or subsystem +- Tasks are tightly coupled and share the same high-level goal +- Tasks represent sequential phases of the same feature +- Example: Parent task "Desktop Application" with subtasks for Electron setup, IPC bridge, UI adaptation, packaging + +**Use separate tasks** (with dependencies) when: +- Tasks span different components or subsystems +- Tasks can be worked on independently by different developers +- Tasks have loose coupling with clear boundaries +- Example: Separate tasks for "API endpoint", "Frontend component", "Documentation" + +**Concrete example**: If a request spans multiple layersβ€”say an API change, a client update, and documentationβ€”create one parent task ("Launch bulk-edit mode") with subtasks for each layer. Note cross-layer dependencies (e.g., "UI waits on API schema") so different collaborators can work in parallel without blocking each other. + +### Step 4: Create multi-task structure + +When scope requires multiple tasks: +1. **Create the task structure**: Either parent task with subtasks, or separate tasks with dependencies +2. **Explain what you created** to the user after creation, including the reasoning for the structure +3. **Document relationships**: Record dependencies using `task_edit` so scheduling and merge-risk tooling stay accurate + +Create all tasks in the same session to maintain consistency and context. + +### Step 5: Create task(s) with proper scope + +**Title and description**: Explain desired outcome and user value (the WHY) + +**Acceptance criteria**: Specific, testable, and independent (the WHAT) +- Keep each checklist item atomic (e.g., "Display saves when user presses Ctrl+S") +- Include negative or edge scenarios when relevant +- Capture testing expectations explicitly + +**Never embed implementation details** in title, description, or acceptance criteria + +**Record dependencies** using `task_edit` for task ordering + +**Ask for clarification** if requirements are ambiguous + +### Step 6: Report created tasks + +After creation, show the user each new task's ID, title, description, and acceptance criteria (e.g., "Created task-290 – API endpoint: …"). This provides visibility into what was created and allows the user to request corrections if needed. + +### Common Anti-patterns to Avoid + +- Creating a single task called "Build desktop application" with 10+ acceptance criteria +- Adding implementation steps to acceptance criteria +- Creating a task before understanding if it needs to be split + +### Correct Pattern + +"This request spans electron setup, IPC bridge, UI adaptation, and packaging. I'll create 4 separate tasks to break this down properly." + +Then create the tasks and report what was created. + +### Additional Context Gathering + +- Use `task_view` to read the description, acceptance criteria, dependencies, current plan, and notes before acting +- Inspect relevant code/docs/tests in the repository to ground your understanding +- When permitted, consult up-to-date external references (design docs, service manuals, API specs) so your plan reflects current reality diff --git a/src/guidelines/mcp/task-execution.md b/src/guidelines/mcp/task-execution.md new file mode 100644 index 0000000..700a239 --- /dev/null +++ b/src/guidelines/mcp/task-execution.md @@ -0,0 +1,68 @@ +## Task Execution Guide + +### Planning Workflow + +> **Non-negotiable:** Capture an implementation plan in the Backlog task _before_ writing any code or running commands. The plan must live in the task record prior to implementation and remain up to date when you close the task. + +1. **Mark task as In Progress** via `task_edit` with status "In Progress" +2. **Assign to yourself** via `task_edit` with assignee field +3. **Draft the implementation plan** - Think through the approach, review code, identify key files +4. **Present plan to user** - Show your proposed implementation approach +5. **Wait for explicit approval** - Do not start coding until user confirms or asks you to skip review +6. **Record approved plan** - Use `task_edit` with planSet or planAppend to capture the agreed approach in the task +7. **Document the agreed breakdown** - In the parent task's plan, capture the final list of subtasks, owners, and sequencing so future agents see the structure the user approved + +**IMPORTANT:** Use tasks as a permanent storage for everything related to the work. Implementation plan and notes are essential to resume work in case of interruptions or handoffs. + +### Planning Guidelines + +- Keep the Backlog task as the single plan of record: capture the agreed approach with `task_edit` (planSet field) before writing code +- Use `task_edit` (planAppend field) to refine the plan when you learn more during implementation +- Verify prerequisites before committing to a plan: confirm required tools, access, data, and environment support are in place +- Keep plans structured and actionable: list concrete steps, highlight key files, call out risks, and note any checkpoints or validations +- Ensure the plan reflects the agreed user outcome and acceptance criteria; if expectations are unclear, clarify them before proceeding +- When additional context is required, review relevant code, documentation, or external references so the plan incorporates the latest knowledge +- Treat the plan and acceptance criteria as living guides - update both when the approach or expectations change so future readers understand the rationale +- If you need to add or remove tasks or shift scope later, pause and run the "present β†’ approval" loop again before editing the backlog; never change the breakdown silently + +### Working with Subtasks (Planning) + +- If working on a parent task with subtasks, create a high-level plan for the parent that outlines the overall approach +- Each subtask should have its own detailed implementation plan when you work on it +- Ensure subtask plans are consistent with the parent task's overall strategy + +### Execution Workflow + +- **IMPORTANT**: Do not touch the codebase until the implementation plan is approved _and_ recorded in the task via `task_edit` +- The recorded plan must stay accurate; if the approach shifts, update it first and get confirmation before continuing +- If feedback requires changes, revise the plan first via `task_edit` (planSet or planAppend fields) +- Work in short loops: implement, run the relevant tests, and immediately check off acceptance criteria with `task_edit` (acceptanceCriteriaCheck field) when they are met +- Log progress with `task_edit` (notesAppend field) to document decisions, blockers, or learnings +- Keep task status aligned with reality via `task_edit` + +### Handling Scope Changes + +If new work appears during implementation that wasn't in the original acceptance criteria: + +**STOP and ask the user**: +"I discovered [new work needed]. Should I: +1. Add acceptance criteria to the current task and continue, or +2. Create a follow-up task to handle this separately?" + +**Never**: +- Silently expand the scope without user approval +- Create new tasks on your own initiative +- Add acceptance criteria without user confirmation + +### Staying on Track + +- Stay within the scope defined by the plan and acceptance criteria +- Update the plan first if direction changes, then get user approval for the revised approach +- If you need to deviate from the plan, explain why and wait for confirmation + +### Working with Subtasks (Execution) + +- When user assigns you a parent task "and all subtasks", work through each subtask sequentially without asking for permission to move to the next one +- When completing a single subtask (without explicit instruction to continue), present progress and ask: "Subtask X is complete. Should I proceed with subtask Y, or would you like to review first?" +- Each subtask should be fully completed (all acceptance criteria met, tests passing) before moving to the next + diff --git a/src/guidelines/project-manager-backlog.md b/src/guidelines/project-manager-backlog.md new file mode 120000 index 0000000..f323db4 --- /dev/null +++ b/src/guidelines/project-manager-backlog.md @@ -0,0 +1 @@ +../../.claude/agents/project-manager-backlog.md \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a93e242 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,32 @@ +export * from "./readme.ts"; +// Types + +export { + _loadAgentGuideline, + type AgentInstructionFile, + addAgentInstructions, + type EnsureMcpGuidelinesResult, + ensureMcpGuidelines, + installClaudeAgent, +} from "./agent-instructions.ts"; +// Kanban board utilities +export { exportKanbanBoardToFile, generateKanbanBoardWithMetadata } from "./board.ts"; +// Constants +export * from "./constants/index.ts"; +// Core entry point +export { Core } from "./core/backlog.ts"; +export { SearchService } from "./core/search-service.ts"; + +// File system operations +export { FileSystem } from "./file-system/operations.ts"; + +// Git operations +export { + GitOperations, + initializeGitRepository, + isGitRepository, +} from "./git/operations.ts"; +// Markdown operations +export * from "./markdown/parser.ts"; +export * from "./markdown/serializer.ts"; +export * from "./types/index.ts"; diff --git a/src/markdown/parser.ts b/src/markdown/parser.ts new file mode 100644 index 0000000..33e9dbf --- /dev/null +++ b/src/markdown/parser.ts @@ -0,0 +1,189 @@ +import matter from "gray-matter"; +import type { AcceptanceCriterion, Decision, Document, ParsedMarkdown, Task } from "../types/index.ts"; +import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts"; + +function preprocessFrontmatter(frontmatter: string): string { + return frontmatter + .split(/\r?\n/) // Handle both Windows (\r\n) and Unix (\n) line endings + .map((line) => { + // Handle both assignee and reporter fields that start with @ + const match = line.match(/^(\s*(?:assignee|reporter):\s*)(.*)$/); + if (!match) return line; + + const [, prefix, raw] = match; + const value = raw?.trim() || ""; + + if ( + value && + !value.startsWith("[") && + !value.startsWith("'") && + !value.startsWith('"') && + !value.startsWith("-") + ) { + return `${prefix}"${value.replace(/"/g, '\\"')}"`; + } + return line; + }) + .join("\n"); // Always join with \n for consistent YAML parsing +} + +function normalizeDate(value: unknown): string { + if (!value) return ""; + if (value instanceof Date) { + // Check if this Date object came from a date-only string (time is midnight UTC) + const hours = value.getUTCHours(); + const minutes = value.getUTCMinutes(); + const seconds = value.getUTCSeconds(); + + if (hours === 0 && minutes === 0 && seconds === 0) { + // This was likely a date-only value, preserve it as date-only + return value.toISOString().slice(0, 10); + } + // This has actual time information, preserve it + return value.toISOString().slice(0, 16).replace("T", " "); + } + const str = String(value) + .trim() + .replace(/^['"]|['"]$/g, ""); + if (!str) return ""; + + // Check for datetime format first (YYYY-MM-DD HH:mm) + let match: RegExpMatchArray | null = str.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/); + if (match) { + // Already in correct format, return as-is + return str; + } + + // Check for ISO datetime format (YYYY-MM-DDTHH:mm) + match = str.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/); + if (match) { + // Convert T separator to space + return str.replace("T", " "); + } + + // Check for date-only format (YYYY-MM-DD) - backward compatibility + match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (match) { + return `${match[1]}-${match[2]}-${match[3]}`; + } + + // Legacy date formats (date-only for backward compatibility) + match = str.match(/^(\d{2})-(\d{2})-(\d{2})$/); + if (match) { + const [day, month, year] = match.slice(1); + return `20${year}-${month}-${day}`; + } + match = str.match(/^(\d{2})\/(\d{2})\/(\d{2})$/); + if (match) { + const [day, month, year] = match.slice(1); + return `20${year}-${month}-${day}`; + } + match = str.match(/^(\d{2})\.(\d{2})\.(\d{2})$/); + if (match) { + const [day, month, year] = match.slice(1); + return `20${year}-${month}-${day}`; + } + return str; +} + +export function parseMarkdown(content: string): ParsedMarkdown { + // Updated regex to handle both Windows (\r\n) and Unix (\n) line endings + const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/; + const match = content.match(fmRegex); + let toParse = content; + + if (match) { + const processed = preprocessFrontmatter(match[1] || ""); + // Replace with consistent line endings + toParse = content.replace(fmRegex, `---\n${processed}\n---`); + } + + const parsed = matter(toParse); + return { + frontmatter: parsed.data, + content: parsed.content.trim(), + }; +} + +export function parseTask(content: string): Task { + const { frontmatter, content: rawContent } = parseMarkdown(content); + + // Validate priority field + const priority = frontmatter.priority ? String(frontmatter.priority).toLowerCase() : undefined; + const validPriorities = ["high", "medium", "low"]; + const validatedPriority = + priority && validPriorities.includes(priority) ? (priority as "high" | "medium" | "low") : undefined; + + // Parse structured acceptance criteria (checked/text/index) from all sections + const structuredCriteria: AcceptanceCriterion[] = AcceptanceCriteriaManager.parseAllCriteria(rawContent); + + // Parse other sections + const descriptionSection = extractStructuredSection(rawContent, STRUCTURED_SECTION_KEYS.description) || ""; + const planSection = extractStructuredSection(rawContent, STRUCTURED_SECTION_KEYS.implementationPlan) || undefined; + const notesSection = extractStructuredSection(rawContent, STRUCTURED_SECTION_KEYS.implementationNotes) || undefined; + + return { + id: String(frontmatter.id || ""), + title: String(frontmatter.title || ""), + status: String(frontmatter.status || ""), + assignee: Array.isArray(frontmatter.assignee) + ? frontmatter.assignee.map(String) + : frontmatter.assignee + ? [String(frontmatter.assignee)] + : [], + reporter: frontmatter.reporter ? String(frontmatter.reporter) : undefined, + createdDate: normalizeDate(frontmatter.created_date), + updatedDate: frontmatter.updated_date ? normalizeDate(frontmatter.updated_date) : undefined, + labels: Array.isArray(frontmatter.labels) ? frontmatter.labels.map(String) : [], + milestone: frontmatter.milestone ? String(frontmatter.milestone) : undefined, + dependencies: Array.isArray(frontmatter.dependencies) ? frontmatter.dependencies.map(String) : [], + rawContent, + acceptanceCriteriaItems: structuredCriteria, + description: descriptionSection, + implementationPlan: planSection, + implementationNotes: notesSection, + parentTaskId: frontmatter.parent_task_id ? String(frontmatter.parent_task_id) : undefined, + subtasks: Array.isArray(frontmatter.subtasks) ? frontmatter.subtasks.map(String) : undefined, + priority: validatedPriority, + ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined, + onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined, + }; +} + +export function parseDecision(content: string): Decision { + const { frontmatter, content: rawContent } = parseMarkdown(content); + + return { + id: String(frontmatter.id || ""), + title: String(frontmatter.title || ""), + date: normalizeDate(frontmatter.date), + status: String(frontmatter.status || "proposed") as Decision["status"], + context: extractSection(rawContent, "Context") || "", + decision: extractSection(rawContent, "Decision") || "", + consequences: extractSection(rawContent, "Consequences") || "", + alternatives: extractSection(rawContent, "Alternatives"), + rawContent, // Raw markdown content without frontmatter + }; +} + +export function parseDocument(content: string): Document { + const { frontmatter, content: rawContent } = parseMarkdown(content); + + return { + id: String(frontmatter.id || ""), + title: String(frontmatter.title || ""), + type: String(frontmatter.type || "other") as Document["type"], + createdDate: normalizeDate(frontmatter.created_date), + updatedDate: frontmatter.updated_date ? normalizeDate(frontmatter.updated_date) : undefined, + rawContent, + tags: Array.isArray(frontmatter.tags) ? frontmatter.tags.map(String) : undefined, + }; +} + +function extractSection(content: string, sectionTitle: string): string | undefined { + // Normalize to LF for reliable matching across platforms + const src = content.replace(/\r\n/g, "\n"); + const regex = new RegExp(`## ${sectionTitle}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`, "i"); + const match = src.match(regex); + return match?.[1]?.trim(); +} diff --git a/src/markdown/section-titles.ts b/src/markdown/section-titles.ts new file mode 100644 index 0000000..300301f --- /dev/null +++ b/src/markdown/section-titles.ts @@ -0,0 +1,30 @@ +const BASE_SECTION_TITLES = [ + "Description", + "Acceptance Criteria", + "Implementation Plan", + "Implementation Notes", +] as const; + +const SECTION_TITLE_VARIANTS: Record<string, string[]> = { + "Acceptance Criteria": ["Acceptance Criteria (Optional)"], + "Implementation Plan": ["Implementation Plan (Optional)"], + "Implementation Notes": ["Implementation Notes (Optional)", "Notes", "Notes & Comments (Optional)"], +}; + +export function getStructuredSectionTitles(): string[] { + const titles = new Set<string>(); + for (const base of BASE_SECTION_TITLES) { + titles.add(base); + const variants = SECTION_TITLE_VARIANTS[base]; + if (variants) { + for (const variant of variants) { + titles.add(variant); + } + } + } + return Array.from(titles); +} + +export function getBaseStructuredSectionTitles(): string[] { + return Array.from(BASE_SECTION_TITLES); +} diff --git a/src/markdown/serializer.ts b/src/markdown/serializer.ts new file mode 100644 index 0000000..a294413 --- /dev/null +++ b/src/markdown/serializer.ts @@ -0,0 +1,146 @@ +import matter from "gray-matter"; +import type { Decision, Document, Task } from "../types/index.ts"; +import { normalizeAssignee } from "../utils/assignee.ts"; +import { AcceptanceCriteriaManager, getStructuredSections, updateStructuredSections } from "./structured-sections.ts"; + +export function serializeTask(task: Task): string { + normalizeAssignee(task); + const frontmatter = { + id: task.id, + title: task.title, + status: task.status, + assignee: task.assignee, + ...(task.reporter && { reporter: task.reporter }), + created_date: task.createdDate, + ...(task.updatedDate && { updated_date: task.updatedDate }), + labels: task.labels, + ...(task.milestone && { milestone: task.milestone }), + dependencies: task.dependencies, + ...(task.parentTaskId && { parent_task_id: task.parentTaskId }), + ...(task.subtasks && task.subtasks.length > 0 && { subtasks: task.subtasks }), + ...(task.priority && { priority: task.priority }), + ...(task.ordinal !== undefined && { ordinal: task.ordinal }), + ...(task.onStatusChange && { onStatusChange: task.onStatusChange }), + }; + + let contentBody = task.rawContent ?? ""; + if (typeof task.description === "string" && task.description.trim() !== "") { + contentBody = updateTaskDescription(contentBody, task.description); + } + if (Array.isArray(task.acceptanceCriteriaItems)) { + const existingCriteria = AcceptanceCriteriaManager.parseAllCriteria(task.rawContent ?? ""); + const hasExistingStructuredCriteria = existingCriteria.length > 0; + if (task.acceptanceCriteriaItems.length > 0 || hasExistingStructuredCriteria) { + contentBody = AcceptanceCriteriaManager.updateContent(contentBody, task.acceptanceCriteriaItems); + } + } + if (typeof task.implementationPlan === "string") { + contentBody = updateTaskImplementationPlan(contentBody, task.implementationPlan); + } + if (typeof task.implementationNotes === "string") { + contentBody = updateTaskImplementationNotes(contentBody, task.implementationNotes); + } + + const serialized = matter.stringify(contentBody, frontmatter); + // Ensure there's a blank line between frontmatter and content + return serialized.replace(/^(---\n(?:.*\n)*?---)\n(?!$)/, "$1\n\n"); +} + +export function serializeDecision(decision: Decision): string { + const frontmatter = { + id: decision.id, + title: decision.title, + date: decision.date, + status: decision.status, + }; + + let content = `## Context\n\n${decision.context}\n\n`; + content += `## Decision\n\n${decision.decision}\n\n`; + content += `## Consequences\n\n${decision.consequences}`; + + if (decision.alternatives) { + content += `\n\n## Alternatives\n\n${decision.alternatives}`; + } + + return matter.stringify(content, frontmatter); +} + +export function serializeDocument(document: Document): string { + const frontmatter = { + id: document.id, + title: document.title, + type: document.type, + created_date: document.createdDate, + ...(document.updatedDate && { updated_date: document.updatedDate }), + ...(document.tags && document.tags.length > 0 && { tags: document.tags }), + }; + + return matter.stringify(document.rawContent, frontmatter); +} + +export function updateTaskAcceptanceCriteria(content: string, criteria: string[]): string { + // Normalize to LF while computing, preserve original EOL at return + const useCRLF = /\r\n/.test(content); + const src = content.replace(/\r\n/g, "\n"); + // Find if there's already an Acceptance Criteria section + const criteriaRegex = /## Acceptance Criteria\s*\n([\s\S]*?)(?=\n## |$)/i; + const match = src.match(criteriaRegex); + + const newCriteria = criteria.map((criterion) => `- [ ] ${criterion}`).join("\n"); + const newSection = `## Acceptance Criteria\n\n${newCriteria}`; + + let out: string | undefined; + if (match) { + // Replace existing section + out = src.replace(criteriaRegex, newSection); + } else { + // Add new section at the end + out = `${src}\n\n${newSection}`; + } + return useCRLF ? out.replace(/\n/g, "\r\n") : out; +} + +export function updateTaskImplementationPlan(content: string, plan: string): string { + const sections = getStructuredSections(content); + return updateStructuredSections(content, { + description: sections.description ?? "", + implementationPlan: plan, + implementationNotes: sections.implementationNotes ?? "", + }); +} + +export function updateTaskImplementationNotes(content: string, notes: string): string { + const sections = getStructuredSections(content); + return updateStructuredSections(content, { + description: sections.description ?? "", + implementationPlan: sections.implementationPlan ?? "", + implementationNotes: notes, + }); +} + +export function appendTaskImplementationNotes(content: string, notesChunks: string | string[]): string { + const chunks = (Array.isArray(notesChunks) ? notesChunks : [notesChunks]) + .map((c) => String(c)) + .map((c) => c.replace(/\r\n/g, "\n")) + .map((c) => c.trim()) + .filter(Boolean); + + const sections = getStructuredSections(content); + const appendedBlock = chunks.join("\n\n"); + const existingNotes = sections.implementationNotes?.trim(); + const combined = existingNotes ? `${existingNotes}\n\n${appendedBlock}` : appendedBlock; + return updateStructuredSections(content, { + description: sections.description ?? "", + implementationPlan: sections.implementationPlan ?? "", + implementationNotes: combined, + }); +} + +export function updateTaskDescription(content: string, description: string): string { + const sections = getStructuredSections(content); + return updateStructuredSections(content, { + description, + implementationPlan: sections.implementationPlan ?? "", + implementationNotes: sections.implementationNotes ?? "", + }); +} diff --git a/src/markdown/structured-sections.ts b/src/markdown/structured-sections.ts new file mode 100644 index 0000000..069be64 --- /dev/null +++ b/src/markdown/structured-sections.ts @@ -0,0 +1,520 @@ +import type { AcceptanceCriterion } from "../types/index.ts"; +import { getStructuredSectionTitles } from "./section-titles.ts"; + +export type StructuredSectionKey = "description" | "implementationPlan" | "implementationNotes"; + +export const STRUCTURED_SECTION_KEYS: Record<StructuredSectionKey, StructuredSectionKey> = { + description: "description", + implementationPlan: "implementationPlan", + implementationNotes: "implementationNotes", +}; + +interface SectionConfig { + title: string; + markerId: string; +} + +const SECTION_CONFIG: Record<StructuredSectionKey, SectionConfig> = { + description: { title: "Description", markerId: "DESCRIPTION" }, + implementationPlan: { title: "Implementation Plan", markerId: "PLAN" }, + implementationNotes: { title: "Implementation Notes", markerId: "NOTES" }, +}; + +const SECTION_INSERTION_ORDER: StructuredSectionKey[] = ["description", "implementationPlan", "implementationNotes"]; + +const ACCEPTANCE_CRITERIA_SECTION_HEADER = "## Acceptance Criteria"; +const ACCEPTANCE_CRITERIA_TITLE = ACCEPTANCE_CRITERIA_SECTION_HEADER.replace(/^##\s*/, ""); +const KNOWN_SECTION_TITLES = new Set<string>([ + ...getStructuredSectionTitles(), + ACCEPTANCE_CRITERIA_TITLE, + "Acceptance Criteria (Optional)", +]); + +function normalizeToLF(content: string): { text: string; useCRLF: boolean } { + const useCRLF = /\r\n/.test(content); + return { text: content.replace(/\r\n/g, "\n"), useCRLF }; +} + +function restoreLineEndings(text: string, useCRLF: boolean): string { + return useCRLF ? text.replace(/\n/g, "\r\n") : text; +} + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getConfig(key: StructuredSectionKey): SectionConfig { + return SECTION_CONFIG[key]; +} + +function getBeginMarker(key: StructuredSectionKey): string { + return `<!-- SECTION:${getConfig(key).markerId}:BEGIN -->`; +} + +function getEndMarker(key: StructuredSectionKey): string { + return `<!-- SECTION:${getConfig(key).markerId}:END -->`; +} + +function buildSectionBlock(key: StructuredSectionKey, body: string): string { + const { title } = getConfig(key); + const begin = getBeginMarker(key); + const end = getEndMarker(key); + const normalized = body.replace(/\r\n/g, "\n").replace(/\s+$/g, ""); + const content = normalized ? `${normalized}\n` : ""; + return `## ${title}\n\n${begin}\n${content}${end}`; +} + +function structuredSectionLookahead(currentTitle: string): string { + const otherTitles = Array.from(KNOWN_SECTION_TITLES).filter( + (title) => title.toLowerCase() !== currentTitle.toLowerCase(), + ); + if (otherTitles.length === 0) return "(?=\\n*$)"; + const pattern = otherTitles.map((title) => escapeForRegex(title)).join("|"); + return `(?=\\n+## (?:${pattern})(?:\\s|$)|\\n*$)`; +} + +function sectionHeaderRegex(key: StructuredSectionKey): RegExp { + const { title } = getConfig(key); + return new RegExp(`## ${escapeForRegex(title)}\\s*\\n([\\s\\S]*?)${structuredSectionLookahead(title)}`, "i"); +} + +function acceptanceCriteriaSentinelRegex(flags = "i"): RegExp { + const header = escapeForRegex(ACCEPTANCE_CRITERIA_SECTION_HEADER); + const begin = escapeForRegex(AcceptanceCriteriaManager.BEGIN_MARKER); + const end = escapeForRegex(AcceptanceCriteriaManager.END_MARKER); + return new RegExp(`(\\n|^)${header}\\s*\\n${begin}\\s*\\n([\\s\\S]*?)${end}`, flags); +} + +function legacySectionRegex(title: string, flags: string): RegExp { + return new RegExp(`(\\n|^)## ${escapeForRegex(title)}\\s*\\n([\\s\\S]*?)${structuredSectionLookahead(title)}`, flags); +} + +function findSectionEndIndex(content: string, title: string): number | undefined { + const normalizedTitle = title.trim(); + let sentinelMatch: RegExpExecArray | null = null; + if (normalizedTitle.toLowerCase() === ACCEPTANCE_CRITERIA_TITLE.toLowerCase()) { + sentinelMatch = acceptanceCriteriaSentinelRegex().exec(content); + } else { + const keyEntry = Object.entries(SECTION_CONFIG).find( + ([, config]) => config.title.toLowerCase() === normalizedTitle.toLowerCase(), + ); + if (keyEntry) { + const key = keyEntry[0] as StructuredSectionKey; + sentinelMatch = new RegExp( + `## ${escapeForRegex(getConfig(key).title)}\\s*\\n${escapeForRegex(getBeginMarker(key))}\\s*\\n([\\s\\S]*?)${escapeForRegex(getEndMarker(key))}`, + "i", + ).exec(content); + } + } + + if (sentinelMatch) { + return sentinelMatch.index + sentinelMatch[0].length; + } + + const legacyMatch = legacySectionRegex(normalizedTitle, "i").exec(content); + if (legacyMatch) { + return legacyMatch.index + legacyMatch[0].length; + } + return undefined; +} + +function sentinelBlockRegex(key: StructuredSectionKey): RegExp { + const { title } = getConfig(key); + const begin = escapeForRegex(getBeginMarker(key)); + const end = escapeForRegex(getEndMarker(key)); + return new RegExp(`## ${escapeForRegex(title)}\\s*\\n${begin}\\s*\\n([\\s\\S]*?)${end}`, "i"); +} + +function stripSectionInstances(content: string, key: StructuredSectionKey): string { + const beginEsc = escapeForRegex(getBeginMarker(key)); + const endEsc = escapeForRegex(getEndMarker(key)); + const { title } = getConfig(key); + + let stripped = content; + const sentinelRegex = new RegExp( + `(\n|^)## ${escapeForRegex(title)}\\s*\\n${beginEsc}\\s*\\n([\\s\\S]*?)${endEsc}(?:\\s*\n|$)`, + "gi", + ); + stripped = stripped.replace(sentinelRegex, "\n"); + + const legacyRegex = legacySectionRegex(title, "gi"); + stripped = stripped.replace(legacyRegex, "\n"); + + return stripped.replace(/\n{3,}/g, "\n\n").trimEnd(); +} + +function insertAfterSection(content: string, title: string, block: string): { inserted: boolean; content: string } { + if (!block.trim()) return { inserted: false, content }; + const insertPos = findSectionEndIndex(content, title); + if (insertPos === undefined) return { inserted: false, content }; + const before = content.slice(0, insertPos).trimEnd(); + const after = content.slice(insertPos).replace(/^\s+/, ""); + const newContent = `${before}${before ? "\n\n" : ""}${block}${after ? `\n\n${after}` : ""}`; + return { inserted: true, content: newContent }; +} + +function insertAtStart(content: string, block: string): string { + const trimmedBlock = block.trim(); + if (!trimmedBlock) return content; + const trimmedContent = content.trim(); + if (!trimmedContent) return trimmedBlock; + return `${trimmedBlock}\n\n${trimmedContent}`; +} + +function appendBlock(content: string, block: string): string { + const trimmedBlock = block.trim(); + if (!trimmedBlock) return content; + const trimmedContent = content.trim(); + if (!trimmedContent) return trimmedBlock; + return `${trimmedContent}\n\n${trimmedBlock}`; +} + +export function extractStructuredSection(content: string, key: StructuredSectionKey): string | undefined { + const src = content.replace(/\r\n/g, "\n"); + const sentinelMatch = sentinelBlockRegex(key).exec(src); + if (sentinelMatch?.[1]) { + return sentinelMatch[1].trim() || undefined; + } + const legacyMatch = sectionHeaderRegex(key).exec(src); + return legacyMatch?.[1]?.trim() || undefined; +} + +export interface StructuredSectionValues { + description?: string; + implementationPlan?: string; + implementationNotes?: string; +} + +interface SectionValues extends StructuredSectionValues {} + +export function updateStructuredSections(content: string, sections: SectionValues): string { + const { text: src, useCRLF } = normalizeToLF(content); + + let working = src; + for (const key of SECTION_INSERTION_ORDER) { + working = stripSectionInstances(working, key); + } + working = working.trim(); + + const description = sections.description?.trim() || ""; + const plan = sections.implementationPlan?.trim() || ""; + const notes = sections.implementationNotes?.trim() || ""; + + let tail = working; + + if (plan) { + const planBlock = buildSectionBlock("implementationPlan", plan); + let res = insertAfterSection(tail, ACCEPTANCE_CRITERIA_TITLE, planBlock); + if (!res.inserted) { + res = insertAfterSection(tail, getConfig("description").title, planBlock); + } + if (!res.inserted) { + tail = insertAtStart(tail, planBlock); + } else { + tail = res.content; + } + } + + if (notes) { + const notesBlock = buildSectionBlock("implementationNotes", notes); + let res = insertAfterSection(tail, getConfig("implementationPlan").title, notesBlock); + if (!res.inserted) { + res = insertAfterSection(tail, ACCEPTANCE_CRITERIA_TITLE, notesBlock); + } + if (!res.inserted) { + tail = appendBlock(tail, notesBlock); + } else { + tail = res.content; + } + } + + let output = tail; + if (description) { + const descriptionBlock = buildSectionBlock("description", description); + output = insertAtStart(tail, descriptionBlock); + } + + const finalOutput = output.replace(/\n{3,}/g, "\n\n").trim(); + return restoreLineEndings(finalOutput, useCRLF); +} + +export function getStructuredSections(content: string): StructuredSectionValues { + return { + description: extractStructuredSection(content, "description") || undefined, + implementationPlan: extractStructuredSection(content, "implementationPlan") || undefined, + implementationNotes: extractStructuredSection(content, "implementationNotes") || undefined, + }; +} + +function acceptanceCriteriaLegacyRegex(flags: string): RegExp { + return new RegExp( + `(\\n|^)${escapeForRegex(ACCEPTANCE_CRITERIA_SECTION_HEADER)}\\s*\\n([\\s\\S]*?)${structuredSectionLookahead(ACCEPTANCE_CRITERIA_TITLE)}`, + flags, + ); +} + +function extractExistingAcceptanceCriteriaBody(content: string): { body: string; hasMarkers: boolean } | undefined { + const src = content.replace(/\r\n/g, "\n"); + const sentinelMatch = acceptanceCriteriaSentinelRegex("i").exec(src); + if (sentinelMatch?.[2] !== undefined) { + return { body: sentinelMatch[2], hasMarkers: true }; + } + const legacyMatch = acceptanceCriteriaLegacyRegex("i").exec(src); + if (legacyMatch?.[2] !== undefined) { + return { body: legacyMatch[2], hasMarkers: false }; + } + return undefined; +} + +/* biome-ignore lint/complexity/noStaticOnlyClass: Utility methods grouped for clarity */ +export class AcceptanceCriteriaManager { + static readonly BEGIN_MARKER = "<!-- AC:BEGIN -->"; + static readonly END_MARKER = "<!-- AC:END -->"; + static readonly SECTION_HEADER = ACCEPTANCE_CRITERIA_SECTION_HEADER; + + private static parseOldFormat(content: string): AcceptanceCriterion[] { + const src = content.replace(/\r\n/g, "\n"); + const criteriaRegex = /## Acceptance Criteria\s*\n([\s\S]*?)(?=\n## |$)/i; + const match = src.match(criteriaRegex); + if (!match || !match[1]) { + return []; + } + const lines = match[1].split("\n").filter((line) => line.trim()); + const criteria: AcceptanceCriterion[] = []; + let index = 1; + for (const line of lines) { + const checkboxMatch = line.match(/^- \[([ x])\] (.+)$/); + if (checkboxMatch?.[1] && checkboxMatch?.[2]) { + criteria.push({ + checked: checkboxMatch[1] === "x", + text: checkboxMatch[2], + index: index++, + }); + } + } + return criteria; + } + + static parseAcceptanceCriteria(content: string): AcceptanceCriterion[] { + const src = content.replace(/\r\n/g, "\n"); + const beginIndex = src.indexOf(AcceptanceCriteriaManager.BEGIN_MARKER); + const endIndex = src.indexOf(AcceptanceCriteriaManager.END_MARKER); + if (beginIndex === -1 || endIndex === -1) { + return AcceptanceCriteriaManager.parseOldFormat(src); + } + const acContent = src.substring(beginIndex + AcceptanceCriteriaManager.BEGIN_MARKER.length, endIndex); + const lines = acContent.split("\n").filter((line) => line.trim()); + const criteria: AcceptanceCriterion[] = []; + for (const line of lines) { + const match = line.match(/^- \[([ x])\] #(\d+) (.+)$/); + if (match?.[1] && match?.[2] && match?.[3]) { + criteria.push({ + checked: match[1] === "x", + text: match[3], + index: Number.parseInt(match[2], 10), + }); + } + } + return criteria; + } + + static formatAcceptanceCriteria(criteria: AcceptanceCriterion[], existingBody?: string): string { + if (criteria.length === 0) { + return ""; + } + const body = AcceptanceCriteriaManager.composeAcceptanceCriteriaBody(criteria, existingBody); + const lines = [AcceptanceCriteriaManager.SECTION_HEADER, AcceptanceCriteriaManager.BEGIN_MARKER]; + if (body.trim() !== "") { + lines.push(...body.split("\n")); + } + lines.push(AcceptanceCriteriaManager.END_MARKER); + return lines.join("\n"); + } + + static updateContent(content: string, criteria: AcceptanceCriterion[]): string { + // Normalize to LF while computing, preserve original EOL at return + const useCRLF = /\r\n/.test(content); + const src = content.replace(/\r\n/g, "\n"); + const existingBodyInfo = extractExistingAcceptanceCriteriaBody(src); + const newSection = AcceptanceCriteriaManager.formatAcceptanceCriteria(criteria, existingBodyInfo?.body); + + // Remove ALL existing Acceptance Criteria sections (legacy header blocks) + const legacyBlockRegex = acceptanceCriteriaLegacyRegex("gi"); + const matches = Array.from(src.matchAll(legacyBlockRegex)); + let insertionIndex: number | null = null; + const firstMatch = matches[0]; + if (firstMatch && firstMatch.index !== undefined) { + insertionIndex = firstMatch.index; + } + + let stripped = src.replace(legacyBlockRegex, "").trimEnd(); + // Also remove any stray marker-only blocks (defensive) + const markerBlockRegex = new RegExp( + `${AcceptanceCriteriaManager.BEGIN_MARKER.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}[\\s\\S]*?${AcceptanceCriteriaManager.END_MARKER.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`, + "gi", + ); + stripped = stripped.replace(markerBlockRegex, "").trimEnd(); + + if (!newSection) { + // If criteria is empty, return stripped content (all AC sections removed) + return stripped; + } + + // Insert the single consolidated section + if (insertionIndex !== null) { + const before = stripped.slice(0, insertionIndex).trimEnd(); + const after = stripped.slice(insertionIndex); + const out = `${before}${before ? "\n\n" : ""}${newSection}${after ? `\n\n${after}` : ""}`; + return useCRLF ? out.replace(/\n/g, "\r\n") : out; + } + + // No existing section found: append at end + { + const out = `${stripped}${stripped ? "\n\n" : ""}${newSection}`; + return useCRLF ? out.replace(/\n/g, "\r\n") : out; + } + } + + private static composeAcceptanceCriteriaBody(criteria: AcceptanceCriterion[], existingBody?: string): string { + const sorted = [...criteria].sort((a, b) => a.index - b.index); + if (sorted.length === 0) { + return ""; + } + const queue = [...sorted]; + const lines: string[] = []; + let nextNumber = 1; + const sourceLines = existingBody ? existingBody.replace(/\r\n/g, "\n").split("\n") : []; + + if (sourceLines.length > 0) { + for (const line of sourceLines) { + const trimmed = line.trim(); + const checkboxMatch = trimmed.match(/^- \[([ x])\] (?:#\d+ )?(.*)$/); + if (checkboxMatch) { + const criterion = queue.shift(); + if (!criterion) { + // Skip stale checklist entries when there are fewer criteria now + continue; + } + const newLine = `- [${criterion.checked ? "x" : " "}] #${nextNumber++} ${criterion.text}`; + lines.push(newLine); + } else { + lines.push(line); + } + } + } + + while (queue.length > 0) { + const criterion = queue.shift(); + if (!criterion) continue; + const lastLine = lines.length > 0 ? lines[lines.length - 1] : undefined; + if (lastLine && lastLine.trim() !== "" && !lastLine.trim().startsWith("- [")) { + lines.push(""); + } + lines.push(`- [${criterion.checked ? "x" : " "}] #${nextNumber++} ${criterion.text}`); + } + + while (lines.length > 0) { + const tail = lines[lines.length - 1]; + if (!tail || tail.trim() === "") { + lines.pop(); + } else { + break; + } + } + + return lines.join("\n"); + } + + private static parseAllBlocks(content: string): AcceptanceCriterion[] { + const marked: AcceptanceCriterion[] = []; + const legacy: AcceptanceCriterion[] = []; + // Normalize to LF to make matching platform-agnostic + const src = content.replace(/\r\n/g, "\n"); + // Find all Acceptance Criteria blocks (legacy header blocks) + const blockRegex = acceptanceCriteriaLegacyRegex("gi"); + let m: RegExpExecArray | null = blockRegex.exec(src); + while (m !== null) { + const block = m[2] || ""; + if ( + block.includes(AcceptanceCriteriaManager.BEGIN_MARKER) && + block.includes(AcceptanceCriteriaManager.END_MARKER) + ) { + // Capture lines within each marked pair + const markedBlockRegex = new RegExp( + `${AcceptanceCriteriaManager.BEGIN_MARKER.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}([\\s\\S]*?)${AcceptanceCriteriaManager.END_MARKER.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`, + "gi", + ); + let mm: RegExpExecArray | null = markedBlockRegex.exec(block); + while (mm !== null) { + const inside = mm[1] || ""; + const lineRegex = /^- \[([ x])\] (?:#\d+ )?(.+)$/gm; + let lm: RegExpExecArray | null = lineRegex.exec(inside); + while (lm !== null) { + marked.push({ checked: lm[1] === "x", text: String(lm?.[2] ?? ""), index: marked.length + 1 }); + lm = lineRegex.exec(inside); + } + mm = markedBlockRegex.exec(block); + } + } else { + // Legacy: parse checkbox lines without markers + const lineRegex = /^- \[([ x])\] (.+)$/gm; + let lm: RegExpExecArray | null = lineRegex.exec(block); + while (lm !== null) { + legacy.push({ checked: lm[1] === "x", text: String(lm?.[2] ?? ""), index: legacy.length + 1 }); + lm = lineRegex.exec(block); + } + } + m = blockRegex.exec(src); + } + // Prefer marked content when present; otherwise fall back to legacy + return marked.length > 0 ? marked : legacy; + } + + static parseAllCriteria(content: string): AcceptanceCriterion[] { + const list = AcceptanceCriteriaManager.parseAllBlocks(content); + return list.map((c, i) => ({ ...c, index: i + 1 })); + } + + static addCriteria(content: string, newCriteria: string[]): string { + const existing = AcceptanceCriteriaManager.parseAllCriteria(content); + let nextIndex = existing.length > 0 ? Math.max(...existing.map((c) => c.index)) + 1 : 1; + for (const text of newCriteria) { + existing.push({ checked: false, text: text.trim(), index: nextIndex++ }); + } + return AcceptanceCriteriaManager.updateContent(content, existing); + } + + static removeCriterionByIndex(content: string, index: number): string { + const criteria = AcceptanceCriteriaManager.parseAllCriteria(content); + const filtered = criteria.filter((c) => c.index !== index); + if (filtered.length === criteria.length) { + throw new Error(`Acceptance criterion #${index} not found`); + } + const renumbered = filtered.map((c, i) => ({ ...c, index: i + 1 })); + return AcceptanceCriteriaManager.updateContent(content, renumbered); + } + + static checkCriterionByIndex(content: string, index: number, checked: boolean): string { + const criteria = AcceptanceCriteriaManager.parseAllCriteria(content); + const criterion = criteria.find((c) => c.index === index); + if (!criterion) { + throw new Error(`Acceptance criterion #${index} not found`); + } + criterion.checked = checked; + return AcceptanceCriteriaManager.updateContent(content, criteria); + } + + static migrateToStableFormat(content: string): string { + const criteria = AcceptanceCriteriaManager.parseAllCriteria(content); + if (criteria.length === 0) { + return content; + } + if ( + content.includes(AcceptanceCriteriaManager.BEGIN_MARKER) && + content.includes(AcceptanceCriteriaManager.END_MARKER) + ) { + return content; + } + return AcceptanceCriteriaManager.updateContent(content, criteria); + } +} diff --git a/src/mcp/README.md b/src/mcp/README.md new file mode 100644 index 0000000..748e18b --- /dev/null +++ b/src/mcp/README.md @@ -0,0 +1,31 @@ +# Backlog.md MCP Implementation (MVP) + +This directory exposes a minimal stdio MCP surface so local agents can work with backlog.md without duplicating business +logic. + +## What’s included + +- `server.ts` / `createMcpServer()` – bootstraps a stdio-only server that extends `Core` and registers task and document tools (`task_*`, `document_*`) for MCP clients. +- `tasks/` – consolidated task tooling that delegates to shared Core helpers (including plan/notes/AC editing). +- `documents/` – document tooling layered on `Core`’s document helpers for list/view/create/update/search flows. +- `tools/dependency-tools.ts` – dependency helpers reusing shared builders. +- `resources/` – lightweight resource adapters for agents. +- `guidelines/mcp/` – task workflow content surfaced via MCP. + +Everything routes through existing Core APIs so the MCP layer stays a protocol wrapper. + +## Development workflow + +```bash +# Run the stdio server from the repo +bun run cli mcp start + +# Or via the globally installed CLI +backlog mcp start + +# Tests +bun test src/test/mcp-*.test.ts +``` + +The test suite keeps to the reduced surface area and focuses on happy-path coverage for tasks, dependencies, and server +bootstrap. diff --git a/src/mcp/errors/mcp-errors.ts b/src/mcp/errors/mcp-errors.ts new file mode 100644 index 0000000..7f287a7 --- /dev/null +++ b/src/mcp/errors/mcp-errors.ts @@ -0,0 +1,126 @@ +import type { CallToolResult } from "../types.ts"; + +/** + * Base MCP error class for all MCP-related errors + */ +export class McpError extends Error { + constructor( + message: string, + public code: string, + public details?: unknown, + ) { + super(message); + this.name = "McpError"; + } +} + +/** + * Validation error for input validation failures + */ +export class McpValidationError extends McpError { + constructor(message: string, validationError?: unknown) { + super(message, "VALIDATION_ERROR", validationError); + } +} + +/** + * Authentication error for auth failures + */ +export class McpAuthenticationError extends McpError { + constructor(message = "Authentication required") { + super(message, "AUTH_ERROR"); + } +} + +/** + * Connection error for transport-level failures + */ +export class McpConnectionError extends McpError { + constructor(message: string, details?: unknown) { + super(message, "CONNECTION_ERROR", details); + } +} + +/** + * Internal error for unexpected failures + */ +export class McpInternalError extends McpError { + constructor(message = "An unexpected error occurred", details?: unknown) { + super(message, "INTERNAL_ERROR", details); + } +} + +/** + * Formats MCP errors into standardized tool responses + */ +function buildErrorResult(code: string, message: string, details?: unknown): CallToolResult { + const includeDetails = !!process.env.DEBUG; + const structured = details !== undefined ? { code, details } : { code }; + return { + content: [ + { + type: "text", + text: formatErrorMarkdown(code, message, details, includeDetails), + }, + ], + isError: true, + structuredContent: structured, + }; +} + +export function handleMcpError(error: unknown): CallToolResult { + if (error instanceof McpError) { + return buildErrorResult(error.code, error.message, error.details); + } + + console.error("Unexpected MCP error:", error); + + return { + content: [ + { + type: "text", + text: formatErrorMarkdown("INTERNAL_ERROR", "An unexpected error occurred", error, !!process.env.DEBUG), + }, + ], + isError: true, + structuredContent: { + code: "INTERNAL_ERROR", + details: error, + }, + }; +} + +/** + * Formats successful responses in a consistent structure + */ +export function handleMcpSuccess(data: unknown): CallToolResult { + return { + content: [ + { + type: "text", + text: "OK", + }, + ], + structuredContent: { + success: true, + data, + }, + }; +} + +/** + * Format error messages in markdown for consistent MCP error responses + */ +export function formatErrorMarkdown(code: string, message: string, details?: unknown, includeDetails = false): string { + // Include details only when explicitly requested (e.g., debug mode) + if (includeDetails && details) { + let result = `${code}: ${message}`; + + const detailsText = typeof details === "string" ? details : JSON.stringify(details, null, 2); + result += `\n ${detailsText}`; + + return result; + } + + return message; +} diff --git a/src/mcp/resources/init-required/index.ts b/src/mcp/resources/init-required/index.ts new file mode 100644 index 0000000..dff5b5e --- /dev/null +++ b/src/mcp/resources/init-required/index.ts @@ -0,0 +1,25 @@ +import { MCP_INIT_REQUIRED_GUIDE } from "../../../guidelines/mcp/index.ts"; +import type { McpServer } from "../../server.ts"; +import type { McpResourceHandler } from "../../types.ts"; + +function createInitRequiredResource(): McpResourceHandler { + return { + uri: "backlog://init-required", + name: "Backlog.md Not Initialized", + description: "Instructions for initializing Backlog.md in this project", + mimeType: "text/markdown", + handler: async () => ({ + contents: [ + { + uri: "backlog://init-required", + mimeType: "text/markdown", + text: MCP_INIT_REQUIRED_GUIDE, + }, + ], + }), + }; +} + +export function registerInitRequiredResource(server: McpServer): void { + server.addResource(createInitRequiredResource()); +} diff --git a/src/mcp/resources/workflow/index.ts b/src/mcp/resources/workflow/index.ts new file mode 100644 index 0000000..2e6fc1f --- /dev/null +++ b/src/mcp/resources/workflow/index.ts @@ -0,0 +1,25 @@ +import type { McpServer } from "../../server.ts"; +import type { McpResourceHandler } from "../../types.ts"; +import { WORKFLOW_GUIDES } from "../../workflow-guides.ts"; + +export function registerWorkflowResources(server: McpServer): void { + for (const guide of WORKFLOW_GUIDES) { + const resource: McpResourceHandler = { + uri: guide.uri, + name: guide.name, + description: guide.description, + mimeType: guide.mimeType, + handler: async () => ({ + contents: [ + { + uri: guide.uri, + mimeType: guide.mimeType, + text: guide.resourceText, + }, + ], + }), + }; + + server.addResource(resource); + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..02d4116 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,289 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { Core } from "../core/backlog.ts"; +import { getPackageName } from "../utils/app-info.ts"; +import { getVersion } from "../utils/version.ts"; +import { registerInitRequiredResource } from "./resources/init-required/index.ts"; +import { registerWorkflowResources } from "./resources/workflow/index.ts"; +import { registerDocumentTools } from "./tools/documents/index.ts"; +import { registerTaskTools } from "./tools/tasks/index.ts"; +import { registerWorkflowTools } from "./tools/workflow/index.ts"; +import type { + CallToolResult, + GetPromptResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + McpPromptHandler, + McpResourceHandler, + McpToolHandler, + ReadResourceResult, +} from "./types.ts"; + +/** + * Minimal MCP server implementation for stdio transport. + * + * The Backlog.md MCP server is intentionally local-only and exposes tools, + * resources, and prompts through the stdio transport so that desktop editors + * (e.g. Claude Code) can interact with a project without network exposure. + */ +const APP_NAME = getPackageName(); +const APP_VERSION = await getVersion(); +const INSTRUCTIONS_NORMAL = + "At the beginning of each session, read the backlog://workflow/overview resource to understand when and how to use Backlog.md for task management. Additional detailed guides are available as resources when needed."; +const INSTRUCTIONS_FALLBACK = + "Backlog.md is not initialized in this directory. Read the backlog://init-required resource for setup instructions."; + +type ServerInitOptions = { + debug?: boolean; +}; + +export class McpServer extends Core { + private readonly server: Server; + private transport?: StdioServerTransport; + + private readonly tools = new Map<string, McpToolHandler>(); + private readonly resources = new Map<string, McpResourceHandler>(); + private readonly prompts = new Map<string, McpPromptHandler>(); + + constructor(projectRoot: string, instructions: string) { + super(projectRoot, { enableWatchers: true }); + + this.server = new Server( + { + name: APP_NAME, + version: APP_VERSION, + }, + { + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + prompts: { listChanged: true }, + }, + instructions, + }, + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => this.listTools()); + this.server.setRequestHandler(CallToolRequestSchema, async (request) => this.callTool(request)); + this.server.setRequestHandler(ListResourcesRequestSchema, async () => this.listResources()); + this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => this.listResourceTemplates()); + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => this.readResource(request)); + this.server.setRequestHandler(ListPromptsRequestSchema, async () => this.listPrompts()); + this.server.setRequestHandler(GetPromptRequestSchema, async (request) => this.getPrompt(request)); + } + + /** + * Register a tool implementation with the server. + */ + public addTool(tool: McpToolHandler): void { + this.tools.set(tool.name, tool); + } + + /** + * Register a resource implementation with the server. + */ + public addResource(resource: McpResourceHandler): void { + this.resources.set(resource.uri, resource); + } + + /** + * Register a prompt implementation with the server. + */ + public addPrompt(prompt: McpPromptHandler): void { + this.prompts.set(prompt.name, prompt); + } + + /** + * Connect the server to the stdio transport. + */ + public async connect(): Promise<void> { + if (this.transport) { + return; + } + + this.transport = new StdioServerTransport(); + await this.server.connect(this.transport); + } + + /** + * Start the server. The stdio transport begins handling requests as soon as + * it is connected, so this method exists primarily for symmetry with + * callers that expect an explicit start step. + */ + public async start(): Promise<void> { + if (!this.transport) { + throw new Error("MCP server not connected. Call connect() before start()."); + } + } + + /** + * Stop the server and release transport resources. + */ + public async stop(): Promise<void> { + await this.server.close(); + this.transport = undefined; + } + + public getServer(): Server { + return this.server; + } + + // -- Internal handlers -------------------------------------------------- + + protected async listTools(): Promise<ListToolsResult> { + return { + tools: Array.from(this.tools.values()).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: { + type: "object", + ...tool.inputSchema, + }, + })), + }; + } + + protected async callTool(request: { + params: { name: string; arguments?: Record<string, unknown> }; + }): Promise<CallToolResult> { + const { name, arguments: args = {} } = request.params; + const tool = this.tools.get(name); + + if (!tool) { + throw new Error(`Tool not found: ${name}`); + } + + return await tool.handler(args); + } + + protected async listResources(): Promise<ListResourcesResult> { + return { + resources: Array.from(this.resources.values()).map((resource) => ({ + uri: resource.uri, + name: resource.name || "Unnamed Resource", + description: resource.description, + mimeType: resource.mimeType, + })), + }; + } + + protected async listResourceTemplates(): Promise<ListResourceTemplatesResult> { + return { + resourceTemplates: [], + }; + } + + protected async readResource(request: { params: { uri: string } }): Promise<ReadResourceResult> { + const { uri } = request.params; + + // Exact match first + let resource = this.resources.get(uri); + + // Fallback to base URI for parameterised resources + if (!resource) { + const baseUri = uri.split("?")[0] || uri; + resource = this.resources.get(baseUri); + } + + if (!resource) { + throw new Error(`Resource not found: ${uri}`); + } + + return await resource.handler(uri); + } + + protected async listPrompts(): Promise<ListPromptsResult> { + return { + prompts: Array.from(this.prompts.values()).map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments, + })), + }; + } + + protected async getPrompt(request: { + params: { name: string; arguments?: Record<string, unknown> }; + }): Promise<GetPromptResult> { + const { name, arguments: args = {} } = request.params; + const prompt = this.prompts.get(name); + + if (!prompt) { + throw new Error(`Prompt not found: ${name}`); + } + + return await prompt.handler(args); + } + + /** + * Helper exposed for tests so they can call handlers directly. + */ + public get testInterface() { + return { + listTools: () => this.listTools(), + callTool: (request: { params: { name: string; arguments?: Record<string, unknown> } }) => this.callTool(request), + listResources: () => this.listResources(), + listResourceTemplates: () => this.listResourceTemplates(), + readResource: (request: { params: { uri: string } }) => this.readResource(request), + listPrompts: () => this.listPrompts(), + getPrompt: (request: { params: { name: string; arguments?: Record<string, unknown> } }) => + this.getPrompt(request), + }; + } +} + +/** + * Factory that bootstraps a fully configured MCP server instance. + * + * If backlog is not initialized in the project directory, the server will start + * successfully but only provide the backlog://init-required resource to guide + * users to run `backlog init`. + */ +export async function createMcpServer(projectRoot: string, options: ServerInitOptions = {}): Promise<McpServer> { + // We need to check config first to determine which instructions to use + const tempCore = new Core(projectRoot); + await tempCore.ensureConfigLoaded(); + const config = await tempCore.filesystem.loadConfig(); + + // Create server with appropriate instructions + const instructions = config ? INSTRUCTIONS_NORMAL : INSTRUCTIONS_FALLBACK; + const server = new McpServer(projectRoot, instructions); + + // Graceful fallback: if config doesn't exist, provide init-required resource + if (!config) { + registerInitRequiredResource(server); + + if (options.debug) { + console.error("MCP server initialised in fallback mode (backlog not initialized in this directory)."); + } + + return server; + } + + // Normal mode: full tools and resources + registerWorkflowResources(server); + registerWorkflowTools(server); + registerTaskTools(server, config); + registerDocumentTools(server, config); + + if (options.debug) { + console.error("MCP server initialised (stdio transport only)."); + } + + return server; +} diff --git a/src/mcp/tools/documents/handlers.ts b/src/mcp/tools/documents/handlers.ts new file mode 100644 index 0000000..5181f2e --- /dev/null +++ b/src/mcp/tools/documents/handlers.ts @@ -0,0 +1,177 @@ +import type { Document, DocumentSearchResult } from "../../../types/index.ts"; +import { McpError } from "../../errors/mcp-errors.ts"; +import type { McpServer } from "../../server.ts"; +import type { CallToolResult } from "../../types.ts"; +import { formatDocumentCallResult } from "../../utils/document-response.ts"; + +export type DocumentListArgs = { + search?: string; +}; + +export type DocumentViewArgs = { + id: string; +}; + +export type DocumentCreateArgs = { + title: string; + content: string; +}; + +export type DocumentUpdateArgs = { + id: string; + title?: string; + content: string; +}; + +export type DocumentSearchArgs = { + query: string; + limit?: number; +}; + +export class DocumentHandlers { + constructor(private readonly core: McpServer) {} + + private formatDocumentSummaryLine(document: Document): string { + const metadata: string[] = [`type: ${document.type}`, `created: ${document.createdDate}`]; + if (document.updatedDate) { + metadata.push(`updated: ${document.updatedDate}`); + } + if (document.tags && document.tags.length > 0) { + metadata.push(`tags: ${document.tags.join(", ")}`); + } else { + metadata.push("tags: (none)"); + } + return ` ${document.id} - ${document.title} (${metadata.join(", ")})`; + } + + private formatScore(score: number | null): string { + if (score === null || score === undefined) { + return ""; + } + const invertedScore = 1 - score; + return ` [score ${invertedScore.toFixed(3)}]`; + } + + private async loadDocumentOrThrow(id: string): Promise<Document> { + const document = await this.core.getDocument(id); + if (!document) { + throw new McpError(`Document not found: ${id}`, "DOCUMENT_NOT_FOUND"); + } + return document; + } + + async listDocuments(args: DocumentListArgs = {}): Promise<CallToolResult> { + const search = args.search?.toLowerCase(); + const documents = await this.core.filesystem.listDocuments(); + + const filtered = + search && search.length > 0 + ? documents.filter((document) => { + const haystacks = [document.id, document.title]; + return haystacks.some((value) => value.toLowerCase().includes(search)); + }) + : documents; + + if (filtered.length === 0) { + return { + content: [ + { + type: "text", + text: "No documents found.", + }, + ], + }; + } + + const lines: string[] = ["Documents:"]; + for (const document of filtered) { + lines.push(this.formatDocumentSummaryLine(document)); + } + + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } + + async viewDocument(args: DocumentViewArgs): Promise<CallToolResult> { + const document = await this.loadDocumentOrThrow(args.id); + return await formatDocumentCallResult(document); + } + + async createDocument(args: DocumentCreateArgs): Promise<CallToolResult> { + try { + const document = await this.core.createDocumentWithId(args.title, args.content); + return await formatDocumentCallResult(document, { + summaryLines: ["Document created successfully."], + }); + } catch (error) { + if (error instanceof Error) { + throw new McpError(`Failed to create document: ${error.message}`, "OPERATION_FAILED"); + } + throw new McpError("Failed to create document.", "OPERATION_FAILED"); + } + } + + async updateDocument(args: DocumentUpdateArgs): Promise<CallToolResult> { + const existing = await this.loadDocumentOrThrow(args.id); + const nextDocument = args.title ? { ...existing, title: args.title } : existing; + + try { + await this.core.updateDocument(nextDocument, args.content); + const refreshed = await this.core.getDocument(existing.id); + if (!refreshed) { + throw new McpError(`Document not found: ${args.id}`, "DOCUMENT_NOT_FOUND"); + } + return await formatDocumentCallResult(refreshed, { + summaryLines: ["Document updated successfully."], + }); + } catch (error) { + if (error instanceof Error) { + throw new McpError(`Failed to update document: ${error.message}`, "OPERATION_FAILED"); + } + throw new McpError("Failed to update document.", "OPERATION_FAILED"); + } + } + + async searchDocuments(args: DocumentSearchArgs): Promise<CallToolResult> { + const searchService = await this.core.getSearchService(); + const results = searchService.search({ + query: args.query, + limit: args.limit, + types: ["document"], + }); + + const documents = results.filter((result): result is DocumentSearchResult => result.type === "document"); + if (documents.length === 0) { + return { + content: [ + { + type: "text", + text: `No documents found for "${args.query}".`, + }, + ], + }; + } + + const lines: string[] = ["Documents:"]; + for (const result of documents) { + const { document } = result; + const scoreText = this.formatScore(result.score); + lines.push(` ${document.id} - ${document.title}${scoreText}`); + } + + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } +} diff --git a/src/mcp/tools/documents/index.ts b/src/mcp/tools/documents/index.ts new file mode 100644 index 0000000..08d6312 --- /dev/null +++ b/src/mcp/tools/documents/index.ts @@ -0,0 +1,94 @@ +import type { BacklogConfig } from "../../../types/index.ts"; +import type { McpServer } from "../../server.ts"; +import type { McpToolHandler } from "../../types.ts"; +import { createSimpleValidatedTool } from "../../validation/tool-wrapper.ts"; +import type { + DocumentCreateArgs, + DocumentListArgs, + DocumentSearchArgs, + DocumentUpdateArgs, + DocumentViewArgs, +} from "./handlers.ts"; +import { DocumentHandlers } from "./handlers.ts"; +import { + documentCreateSchema, + documentListSchema, + documentSearchSchema, + documentUpdateSchema, + documentViewSchema, +} from "./schemas.ts"; + +export function registerDocumentTools(server: McpServer, _config: BacklogConfig): void { + const handlers = new DocumentHandlers(server); + + const listDocumentsTool: McpToolHandler = createSimpleValidatedTool( + { + name: "document_list", + description: "List Backlog.md documents with optional substring filtering", + inputSchema: documentListSchema, + }, + documentListSchema, + async (input) => handlers.listDocuments(input as DocumentListArgs), + ); + + const viewDocumentTool: McpToolHandler = createSimpleValidatedTool( + { + name: "document_view", + description: "View a Backlog.md document including metadata and markdown content", + inputSchema: documentViewSchema, + }, + documentViewSchema, + async (input) => handlers.viewDocument(input as DocumentViewArgs), + ); + + const createDocumentTool: McpToolHandler = createSimpleValidatedTool( + { + name: "document_create", + description: "Create a Backlog.md document using the shared ID generator", + inputSchema: documentCreateSchema, + }, + documentCreateSchema, + async (input) => handlers.createDocument(input as DocumentCreateArgs), + ); + + const updateDocumentTool: McpToolHandler = createSimpleValidatedTool( + { + name: "document_update", + description: "Update an existing Backlog.md document's content and optional title", + inputSchema: documentUpdateSchema, + }, + documentUpdateSchema, + async (input) => handlers.updateDocument(input as DocumentUpdateArgs), + ); + + const searchDocumentTool: McpToolHandler = createSimpleValidatedTool( + { + name: "document_search", + description: "Search Backlog.md documents using the shared fuzzy index", + inputSchema: documentSearchSchema, + }, + documentSearchSchema, + async (input) => handlers.searchDocuments(input as DocumentSearchArgs), + ); + + server.addTool(listDocumentsTool); + server.addTool(viewDocumentTool); + server.addTool(createDocumentTool); + server.addTool(updateDocumentTool); + server.addTool(searchDocumentTool); +} + +export type { + DocumentCreateArgs, + DocumentListArgs, + DocumentSearchArgs, + DocumentUpdateArgs, + DocumentViewArgs, +} from "./handlers.ts"; +export { + documentCreateSchema, + documentListSchema, + documentSearchSchema, + documentUpdateSchema, + documentViewSchema, +} from "./schemas.ts"; diff --git a/src/mcp/tools/documents/schemas.ts b/src/mcp/tools/documents/schemas.ts new file mode 100644 index 0000000..8c39187 --- /dev/null +++ b/src/mcp/tools/documents/schemas.ts @@ -0,0 +1,81 @@ +import type { JsonSchema } from "../../validation/validators.ts"; + +export const documentListSchema: JsonSchema = { + type: "object", + properties: { + search: { + type: "string", + maxLength: 200, + }, + }, + required: [], + additionalProperties: false, +}; + +export const documentViewSchema: JsonSchema = { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: 100, + }, + }, + required: ["id"], + additionalProperties: false, +}; + +export const documentCreateSchema: JsonSchema = { + type: "object", + properties: { + title: { + type: "string", + minLength: 1, + maxLength: 200, + }, + content: { + type: "string", + }, + }, + required: ["title", "content"], + additionalProperties: false, +}; + +export const documentUpdateSchema: JsonSchema = { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: 100, + }, + title: { + type: "string", + minLength: 1, + maxLength: 200, + }, + content: { + type: "string", + }, + }, + required: ["id", "content"], + additionalProperties: false, +}; + +export const documentSearchSchema: JsonSchema = { + type: "object", + properties: { + query: { + type: "string", + minLength: 1, + maxLength: 200, + }, + limit: { + type: "number", + minimum: 1, + maximum: 100, + }, + }, + required: ["query"], + additionalProperties: false, +}; diff --git a/src/mcp/tools/tasks/handlers.ts b/src/mcp/tools/tasks/handlers.ts new file mode 100644 index 0000000..e7c5a57 --- /dev/null +++ b/src/mcp/tools/tasks/handlers.ts @@ -0,0 +1,266 @@ +import { + isLocalEditableTask, + type SearchPriorityFilter, + type Task, + type TaskListFilter, + type TaskSearchResult, +} from "../../../types/index.ts"; +import type { TaskEditArgs, TaskEditRequest } from "../../../types/task-edit-args.ts"; +import { buildTaskUpdateInput } from "../../../utils/task-edit-builder.ts"; +import { sortTasks } from "../../../utils/task-sorting.ts"; +import { McpError } from "../../errors/mcp-errors.ts"; +import type { McpServer } from "../../server.ts"; +import type { CallToolResult } from "../../types.ts"; +import { formatTaskCallResult } from "../../utils/task-response.ts"; + +export type TaskCreateArgs = { + title: string; + description?: string; + labels?: string[]; + assignee?: string[]; + priority?: "high" | "medium" | "low"; + status?: string; + parentTaskId?: string; + acceptanceCriteria?: string[]; + dependencies?: string[]; +}; + +export type TaskListArgs = { + status?: string; + assignee?: string; + labels?: string[]; + search?: string; + limit?: number; +}; + +export type TaskSearchArgs = { + query: string; + status?: string; + priority?: SearchPriorityFilter; + limit?: number; +}; + +export class TaskHandlers { + constructor(private readonly core: McpServer) {} + + private formatTaskSummaryLine(task: Task, options: { includeStatus?: boolean } = {}): string { + const priorityIndicator = task.priority ? `[${task.priority.toUpperCase()}] ` : ""; + const statusText = options.includeStatus && task.status ? ` (${task.status})` : ""; + return ` ${priorityIndicator}${task.id} - ${task.title}${statusText}`; + } + + private async loadTaskOrThrow(id: string): Promise<Task> { + const task = await this.core.getTask(id); + if (!task) { + throw new McpError(`Task not found: ${id}`, "TASK_NOT_FOUND"); + } + return task; + } + + async createTask(args: TaskCreateArgs): Promise<CallToolResult> { + try { + const acceptanceCriteria = + args.acceptanceCriteria + ?.map((text) => String(text).trim()) + .filter((text) => text.length > 0) + .map((text) => ({ text, checked: false })) ?? undefined; + + const { task: createdTask } = await this.core.createTaskFromInput({ + title: args.title, + description: args.description, + status: args.status, + priority: args.priority, + labels: args.labels, + assignee: args.assignee, + dependencies: args.dependencies, + parentTaskId: args.parentTaskId, + acceptanceCriteria, + }); + + return await formatTaskCallResult(createdTask); + } catch (error) { + if (error instanceof Error) { + throw new McpError(error.message, "VALIDATION_ERROR"); + } + throw new McpError(String(error), "VALIDATION_ERROR"); + } + } + + async listTasks(args: TaskListArgs = {}): Promise<CallToolResult> { + const filters: TaskListFilter = {}; + if (args.status) { + filters.status = args.status; + } + if (args.assignee) { + filters.assignee = args.assignee; + } + + const tasks = await this.core.queryTasks({ + query: args.search, + limit: args.limit, + filters: Object.keys(filters).length > 0 ? filters : undefined, + includeCrossBranch: false, + }); + + let filteredByLabels = tasks.filter((task) => isLocalEditableTask(task)); + const labelFilters = args.labels ?? []; + if (labelFilters.length > 0) { + filteredByLabels = filteredByLabels.filter((task) => { + const taskLabels = task.labels ?? []; + return labelFilters.every((label) => taskLabels.includes(label)); + }); + } + + if (filteredByLabels.length === 0) { + return { + content: [ + { + type: "text", + text: "No tasks found.", + }, + ], + }; + } + + const config = await this.core.filesystem.loadConfig(); + const statuses = config?.statuses ?? []; + + const canonicalByLower = new Map<string, string>(); + for (const status of statuses) { + canonicalByLower.set(status.toLowerCase(), status); + } + + const grouped = new Map<string, Task[]>(); + for (const task of filteredByLabels) { + const rawStatus = (task.status ?? "").trim(); + const canonicalStatus = canonicalByLower.get(rawStatus.toLowerCase()) ?? rawStatus; + const bucketKey = canonicalStatus || ""; + const existing = grouped.get(bucketKey) ?? []; + existing.push(task); + grouped.set(bucketKey, existing); + } + + const orderedStatuses = [ + ...statuses.filter((status) => grouped.has(status)), + ...Array.from(grouped.keys()).filter((status) => !statuses.includes(status)), + ]; + + const contentItems: Array<{ type: "text"; text: string }> = []; + for (const status of orderedStatuses) { + const bucket = grouped.get(status) ?? []; + const sortedBucket = sortTasks(bucket, "priority"); + const sectionLines: string[] = [`${status || "No Status"}:`]; + for (const task of sortedBucket) { + sectionLines.push(this.formatTaskSummaryLine(task)); + } + contentItems.push({ + type: "text", + text: sectionLines.join("\n"), + }); + } + + if (contentItems.length === 0) { + contentItems.push({ + type: "text", + text: "No tasks found.", + }); + } + + return { + content: contentItems, + }; + } + + async searchTasks(args: TaskSearchArgs): Promise<CallToolResult> { + const query = args.query.trim(); + if (!query) { + throw new McpError("Search query cannot be empty", "VALIDATION_ERROR"); + } + + const searchService = await this.core.getSearchService(); + const filters: { status?: string; priority?: SearchPriorityFilter } = {}; + if (args.status) { + filters.status = args.status; + } + if (args.priority) { + filters.priority = args.priority; + } + + const results = searchService.search({ + query, + limit: args.limit, + types: ["task"], + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + const taskResults = results + .filter((result): result is TaskSearchResult => result.type === "task") + .filter((result) => isLocalEditableTask(result.task)); + if (taskResults.length === 0) { + return { + content: [ + { + type: "text", + text: `No tasks found for "${query}".`, + }, + ], + }; + } + + const lines: string[] = ["Tasks:"]; + for (const { task } of taskResults) { + lines.push(this.formatTaskSummaryLine(task, { includeStatus: true })); + } + + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } + + async viewTask(args: { id: string }): Promise<CallToolResult> { + const task = await this.loadTaskOrThrow(args.id); + return await formatTaskCallResult(task); + } + + async archiveTask(args: { id: string }): Promise<CallToolResult> { + const task = await this.loadTaskOrThrow(args.id); + const success = await this.core.archiveTask(task.id); + if (!success) { + throw new McpError(`Failed to archive task: ${args.id}`, "OPERATION_FAILED"); + } + + const refreshed = (await this.core.getTask(task.id)) ?? task; + return await formatTaskCallResult(refreshed); + } + + async demoteTask(args: { id: string }): Promise<CallToolResult> { + const task = await this.loadTaskOrThrow(args.id); + const success = await this.core.demoteTask(task.id, false); + if (!success) { + throw new McpError(`Failed to demote task: ${args.id}`, "OPERATION_FAILED"); + } + + const refreshed = (await this.core.getTask(task.id)) ?? task; + return await formatTaskCallResult(refreshed); + } + + async editTask(args: TaskEditRequest): Promise<CallToolResult> { + try { + const updateInput = buildTaskUpdateInput(args); + const updatedTask = await this.core.editTask(args.id, updateInput); + return await formatTaskCallResult(updatedTask); + } catch (error) { + if (error instanceof Error) { + throw new McpError(error.message, "VALIDATION_ERROR"); + } + throw new McpError(String(error), "VALIDATION_ERROR"); + } + } +} + +export type { TaskEditArgs, TaskEditRequest }; diff --git a/src/mcp/tools/tasks/index.ts b/src/mcp/tools/tasks/index.ts new file mode 100644 index 0000000..5542cf1 --- /dev/null +++ b/src/mcp/tools/tasks/index.ts @@ -0,0 +1,86 @@ +import type { BacklogConfig } from "../../../types/index.ts"; +import type { McpServer } from "../../server.ts"; +import type { McpToolHandler } from "../../types.ts"; +import { generateTaskCreateSchema, generateTaskEditSchema } from "../../utils/schema-generators.ts"; +import { createSimpleValidatedTool } from "../../validation/tool-wrapper.ts"; +import type { TaskCreateArgs, TaskEditRequest, TaskListArgs, TaskSearchArgs } from "./handlers.ts"; +import { TaskHandlers } from "./handlers.ts"; +import { taskArchiveSchema, taskListSchema, taskSearchSchema, taskViewSchema } from "./schemas.ts"; + +export function registerTaskTools(server: McpServer, config: BacklogConfig): void { + const handlers = new TaskHandlers(server); + + const taskCreateSchema = generateTaskCreateSchema(config); + const taskEditSchema = generateTaskEditSchema(config); + + const createTaskTool: McpToolHandler = createSimpleValidatedTool( + { + name: "task_create", + description: "Create a new task using Backlog.md", + inputSchema: taskCreateSchema, + }, + taskCreateSchema, + async (input) => handlers.createTask(input as TaskCreateArgs), + ); + + const listTaskTool: McpToolHandler = createSimpleValidatedTool( + { + name: "task_list", + description: "List Backlog.md tasks from with optional filtering", + inputSchema: taskListSchema, + }, + taskListSchema, + async (input) => handlers.listTasks(input as TaskListArgs), + ); + + const searchTaskTool: McpToolHandler = createSimpleValidatedTool( + { + name: "task_search", + description: "Search Backlog.md tasks by title and description", + inputSchema: taskSearchSchema, + }, + taskSearchSchema, + async (input) => handlers.searchTasks(input as TaskSearchArgs), + ); + + const editTaskTool: McpToolHandler = createSimpleValidatedTool( + { + name: "task_edit", + description: + "Edit a Backlog.md task, including metadata, implementation plan/notes, dependencies, and acceptance criteria", + inputSchema: taskEditSchema, + }, + taskEditSchema, + async (input) => handlers.editTask(input as unknown as TaskEditRequest), + ); + + const viewTaskTool: McpToolHandler = createSimpleValidatedTool( + { + name: "task_view", + description: "View a Backlog.md task details", + inputSchema: taskViewSchema, + }, + taskViewSchema, + async (input) => handlers.viewTask(input as { id: string }), + ); + + const archiveTaskTool: McpToolHandler = createSimpleValidatedTool( + { + name: "task_archive", + description: "Archive a Backlog.md task", + inputSchema: taskArchiveSchema, + }, + taskArchiveSchema, + async (input) => handlers.archiveTask(input as { id: string }), + ); + + server.addTool(createTaskTool); + server.addTool(listTaskTool); + server.addTool(searchTaskTool); + server.addTool(editTaskTool); + server.addTool(viewTaskTool); + server.addTool(archiveTaskTool); +} + +export type { TaskCreateArgs, TaskEditArgs, TaskListArgs, TaskSearchArgs } from "./handlers.ts"; +export { taskArchiveSchema, taskListSchema, taskSearchSchema, taskViewSchema } from "./schemas.ts"; diff --git a/src/mcp/tools/tasks/schemas.ts b/src/mcp/tools/tasks/schemas.ts new file mode 100644 index 0000000..44eb8db --- /dev/null +++ b/src/mcp/tools/tasks/schemas.ts @@ -0,0 +1,95 @@ +import type { JsonSchema } from "../../validation/validators.ts"; + +export const taskListSchema: JsonSchema = { + type: "object", + properties: { + status: { + type: "string", + maxLength: 100, + }, + assignee: { + type: "string", + maxLength: 100, + }, + labels: { + type: "array", + items: { type: "string", maxLength: 50 }, + }, + search: { + type: "string", + maxLength: 200, + }, + limit: { + type: "number", + minimum: 1, + maximum: 1000, + }, + }, + required: [], + additionalProperties: false, +}; + +export const taskSearchSchema: JsonSchema = { + type: "object", + properties: { + query: { + type: "string", + minLength: 1, + maxLength: 200, + }, + status: { + type: "string", + maxLength: 100, + }, + priority: { + type: "string", + enum: ["high", "medium", "low"], + }, + limit: { + type: "number", + minimum: 1, + maximum: 100, + }, + }, + required: ["query"], + additionalProperties: false, +}; + +export const taskViewSchema: JsonSchema = { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: 50, + }, + }, + required: ["id"], + additionalProperties: false, +}; + +export const taskArchiveSchema: JsonSchema = { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: 50, + }, + }, + required: ["id"], + additionalProperties: false, +}; + +export const taskDemoteSchema: JsonSchema = { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: 50, + }, + }, + required: ["id"], + additionalProperties: false, +}; diff --git a/src/mcp/tools/workflow/index.ts b/src/mcp/tools/workflow/index.ts new file mode 100644 index 0000000..533d4f7 --- /dev/null +++ b/src/mcp/tools/workflow/index.ts @@ -0,0 +1,46 @@ +import type { McpServer } from "../../server.ts"; +import type { McpToolHandler } from "../../types.ts"; +import { createSimpleValidatedTool } from "../../validation/tool-wrapper.ts"; +import type { JsonSchema } from "../../validation/validators.ts"; +import { WORKFLOW_GUIDES } from "../../workflow-guides.ts"; + +const emptyInputSchema: JsonSchema = { + type: "object", + properties: {}, + required: [], + additionalProperties: false, +}; + +function createWorkflowTool(guide: (typeof WORKFLOW_GUIDES)[number]): McpToolHandler { + const toolText = guide.toolText ?? guide.resourceText; + return createSimpleValidatedTool( + { + name: guide.toolName, + description: guide.toolDescription, + inputSchema: emptyInputSchema, + }, + emptyInputSchema, + async () => ({ + content: [ + { + type: "text", + text: toolText, + }, + ], + structuredContent: { + type: "resource", + uri: guide.uri, + title: guide.name, + description: guide.description, + mimeType: guide.mimeType, + text: toolText, + }, + }), + ); +} + +export function registerWorkflowTools(server: McpServer): void { + for (const guide of WORKFLOW_GUIDES) { + server.addTool(createWorkflowTool(guide)); + } +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..e8b4d39 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,51 @@ +import type { + CallToolResult, + GetPromptResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + Prompt, + ReadResourceResult, + Resource, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; + +export interface McpToolHandler { + name: string; + description: string; + inputSchema: object; + handler: (args: Record<string, unknown>) => Promise<CallToolResult>; +} + +export interface McpResourceHandler { + uri: string; + name?: string; + description?: string; + mimeType?: string; + handler: (uri: string) => Promise<ReadResourceResult>; +} + +export interface McpPromptHandler { + name: string; + description?: string; + arguments?: Array<{ + name: string; + description?: string; + required?: boolean; + }>; + handler: (args: Record<string, unknown>) => Promise<GetPromptResult>; +} + +export type { + CallToolResult, + GetPromptResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + Prompt, + ReadResourceResult, + Resource, + Tool, +}; diff --git a/src/mcp/utils/document-response.ts b/src/mcp/utils/document-response.ts new file mode 100644 index 0000000..af344cf --- /dev/null +++ b/src/mcp/utils/document-response.ts @@ -0,0 +1,48 @@ +import type { Document } from "../../types/index.ts"; +import type { CallToolResult } from "../types.ts"; + +function formatTags(tags?: string[]): string { + if (!tags || tags.length === 0) { + return "Tags: (none)"; + } + return `Tags: ${tags.join(", ")}`; +} + +function buildDocumentText(document: Document, options?: { includeContent?: boolean }): string { + const lines: string[] = [ + `Document ${document.id} - ${document.title}`, + `Type: ${document.type}`, + `Created: ${document.createdDate}`, + ]; + + if (document.updatedDate) { + lines.push(`Updated: ${document.updatedDate}`); + } + + lines.push(formatTags(document.tags)); + + if (options?.includeContent !== false) { + lines.push(""); + lines.push(document.rawContent && document.rawContent.trim().length > 0 ? document.rawContent : "(empty document)"); + } + + return lines.join("\n"); +} + +export async function formatDocumentCallResult( + document: Document, + options: { includeContent?: boolean; summaryLines?: string[] } = {}, +): Promise<CallToolResult> { + const summary = options.summaryLines?.filter((line) => line.trim().length > 0).join("\n"); + const documentText = buildDocumentText(document, { includeContent: options.includeContent }); + const text = summary ? `${summary}\n\n${documentText}` : documentText; + + return { + content: [ + { + type: "text", + text, + }, + ], + }; +} diff --git a/src/mcp/utils/schema-generators.ts b/src/mcp/utils/schema-generators.ts new file mode 100644 index 0000000..88685a3 --- /dev/null +++ b/src/mcp/utils/schema-generators.ts @@ -0,0 +1,207 @@ +import { DEFAULT_STATUSES } from "../../constants/index.ts"; +import type { BacklogConfig } from "../../types/index.ts"; +import type { JsonSchema } from "../validation/validators.ts"; + +/** + * Generates a status field schema with dynamic enum values sourced from config. + */ +export function generateStatusFieldSchema(config: BacklogConfig): JsonSchema { + const configuredStatuses = + config.statuses && config.statuses.length > 0 ? [...config.statuses] : [...DEFAULT_STATUSES]; + const defaultStatus = configuredStatuses[0] ?? DEFAULT_STATUSES[0]; + + return { + type: "string", + maxLength: 100, + enum: configuredStatuses, + enumCaseInsensitive: true, + enumNormalizeWhitespace: true, + default: defaultStatus, + description: `Status value (case-insensitive). Valid values: ${configuredStatuses.join(", ")}`, + }; +} + +/** + * Generates the task_create input schema with dynamic status enum + */ +export function generateTaskCreateSchema(config: BacklogConfig): JsonSchema { + return { + type: "object", + properties: { + title: { + type: "string", + minLength: 1, + maxLength: 200, + }, + description: { + type: "string", + maxLength: 10000, + }, + status: generateStatusFieldSchema(config), + priority: { + type: "string", + enum: ["high", "medium", "low"], + }, + labels: { + type: "array", + items: { + type: "string", + maxLength: 50, + }, + }, + assignee: { + type: "array", + items: { + type: "string", + maxLength: 100, + }, + }, + dependencies: { + type: "array", + items: { + type: "string", + maxLength: 50, + }, + }, + acceptanceCriteria: { + type: "array", + items: { + type: "string", + maxLength: 500, + }, + }, + parentTaskId: { + type: "string", + maxLength: 50, + }, + }, + required: ["title"], + additionalProperties: false, + }; +} + +/** + * Generates the task_edit input schema with dynamic status enum and MCP-specific operations. + */ +export function generateTaskEditSchema(config: BacklogConfig): JsonSchema { + return { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: 50, + }, + title: { + type: "string", + maxLength: 200, + }, + description: { + type: "string", + maxLength: 10000, + }, + status: generateStatusFieldSchema(config), + priority: { + type: "string", + enum: ["high", "medium", "low"], + }, + labels: { + type: "array", + items: { + type: "string", + maxLength: 50, + }, + }, + assignee: { + type: "array", + items: { + type: "string", + maxLength: 100, + }, + }, + dependencies: { + type: "array", + items: { + type: "string", + maxLength: 50, + }, + }, + implementationNotes: { + type: "string", + maxLength: 10000, + }, + notesSet: { + type: "string", + maxLength: 20000, + }, + notesAppend: { + type: "array", + items: { + type: "string", + maxLength: 5000, + }, + maxItems: 20, + }, + notesClear: { + type: "boolean", + }, + planSet: { + type: "string", + maxLength: 20000, + }, + planAppend: { + type: "array", + items: { + type: "string", + maxLength: 5000, + }, + maxItems: 20, + }, + planClear: { + type: "boolean", + }, + acceptanceCriteriaSet: { + type: "array", + items: { + type: "string", + maxLength: 500, + }, + maxItems: 50, + }, + acceptanceCriteriaAdd: { + type: "array", + items: { + type: "string", + maxLength: 500, + }, + maxItems: 50, + }, + acceptanceCriteriaRemove: { + type: "array", + items: { + type: "number", + minimum: 1, + }, + maxItems: 50, + }, + acceptanceCriteriaCheck: { + type: "array", + items: { + type: "number", + minimum: 1, + }, + maxItems: 50, + }, + acceptanceCriteriaUncheck: { + type: "array", + items: { + type: "number", + minimum: 1, + }, + maxItems: 50, + }, + }, + required: ["id"], + additionalProperties: false, + }; +} diff --git a/src/mcp/utils/task-response.ts b/src/mcp/utils/task-response.ts new file mode 100644 index 0000000..6e09618 --- /dev/null +++ b/src/mcp/utils/task-response.ts @@ -0,0 +1,18 @@ +import { formatTaskPlainText } from "../../formatters/task-plain-text.ts"; +import type { Task } from "../../types/index.ts"; +import type { CallToolResult } from "../types.ts"; + +export async function formatTaskCallResult(task: Task, summaryLines: string[] = []): Promise<CallToolResult> { + const formattedTask = formatTaskPlainText(task); + const summary = summaryLines.filter((line) => line.trim().length > 0).join("\n"); + const text = summary ? `${summary}\n\n${formattedTask}` : formattedTask; + + return { + content: [ + { + type: "text", + text, + }, + ], + }; +} diff --git a/src/mcp/validation/tool-wrapper.ts b/src/mcp/validation/tool-wrapper.ts new file mode 100644 index 0000000..ccba689 --- /dev/null +++ b/src/mcp/validation/tool-wrapper.ts @@ -0,0 +1,168 @@ +import { handleMcpError, McpValidationError } from "../errors/mcp-errors.ts"; +import type { CallToolResult, McpToolHandler } from "../types.ts"; +import type { JsonSchema, ValidationResult } from "./validators.ts"; +import { validateInput } from "./validators.ts"; + +/** + * Validation context for tool calls + */ +export type ValidationContext = { + clientId?: string; + timestamp: number; +}; + +/** + * Tool handler function with validation context + */ +export type ValidatedToolHandler<T = Record<string, unknown>> = ( + input: T, + context: ValidationContext, +) => Promise<CallToolResult>; + +/** + * Creates a validated tool wrapper that adds comprehensive validation and error handling + */ +export function createValidatedTool<T extends Record<string, unknown>>( + toolDefinition: Omit<McpToolHandler, "handler">, + validator: (input: unknown, context?: ValidationContext) => Promise<ValidationResult> | ValidationResult, + handler: ValidatedToolHandler<T>, +): McpToolHandler { + return { + ...toolDefinition, + async handler(request: Record<string, unknown>, clientId?: string): Promise<CallToolResult> { + const context: ValidationContext = { + clientId, + timestamp: Date.now(), + }; + + try { + // Input validation + const validationResult = await validator(request, context); + + if (!validationResult.isValid) { + throw new McpValidationError( + `Validation failed: ${validationResult.errors.join(", ")}`, + validationResult.errors, + ); + } + + // Execute handler directly + const result = await handler(validationResult.sanitizedData as T, context); + + return result; + } catch (error) { + // Log error for debugging (but don't expose sensitive details) + if (process.env.DEBUG) { + console.error(`Tool '${toolDefinition.name}' error:`, { + clientId: context.clientId, + timestamp: context.timestamp, + error: error instanceof Error ? error.message : String(error), + }); + } + + return handleMcpError(error); + } + }, + }; +} + +/** + * Creates a simple validator from a JSON Schema + */ +export function createSchemaValidator(schema: JsonSchema): (input: unknown) => ValidationResult { + return (input: unknown) => validateInput(input, schema); +} + +/** + * Creates an async validator that includes core-dependent validation + */ +export function createAsyncValidator( + schema: JsonSchema, + customValidator?: (input: Record<string, unknown>, context?: ValidationContext) => Promise<string[]>, +): (input: unknown, context?: ValidationContext) => Promise<ValidationResult> { + return async (input: unknown, context?: ValidationContext) => { + // Basic schema validation + const baseResult = validateInput(input, schema); + + if (!baseResult.isValid) { + return baseResult; + } + + // Custom async validation + if (customValidator && baseResult.sanitizedData) { + try { + const customErrors = await customValidator(baseResult.sanitizedData, context); + if (customErrors.length > 0) { + return { + isValid: false, + errors: [...baseResult.errors, ...customErrors], + }; + } + } catch (error) { + return { + isValid: false, + errors: [`Validation error: ${error instanceof Error ? error.message : String(error)}`], + }; + } + } + + return baseResult; + }; +} + +/** + * Validates that all strings in the input are properly sanitized + */ +export function validateSanitizedStrings(data: Record<string, unknown>): string[] { + const errors: string[] = []; + + function checkValue(key: string, value: unknown): void { + if (typeof value === "string") { + // Check for potential injection attempts + if (value.includes("\0")) { + errors.push(`Field '${key}' contains null bytes`); + } + if (value !== value.trim()) { + errors.push(`Field '${key}' has leading or trailing whitespace`); + } + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + checkValue(`${key}[${i}]`, value[i]); + } + } else if (typeof value === "object" && value !== null) { + const obj = value as Record<string, unknown>; + for (const [nestedKey, nestedValue] of Object.entries(obj)) { + checkValue(`${key}.${nestedKey}`, nestedValue); + } + } + } + + for (const [key, value] of Object.entries(data)) { + checkValue(key, value); + } + + return errors; +} + +/** + * Wrapper for tools that don't need custom validation beyond schema + */ +export function createSimpleValidatedTool<T extends Record<string, unknown>>( + toolDefinition: Omit<McpToolHandler, "handler">, + schema: JsonSchema, + handler: ValidatedToolHandler<T>, +): McpToolHandler { + return createValidatedTool(toolDefinition, createSchemaValidator(schema), handler); +} + +/** + * Wrapper for tools that need async validation (e.g., status validation) + */ +export function createAsyncValidatedTool<T extends Record<string, unknown>>( + toolDefinition: Omit<McpToolHandler, "handler">, + schema: JsonSchema, + customValidator: (input: Record<string, unknown>, context?: ValidationContext) => Promise<string[]>, + handler: ValidatedToolHandler<T>, +): McpToolHandler { + return createValidatedTool(toolDefinition, createAsyncValidator(schema, customValidator), handler); +} diff --git a/src/mcp/validation/validators.ts b/src/mcp/validation/validators.ts new file mode 100644 index 0000000..45abeea --- /dev/null +++ b/src/mcp/validation/validators.ts @@ -0,0 +1,239 @@ +/** + * JSON Schema validator interface + */ +export interface JsonSchema { + type?: string; // Optional to allow "any type" schemas + properties?: Record<string, JsonSchema>; + required?: string[]; + items?: JsonSchema; + enum?: string[]; + enumCaseInsensitive?: boolean; + enumNormalizeWhitespace?: boolean; + minLength?: number; + maxLength?: number; + minimum?: number; + maximum?: number; + maxItems?: number; + additionalProperties?: boolean; + preserveWhitespace?: boolean; + description?: string; + default?: unknown; +} + +/** + * Validation result interface + */ +export interface ValidationResult { + isValid: boolean; + errors: string[]; + sanitizedData?: Record<string, unknown>; +} + +/** + * Validates input against a JSON Schema + */ +export function validateInput(input: unknown, schema: JsonSchema): ValidationResult { + const errors: string[] = []; + const sanitizedData: Record<string, unknown> = {}; + + if (typeof input !== "object" || input === null) { + return { + isValid: false, + errors: ["Input must be an object"], + }; + } + + const data = input as Record<string, unknown>; + + if (schema.required) { + for (const field of schema.required) { + if (!(field in data) || data[field] === undefined || data[field] === null) { + errors.push(`Required field '${field}' is missing or null`); + } + } + } + + if (schema.properties) { + for (const [key, value] of Object.entries(data)) { + const fieldSchema = schema.properties[key]; + if (!fieldSchema) { + if (schema.additionalProperties === false) { + errors.push(`Unknown field '${key}' is not allowed`); + } + continue; + } + + const fieldResult = validateField(key, value, fieldSchema); + if (!fieldResult.isValid) { + errors.push(...fieldResult.errors); + } else if (fieldResult.sanitizedValue !== undefined) { + sanitizedData[key] = fieldResult.sanitizedValue; + } + } + } + + return { + isValid: errors.length === 0, + errors, + sanitizedData: errors.length === 0 ? sanitizedData : undefined, + }; +} + +/** + * Validates a single field against its schema + */ +function validateField( + fieldName: string, + value: unknown, + schema: JsonSchema, +): { isValid: boolean; errors: string[]; sanitizedValue?: unknown } { + const errors: string[] = []; + + if (value === undefined || value === null) { + return { isValid: true, errors: [], sanitizedValue: value }; + } + + // If no type is specified, accept any type + if (!schema.type) { + return { isValid: true, errors: [], sanitizedValue: value }; + } + + // Type validation + switch (schema.type) { + case "string": { + if (typeof value !== "string") { + errors.push(`Field '${fieldName}' must be a string`); + break; + } + + // Sanitize string input + // Preserve whitespace for separator fields and when explicitly requested + const shouldPreserveWhitespace = schema.preserveWhitespace || fieldName === "separator"; + const sanitizedString = shouldPreserveWhitespace + ? sanitizeStringPreserveWhitespace(value) + : sanitizeString(value); + let sanitizedResult = sanitizedString; + + // Length validation + if (schema.minLength !== undefined && sanitizedString.length < schema.minLength) { + errors.push(`Field '${fieldName}' must be at least ${schema.minLength} characters long`); + } + if (schema.maxLength !== undefined && sanitizedString.length > schema.maxLength) { + errors.push( + `Field '${fieldName}' exceeds maximum length of ${schema.maxLength} characters (${sanitizedString.length} characters)`, + ); + } + + // Enum validation + if (schema.enum) { + const normalizeValue = (inputValue: string): string => { + const withoutWhitespace = schema.enumNormalizeWhitespace ? inputValue.replace(/\s+/g, "") : inputValue; + return schema.enumCaseInsensitive ? withoutWhitespace.toLowerCase() : withoutWhitespace; + }; + + const normalizedCandidate = normalizeValue(sanitizedString); + let canonicalMatch: string | undefined; + + for (const option of schema.enum) { + if (normalizeValue(option) === normalizedCandidate) { + canonicalMatch = option; + break; + } + } + + if (!canonicalMatch) { + errors.push(`Field '${fieldName}' must be one of: ${schema.enum.join(", ")}`); + } else { + sanitizedResult = canonicalMatch; + } + } + + return { isValid: errors.length === 0, errors, sanitizedValue: sanitizedResult }; + } + + case "number": { + const numValue = typeof value === "string" ? Number.parseFloat(value) : value; + if (typeof numValue !== "number" || Number.isNaN(numValue)) { + errors.push(`Field '${fieldName}' must be a number`); + break; + } + + // Range validation + if (schema.minimum !== undefined && numValue < schema.minimum) { + errors.push(`Field '${fieldName}' must be at least ${schema.minimum}`); + } + if (schema.maximum !== undefined && numValue > schema.maximum) { + errors.push(`Field '${fieldName}' must be at most ${schema.maximum}`); + } + + return { isValid: errors.length === 0, errors, sanitizedValue: numValue }; + } + + case "array": { + if (!Array.isArray(value)) { + errors.push(`Field '${fieldName}' must be an array`); + break; + } + + // Validate maxItems + if (schema.maxItems !== undefined && value.length > schema.maxItems) { + errors.push(`Field '${fieldName}' must have at most ${schema.maxItems} items`); + } + + const sanitizedArray: unknown[] = []; + + // Validate array items + if (schema.items) { + for (let i = 0; i < value.length; i++) { + const itemResult = validateField(`${fieldName}[${i}]`, value[i], schema.items); + if (!itemResult.isValid) { + errors.push(...itemResult.errors); + } else if (itemResult.sanitizedValue !== undefined) { + sanitizedArray.push(itemResult.sanitizedValue); + } + } + } + + return { isValid: errors.length === 0, errors, sanitizedValue: sanitizedArray }; + } + + case "boolean": { + const boolValue = typeof value === "string" ? value.toLowerCase() === "true" : Boolean(value); + return { isValid: true, errors: [], sanitizedValue: boolValue }; + } + + default: { + errors.push(`Unknown schema type '${schema.type}' for field '${fieldName}'`); + } + } + + return { isValid: errors.length === 0, errors, sanitizedValue: value }; +} + +/** + * Sanitizes string input to prevent various injection attacks + */ +function sanitizeString(input: string): string { + if (typeof input !== "string") { + return String(input); + } + + // Remove null bytes + let sanitized = input.replace(/\0/g, ""); + + // Trim whitespace + sanitized = sanitized.trim(); + + // Normalize line endings + sanitized = sanitized.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + return sanitized; +} + +export function sanitizeStringPreserveWhitespace(input: string): string { + if (typeof input !== "string") { + return String(input); + } + + return input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} diff --git a/src/mcp/workflow-guides.ts b/src/mcp/workflow-guides.ts new file mode 100644 index 0000000..0c2a674 --- /dev/null +++ b/src/mcp/workflow-guides.ts @@ -0,0 +1,67 @@ +import { + MCP_TASK_COMPLETION_GUIDE, + MCP_TASK_CREATION_GUIDE, + MCP_TASK_EXECUTION_GUIDE, + MCP_WORKFLOW_OVERVIEW, + MCP_WORKFLOW_OVERVIEW_TOOLS, +} from "../guidelines/mcp/index.ts"; + +export interface WorkflowGuideDefinition { + key: "overview" | "task-creation" | "task-execution" | "task-completion"; + uri: string; + name: string; + description: string; + mimeType: string; + resourceText: string; + toolText?: string; + toolName: string; + toolDescription: string; +} + +export const WORKFLOW_GUIDES: WorkflowGuideDefinition[] = [ + { + key: "overview", + uri: "backlog://workflow/overview", + name: "Backlog Workflow Overview", + description: "Overview of when and how to use Backlog.md for task management", + mimeType: "text/markdown", + resourceText: MCP_WORKFLOW_OVERVIEW, + toolText: MCP_WORKFLOW_OVERVIEW_TOOLS, + toolName: "get_workflow_overview", + toolDescription: "Retrieve the Backlog.md workflow overview guidance in markdown format", + }, + { + key: "task-creation", + uri: "backlog://workflow/task-creation", + name: "Task Creation Guide", + description: "Detailed guide for creating tasks: scope assessment, acceptance criteria, parent/subtasks", + mimeType: "text/markdown", + resourceText: MCP_TASK_CREATION_GUIDE, + toolName: "get_task_creation_guide", + toolDescription: "Retrieve the Backlog.md task creation guide in markdown format", + }, + { + key: "task-execution", + uri: "backlog://workflow/task-execution", + name: "Task Execution Guide", + description: "Detailed guide for planning and executing tasks: workflow, discipline, scope changes", + mimeType: "text/markdown", + resourceText: MCP_TASK_EXECUTION_GUIDE, + toolName: "get_task_execution_guide", + toolDescription: "Retrieve the Backlog.md task execution guide in markdown format", + }, + { + key: "task-completion", + uri: "backlog://workflow/task-completion", + name: "Task Completion Guide", + description: "Detailed guide for completing tasks: Definition of Done, completion workflow, next steps", + mimeType: "text/markdown", + resourceText: MCP_TASK_COMPLETION_GUIDE, + toolName: "get_task_completion_guide", + toolDescription: "Retrieve the Backlog.md task completion guide in markdown format", + }, +]; + +export function getWorkflowGuideByUri(uri: string): WorkflowGuideDefinition | undefined { + return WORKFLOW_GUIDES.find((guide) => guide.uri === uri); +} diff --git a/src/readme.ts b/src/readme.ts new file mode 100644 index 0000000..c856223 --- /dev/null +++ b/src/readme.ts @@ -0,0 +1,68 @@ +import { join } from "node:path"; +import { exportKanbanBoardToFile } from "./board.ts"; +import type { Task } from "./types/index.ts"; + +const BOARD_START = "<!-- BOARD_START -->"; +const BOARD_END = "<!-- BOARD_END -->"; + +export async function updateReadmeWithBoard(tasks: Task[], statuses: string[], projectName: string, version?: string) { + const readmePath = join(process.cwd(), "README.md"); + let readmeContent = ""; + try { + readmeContent = await Bun.file(readmePath).text(); + } catch { + // If README.md doesn't exist, create it. + } + + // Use the same high-quality board generation as file export + // Create a temporary file to get the properly formatted board + const tempPath = join(process.cwd(), ".temp-board.md"); + await exportKanbanBoardToFile(tasks, statuses, tempPath, projectName); + const fullBoardContent = await Bun.file(tempPath).text(); + + // Extract timestamp from the board content + const timestampMatch = fullBoardContent.match(/Generated on: ([^\n]+)/); + const timestamp = timestampMatch ? timestampMatch[1] : new Date().toISOString().replace("T", " ").substring(0, 19); + + // Extract just the board table (skip all metadata headers) + const lines = fullBoardContent.split("\n"); + const tableStartIndex = lines.findIndex( + (line) => + line.includes("|") && + (line.includes("To Do") || line.includes("In Progress") || line.includes("Done") || line.includes("---")), + ); + const boardTable = lines.slice(tableStartIndex).join("\n").trim(); + + // Clean up temp file + try { + await Bun.file(tempPath).write(""); + await Bun.$`rm -f ${tempPath}`; + } catch { + // Ignore cleanup errors + } + + // Create the board section with a nice title + const versionText = version ? ` (${version})` : ""; + const statusTitle = `## πŸ“Š ${projectName} Project Status${versionText}`; + const subtitle = "This board was automatically generated by [Backlog.md](https://backlog.md)"; + const boardSection = `${statusTitle}\n\n${subtitle}\n\nGenerated on: ${timestamp}\n\n${boardTable}`; + + const startMarkerIndex = readmeContent.indexOf(BOARD_START); + const endMarkerIndex = readmeContent.indexOf(BOARD_END); + const licenseIndex = readmeContent.indexOf("## License"); + + if (startMarkerIndex !== -1 && endMarkerIndex !== -1) { + const preContent = readmeContent.substring(0, startMarkerIndex + BOARD_START.length); + const postContent = readmeContent.substring(endMarkerIndex); + readmeContent = `${preContent}\n\n${boardSection}\n\n${postContent}`; + } else if (licenseIndex !== -1) { + const preContent = readmeContent.substring(0, licenseIndex); + const postContent = readmeContent.substring(licenseIndex); + readmeContent = `${preContent}${BOARD_START}\n\n${boardSection}\n\n${BOARD_END}\n\n${postContent}`; + } else { + // If markers are not found, append the board at the end of the file. + readmeContent += `\n\n${BOARD_START}\n\n${boardSection}\n\n${BOARD_END}`; + } + + await Bun.write(readmePath, readmeContent); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..83ef59f --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,1209 @@ +import { dirname, join } from "node:path"; +import type { Server, ServerWebSocket } from "bun"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { ContentStore } from "../core/content-store.ts"; +import { initializeProject } from "../core/init.ts"; +import type { SearchService } from "../core/search-service.ts"; +import { getTaskStatistics } from "../core/statistics.ts"; +import type { SearchPriorityFilter, SearchResultType, Task, TaskUpdateInput } from "../types/index.ts"; +import { watchConfig } from "../utils/config-watcher.ts"; +import { getVersion } from "../utils/version.ts"; + +const TASK_ID_PREFIX = "task-"; + +function parseTaskIdSegments(value: string): number[] | null { + const withoutPrefix = value.startsWith(TASK_ID_PREFIX) ? value.slice(TASK_ID_PREFIX.length) : value; + if (!/^[0-9]+(?:\.[0-9]+)*$/.test(withoutPrefix)) { + return null; + } + return withoutPrefix.split(".").map((segment) => Number.parseInt(segment, 10)); +} + +function findTaskByLooseId(tasks: Task[], inputId: string): Task | undefined { + const normalized = inputId.startsWith(TASK_ID_PREFIX) ? inputId : `${TASK_ID_PREFIX}${inputId}`; + const exact = tasks.find((task) => task.id === normalized); + if (exact) { + return exact; + } + + const inputSegments = parseTaskIdSegments(inputId); + if (!inputSegments) { + return undefined; + } + + return tasks.find((task) => { + const candidateSegments = parseTaskIdSegments(task.id); + if (!candidateSegments || candidateSegments.length !== inputSegments.length) { + return false; + } + for (let index = 0; index < candidateSegments.length; index += 1) { + if (candidateSegments[index] !== inputSegments[index]) { + return false; + } + } + return true; + }); +} + +// @ts-expect-error +import favicon from "../web/favicon.png" with { type: "file" }; +import indexHtml from "../web/index.html"; + +export class BacklogServer { + private core: Core; + private server: Server<unknown> | null = null; + private projectName = "Untitled Project"; + private sockets = new Set<ServerWebSocket<unknown>>(); + private contentStore: ContentStore | null = null; + private searchService: SearchService | null = null; + private unsubscribeContentStore?: () => void; + private storeReadyBroadcasted = false; + private configWatcher: { stop: () => void } | null = null; + + constructor(projectPath: string) { + this.core = new Core(projectPath, { enableWatchers: true }); + } + + private async ensureServicesReady(): Promise<void> { + const store = await this.core.getContentStore(); + this.contentStore = store; + + if (!this.unsubscribeContentStore) { + this.unsubscribeContentStore = store.subscribe((event) => { + if (event.type === "ready") { + if (!this.storeReadyBroadcasted) { + this.storeReadyBroadcasted = true; + return; + } + this.broadcastTasksUpdated(); + return; + } + + // Broadcast for tasks/documents/decisions so clients refresh caches/search + this.storeReadyBroadcasted = true; + this.broadcastTasksUpdated(); + }); + } + + const search = await this.core.getSearchService(); + this.searchService = search; + } + + private async getContentStoreInstance(): Promise<ContentStore> { + await this.ensureServicesReady(); + if (!this.contentStore) { + throw new Error("Content store not initialized"); + } + return this.contentStore; + } + + private async getSearchServiceInstance(): Promise<SearchService> { + await this.ensureServicesReady(); + if (!this.searchService) { + throw new Error("Search service not initialized"); + } + return this.searchService; + } + + getPort(): number | null { + return this.server?.port ?? null; + } + + private broadcastTasksUpdated() { + for (const ws of this.sockets) { + try { + ws.send("tasks-updated"); + } catch {} + } + } + + private broadcastConfigUpdated() { + for (const ws of this.sockets) { + try { + ws.send("config-updated"); + } catch {} + } + } + + async start(port?: number, openBrowser = true): Promise<void> { + // Prevent duplicate starts (e.g., accidental re-entry) + if (this.server) { + console.log("Server already running"); + return; + } + // Load config (migration is handled globally by CLI) + const config = await this.core.filesystem.loadConfig(); + + // Use config default port if no port specified + const finalPort = port ?? config?.defaultPort ?? 6420; + this.projectName = config?.projectName || "Untitled Project"; + + // Check if browser should open (config setting or CLI override) + // Default to true if autoOpenBrowser is not explicitly set to false + const shouldOpenBrowser = openBrowser && (config?.autoOpenBrowser ?? true); + + // Set up config watcher to broadcast changes + this.configWatcher = watchConfig(this.core, { + onConfigChanged: () => { + this.broadcastConfigUpdated(); + }, + }); + + try { + await this.ensureServicesReady(); + const serveOptions = { + port: finalPort, + development: process.env.NODE_ENV === "development", + routes: { + "/": indexHtml, + "/tasks": indexHtml, + "/drafts": indexHtml, + "/documentation": indexHtml, + "/documentation/*": indexHtml, + "/decisions": indexHtml, + "/decisions/*": indexHtml, + "/statistics": indexHtml, + "/settings": indexHtml, + + // API Routes using Bun's native route syntax + "/api/tasks": { + GET: async (req: Request) => await this.handleListTasks(req), + POST: async (req: Request) => await this.handleCreateTask(req), + }, + "/api/task/:id": { + GET: async (req: Request & { params: { id: string } }) => await this.handleGetTask(req.params.id), + }, + "/api/tasks/:id": { + GET: async (req: Request & { params: { id: string } }) => await this.handleGetTask(req.params.id), + PUT: async (req: Request & { params: { id: string } }) => await this.handleUpdateTask(req, req.params.id), + DELETE: async (req: Request & { params: { id: string } }) => await this.handleDeleteTask(req.params.id), + }, + "/api/tasks/:id/complete": { + POST: async (req: Request & { params: { id: string } }) => await this.handleCompleteTask(req.params.id), + }, + "/api/statuses": { + GET: async () => await this.handleGetStatuses(), + }, + "/api/config": { + GET: async () => await this.handleGetConfig(), + PUT: async (req: Request) => await this.handleUpdateConfig(req), + }, + "/api/docs": { + GET: async () => await this.handleListDocs(), + POST: async (req: Request) => await this.handleCreateDoc(req), + }, + "/api/doc/:id": { + GET: async (req: Request & { params: { id: string } }) => await this.handleGetDoc(req.params.id), + }, + "/api/docs/:id": { + GET: async (req: Request & { params: { id: string } }) => await this.handleGetDoc(req.params.id), + PUT: async (req: Request & { params: { id: string } }) => await this.handleUpdateDoc(req, req.params.id), + }, + "/api/decisions": { + GET: async () => await this.handleListDecisions(), + POST: async (req: Request) => await this.handleCreateDecision(req), + }, + "/api/decision/:id": { + GET: async (req: Request & { params: { id: string } }) => await this.handleGetDecision(req.params.id), + }, + "/api/decisions/:id": { + GET: async (req: Request & { params: { id: string } }) => await this.handleGetDecision(req.params.id), + PUT: async (req: Request & { params: { id: string } }) => + await this.handleUpdateDecision(req, req.params.id), + }, + "/api/drafts": { + GET: async () => await this.handleListDrafts(), + }, + "/api/drafts/:id/promote": { + POST: async (req: Request & { params: { id: string } }) => await this.handlePromoteDraft(req.params.id), + }, + "/api/tasks/reorder": { + POST: async (req: Request) => await this.handleReorderTask(req), + }, + "/api/tasks/cleanup": { + GET: async (req: Request) => await this.handleCleanupPreview(req), + }, + "/api/tasks/cleanup/execute": { + POST: async (req: Request) => await this.handleCleanupExecute(req), + }, + "/api/version": { + GET: async () => await this.handleGetVersion(), + }, + "/api/statistics": { + GET: async () => await this.handleGetStatistics(), + }, + "/api/status": { + GET: async () => await this.handleGetStatus(), + }, + "/api/init": { + POST: async (req: Request) => await this.handleInit(req), + }, + "/api/search": { + GET: async (req: Request) => await this.handleSearch(req), + }, + "/sequences": { + GET: async () => await this.handleGetSequences(), + }, + "/sequences/move": { + POST: async (req: Request) => await this.handleMoveSequence(req), + }, + "/api/sequences": { + GET: async () => await this.handleGetSequences(), + }, + "/api/sequences/move": { + POST: async (req: Request) => await this.handleMoveSequence(req), + }, + // Serve files placed under backlog/assets at /assets/<relative-path> + "/assets/*": { + GET: async (req: Request) => await this.handleAssetRequest(req), + }, + }, + fetch: async (req: Request, server: Server<unknown>) => { + return await this.handleRequest(req, server); + }, + error: this.handleError.bind(this), + websocket: { + open: (ws: ServerWebSocket) => { + this.sockets.add(ws); + }, + message(ws: ServerWebSocket) { + ws.send("pong"); + }, + close: (ws: ServerWebSocket) => { + this.sockets.delete(ws); + }, + }, + /* biome-ignore format: keep cast on single line below for type narrowing */ + }; + this.server = Bun.serve(serveOptions as unknown as Parameters<typeof Bun.serve>[0]); + + const url = `http://localhost:${finalPort}`; + console.log(`πŸš€ Backlog.md browser interface running at ${url}`); + console.log(`πŸ“Š Project: ${this.projectName}`); + const stopKey = process.platform === "darwin" ? "Cmd+C" : "Ctrl+C"; + console.log(`⏹️ Press ${stopKey} to stop the server`); + + if (shouldOpenBrowser) { + console.log("🌐 Opening browser..."); + await this.openBrowser(url); + } else { + console.log("πŸ’‘ Open your browser and navigate to the URL above"); + } + } catch (error) { + // Handle port already in use error + const errorCode = (error as { code?: string })?.code; + const errorMessage = (error as Error)?.message; + if (errorCode === "EADDRINUSE" || errorMessage?.includes("address already in use")) { + console.error(`\n❌ Error: Port ${finalPort} is already in use.\n`); + console.log("πŸ’‘ Suggestions:"); + console.log(` 1. Try a different port: backlog browser --port ${finalPort + 1}`); + console.log(` 2. Find what's using port ${finalPort}:`); + if (process.platform === "darwin" || process.platform === "linux") { + console.log(` Run: lsof -i :${finalPort}`); + } else if (process.platform === "win32") { + console.log(` Run: netstat -ano | findstr :${finalPort}`); + } + console.log(" 3. Or kill the process using the port and try again\n"); + process.exit(1); + } + + // Handle other errors + console.error("❌ Failed to start server:", errorMessage || error); + process.exit(1); + } + } + + private _stopping = false; + + async stop(): Promise<void> { + if (this._stopping) return; + this._stopping = true; + + // Stop filesystem watcher first to reduce churn + try { + this.unsubscribeContentStore?.(); + this.unsubscribeContentStore = undefined; + } catch {} + + // Stop config watcher + try { + this.configWatcher?.stop(); + this.configWatcher = null; + } catch {} + + this.core.disposeSearchService(); + this.core.disposeContentStore(); + this.searchService = null; + this.contentStore = null; + this.storeReadyBroadcasted = false; + + // Proactively close WebSocket connections + for (const ws of this.sockets) { + try { + ws.close(); + } catch {} + } + this.sockets.clear(); + + // Attempt to stop the server but don't hang forever + if (this.server) { + const serverRef = this.server; + const stopPromise = (async () => { + try { + await serverRef.stop(); + } catch {} + })(); + const timeout = new Promise<void>((resolve) => setTimeout(resolve, 1500)); + await Promise.race([stopPromise, timeout]); + this.server = null; + console.log("Server stopped"); + } + + this._stopping = false; + } + + private async openBrowser(url: string): Promise<void> { + try { + const platform = process.platform; + let cmd: string[]; + + switch (platform) { + case "darwin": // macOS + cmd = ["open", url]; + break; + case "win32": // Windows + cmd = ["cmd", "/c", "start", "", url]; + break; + default: // Linux and others + cmd = ["xdg-open", url]; + break; + } + + await $`${cmd}`.quiet(); + } catch (error) { + console.warn("⚠️ Failed to open browser automatically:", error); + console.log("πŸ’‘ Please open your browser manually and navigate to the URL above"); + } + } + + private async handleAssetRequest(req: Request): Promise<Response> { + try { + const url = new URL(req.url); + const pathname = decodeURIComponent(url.pathname || ""); + const prefix = "/assets/"; + if (!pathname.startsWith(prefix)) return new Response("Not Found", { status: 404 }); + + // Path relative to backlog/assets + const relPath = pathname.slice(prefix.length); + + // disallow traversal + if (relPath.includes("..")) return new Response("Not Found", { status: 404 }); + + // derive backlog root from docsDir (parent of backlog/docs) + const docsDir = this.core.filesystem.docsDir; + const backlogRoot = dirname(docsDir); + const assetsRoot = join(backlogRoot, "assets"); + const filePath = join(assetsRoot, relPath); + + if (!filePath.startsWith(assetsRoot)) return new Response("Not Found", { status: 404 }); + + const file = Bun.file(filePath); + if (!(await file.exists())) return new Response("Not Found", { status: 404 }); + + const ext = (filePath.match(/\.([^./]+)$/) || [])[1]?.toLowerCase() || ""; + const mimeMap: Record<string, string> = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + avif: "image/avif", + pdf: "application/pdf", + txt: "text/plain", + css: "text/css", + js: "application/javascript", + }; + + const mime = mimeMap[ext] ?? "application/octet-stream"; + return new Response(file, { headers: { "Content-Type": mime } }); + } catch (error) { + console.error("Error serving asset:", error); + return new Response("Internal Server Error", { status: 500 }); + } + } + + private async handleRequest(req: Request, server: Server<unknown>): Promise<Response> { + const url = new URL(req.url); + const pathname = url.pathname; + + // Handle WebSocket upgrade + if (req.headers.get("upgrade") === "websocket") { + const success = server.upgrade(req, { data: undefined }); + if (success) { + return new Response(null, { status: 101 }); // WebSocket upgrade response + } + return new Response("WebSocket upgrade failed", { status: 400 }); + } + + // Workaround as Bun doesn't support images imported from link tags in HTML + if (pathname.startsWith("/favicon")) { + const faviconFile = Bun.file(favicon); + return new Response(faviconFile, { + headers: { "Content-Type": "image/png" }, + }); + } + + // For all other routes, return 404 since routes should handle all valid paths + return new Response("Not Found", { status: 404 }); + } + + // Task handlers + private async handleListTasks(req: Request): Promise<Response> { + const url = new URL(req.url); + const status = url.searchParams.get("status") || undefined; + const assignee = url.searchParams.get("assignee") || undefined; + const parent = url.searchParams.get("parent") || undefined; + const priorityParam = url.searchParams.get("priority") || undefined; + const crossBranch = url.searchParams.get("crossBranch") === "true"; + + let priority: "high" | "medium" | "low" | undefined; + if (priorityParam) { + const normalizedPriority = priorityParam.toLowerCase(); + const allowed = ["high", "medium", "low"]; + if (!allowed.includes(normalizedPriority)) { + return Response.json({ error: "Invalid priority filter" }, { status: 400 }); + } + priority = normalizedPriority as "high" | "medium" | "low"; + } + + // Resolve parent task ID if provided + let parentTaskId: string | undefined; + if (parent) { + const store = await this.getContentStoreInstance(); + const allTasks = store.getTasks(); + let parentTask = findTaskByLooseId(allTasks, parent); + if (!parentTask) { + const fallbackId = parent.startsWith(TASK_ID_PREFIX) ? parent : `${TASK_ID_PREFIX}${parent}`; + const fallback = await this.core.filesystem.loadTask(fallbackId); + if (fallback) { + store.upsertTask(fallback); + parentTask = fallback; + } + } + if (!parentTask) { + const normalizedParent = parent.startsWith(TASK_ID_PREFIX) ? parent : `${TASK_ID_PREFIX}${parent}`; + return Response.json({ error: `Parent task ${normalizedParent} not found` }, { status: 404 }); + } + parentTaskId = parentTask.id; + } + + // Use Core.queryTasks which handles all filtering and cross-branch logic + const tasks = await this.core.queryTasks({ + filters: { status, assignee, priority, parentTaskId }, + includeCrossBranch: crossBranch, + }); + + return Response.json(tasks); + } + + private async handleSearch(req: Request): Promise<Response> { + try { + const searchService = await this.getSearchServiceInstance(); + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? undefined; + const limitParam = url.searchParams.get("limit"); + const typeParams = [...url.searchParams.getAll("type"), ...url.searchParams.getAll("types")]; + const statusParams = url.searchParams.getAll("status"); + const priorityParamsRaw = url.searchParams.getAll("priority"); + + let limit: number | undefined; + if (limitParam) { + const parsed = Number.parseInt(limitParam, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + return Response.json({ error: "limit must be a positive integer" }, { status: 400 }); + } + limit = parsed; + } + + let types: SearchResultType[] | undefined; + if (typeParams.length > 0) { + const allowed: SearchResultType[] = ["task", "document", "decision"]; + const normalizedTypes = typeParams + .map((value) => value.toLowerCase()) + .filter((value): value is SearchResultType => { + return allowed.includes(value as SearchResultType); + }); + if (normalizedTypes.length === 0) { + return Response.json({ error: "type must be task, document, or decision" }, { status: 400 }); + } + types = normalizedTypes; + } + + const filters: { + status?: string | string[]; + priority?: SearchPriorityFilter | SearchPriorityFilter[]; + } = {}; + + if (statusParams.length === 1) { + filters.status = statusParams[0]; + } else if (statusParams.length > 1) { + filters.status = statusParams; + } + + if (priorityParamsRaw.length > 0) { + const allowedPriorities: SearchPriorityFilter[] = ["high", "medium", "low"]; + const normalizedPriorities = priorityParamsRaw.map((value) => value.toLowerCase()); + const invalidPriority = normalizedPriorities.find( + (value) => !allowedPriorities.includes(value as SearchPriorityFilter), + ); + if (invalidPriority) { + return Response.json( + { error: `Unsupported priority '${invalidPriority}'. Use high, medium, or low.` }, + { status: 400 }, + ); + } + const casted = normalizedPriorities as SearchPriorityFilter[]; + filters.priority = casted.length === 1 ? casted[0] : casted; + } + + const results = searchService.search({ query, limit, types, filters }); + return Response.json(results); + } catch (error) { + console.error("Error performing search:", error); + return Response.json({ error: "Search failed" }, { status: 500 }); + } + } + + private async handleCreateTask(req: Request): Promise<Response> { + const payload = await req.json(); + + if (!payload || typeof payload.title !== "string" || payload.title.trim().length === 0) { + return Response.json({ error: "Title is required" }, { status: 400 }); + } + + const acceptanceCriteria = Array.isArray(payload.acceptanceCriteriaItems) + ? payload.acceptanceCriteriaItems + .map((item: { text?: string; checked?: boolean }) => ({ + text: String(item?.text ?? "").trim(), + checked: Boolean(item?.checked), + })) + .filter((item: { text: string }) => item.text.length > 0) + : []; + + try { + const { task: createdTask } = await this.core.createTaskFromInput({ + title: payload.title, + description: payload.description, + status: payload.status, + priority: payload.priority, + labels: payload.labels, + assignee: payload.assignee, + dependencies: payload.dependencies, + parentTaskId: payload.parentTaskId, + implementationPlan: payload.implementationPlan, + implementationNotes: payload.implementationNotes, + acceptanceCriteria, + }); + return Response.json(createdTask, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create task"; + return Response.json({ error: message }, { status: 400 }); + } + } + + private async handleGetTask(taskId: string): Promise<Response> { + const store = await this.getContentStoreInstance(); + const tasks = store.getTasks(); + const task = findTaskByLooseId(tasks, taskId); + if (!task) { + const fallbackId = taskId.startsWith(TASK_ID_PREFIX) ? taskId : `${TASK_ID_PREFIX}${taskId}`; + const fallback = await this.core.filesystem.loadTask(fallbackId); + if (fallback) { + store.upsertTask(fallback); + return Response.json(fallback); + } + return Response.json({ error: "Task not found" }, { status: 404 }); + } + return Response.json(task); + } + + private async handleUpdateTask(req: Request, taskId: string): Promise<Response> { + const updates = await req.json(); + const existingTask = await this.core.filesystem.loadTask(taskId); + if (!existingTask) { + return Response.json({ error: "Task not found" }, { status: 404 }); + } + + const updateInput: TaskUpdateInput = {}; + + if ("title" in updates && typeof updates.title === "string") { + updateInput.title = updates.title; + } + + if ("description" in updates && typeof updates.description === "string") { + updateInput.description = updates.description; + } + + if ("status" in updates && typeof updates.status === "string") { + updateInput.status = updates.status; + } + + if ("priority" in updates && typeof updates.priority === "string") { + updateInput.priority = updates.priority; + } + + if ("labels" in updates && Array.isArray(updates.labels)) { + updateInput.labels = updates.labels; + } + + if ("assignee" in updates && Array.isArray(updates.assignee)) { + updateInput.assignee = updates.assignee; + } + + if ("dependencies" in updates && Array.isArray(updates.dependencies)) { + updateInput.dependencies = updates.dependencies; + } + + if ("implementationPlan" in updates && typeof updates.implementationPlan === "string") { + updateInput.implementationPlan = updates.implementationPlan; + } + + if ("implementationNotes" in updates && typeof updates.implementationNotes === "string") { + updateInput.implementationNotes = updates.implementationNotes; + } + + if ("acceptanceCriteriaItems" in updates && Array.isArray(updates.acceptanceCriteriaItems)) { + updateInput.acceptanceCriteria = updates.acceptanceCriteriaItems + .map((item: { text?: string; checked?: boolean }) => ({ + text: String(item?.text ?? "").trim(), + checked: Boolean(item?.checked), + })) + .filter((item: { text: string }) => item.text.length > 0); + } + + try { + const updatedTask = await this.core.updateTaskFromInput(taskId, updateInput); + return Response.json(updatedTask); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update task"; + return Response.json({ error: message }, { status: 400 }); + } + } + + private async handleDeleteTask(taskId: string): Promise<Response> { + const success = await this.core.archiveTask(taskId); + if (!success) { + return Response.json({ error: "Task not found" }, { status: 404 }); + } + return Response.json({ success: true }); + } + + private async handleCompleteTask(taskId: string): Promise<Response> { + try { + const task = await this.core.filesystem.loadTask(taskId); + if (!task) { + return Response.json({ error: "Task not found" }, { status: 404 }); + } + + const success = await this.core.completeTask(taskId); + if (!success) { + return Response.json({ error: "Failed to complete task" }, { status: 500 }); + } + + // Notify listeners to refresh + this.broadcastTasksUpdated(); + return Response.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to complete task"; + console.error("Error completing task:", error); + return Response.json({ error: message }, { status: 500 }); + } + } + + private async handleGetStatuses(): Promise<Response> { + const config = await this.core.filesystem.loadConfig(); + const statuses = config?.statuses || ["To Do", "In Progress", "Done"]; + return Response.json(statuses); + } + + // Documentation handlers + private async handleListDocs(): Promise<Response> { + try { + const store = await this.getContentStoreInstance(); + const docs = store.getDocuments(); + const docFiles = docs.map((doc) => ({ + name: `${doc.title}.md`, + id: doc.id, + title: doc.title, + type: doc.type, + createdDate: doc.createdDate, + updatedDate: doc.updatedDate, + lastModified: doc.updatedDate || doc.createdDate, + tags: doc.tags || [], + })); + return Response.json(docFiles); + } catch (error) { + console.error("Error listing documents:", error); + return Response.json([]); + } + } + + private async handleGetDoc(docId: string): Promise<Response> { + try { + const doc = await this.core.getDocument(docId); + if (!doc) { + return Response.json({ error: "Document not found" }, { status: 404 }); + } + return Response.json(doc); + } catch (error) { + console.error("Error loading document:", error); + return Response.json({ error: "Document not found" }, { status: 404 }); + } + } + + private async handleCreateDoc(req: Request): Promise<Response> { + const { filename, content } = await req.json(); + + try { + const title = filename.replace(".md", ""); + const document = await this.core.createDocumentWithId(title, content); + return Response.json({ success: true, id: document.id }, { status: 201 }); + } catch (error) { + console.error("Error creating document:", error); + return Response.json({ error: "Failed to create document" }, { status: 500 }); + } + } + + private async handleUpdateDoc(req: Request, docId: string): Promise<Response> { + try { + const body = await req.json(); + const content = typeof body?.content === "string" ? body.content : undefined; + const title = typeof body?.title === "string" ? body.title : undefined; + + if (typeof content !== "string") { + return Response.json({ error: "Document content is required" }, { status: 400 }); + } + + let normalizedTitle: string | undefined; + + if (typeof title === "string") { + normalizedTitle = title.trim(); + if (normalizedTitle.length === 0) { + return Response.json({ error: "Document title cannot be empty" }, { status: 400 }); + } + } + + const existingDoc = await this.core.getDocument(docId); + if (!existingDoc) { + return Response.json({ error: "Document not found" }, { status: 404 }); + } + + const nextDoc = normalizedTitle ? { ...existingDoc, title: normalizedTitle } : { ...existingDoc }; + + await this.core.updateDocument(nextDoc, content); + return Response.json({ success: true }); + } catch (error) { + console.error("Error updating document:", error); + if (error instanceof SyntaxError) { + return Response.json({ error: "Invalid request payload" }, { status: 400 }); + } + return Response.json({ error: "Failed to update document" }, { status: 500 }); + } + } + + // Decision handlers + private async handleListDecisions(): Promise<Response> { + try { + const store = await this.getContentStoreInstance(); + const decisions = store.getDecisions(); + const decisionFiles = decisions.map((decision) => ({ + id: decision.id, + title: decision.title, + status: decision.status, + date: decision.date, + context: decision.context, + decision: decision.decision, + consequences: decision.consequences, + alternatives: decision.alternatives, + })); + return Response.json(decisionFiles); + } catch (error) { + console.error("Error listing decisions:", error); + return Response.json([]); + } + } + + private async handleGetDecision(decisionId: string): Promise<Response> { + try { + const store = await this.getContentStoreInstance(); + const normalizedId = decisionId.startsWith("decision-") ? decisionId : `decision-${decisionId}`; + const decision = store.getDecisions().find((item) => item.id === normalizedId || item.id === decisionId); + + if (!decision) { + return Response.json({ error: "Decision not found" }, { status: 404 }); + } + + return Response.json(decision); + } catch (error) { + console.error("Error loading decision:", error); + return Response.json({ error: "Decision not found" }, { status: 404 }); + } + } + + private async handleCreateDecision(req: Request): Promise<Response> { + const { title } = await req.json(); + + try { + const decision = await this.core.createDecisionWithTitle(title); + return Response.json(decision, { status: 201 }); + } catch (error) { + console.error("Error creating decision:", error); + return Response.json({ error: "Failed to create decision" }, { status: 500 }); + } + } + + private async handleUpdateDecision(req: Request, decisionId: string): Promise<Response> { + const content = await req.text(); + + try { + await this.core.updateDecisionFromContent(decisionId, content); + return Response.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message.includes("not found")) { + return Response.json({ error: "Decision not found" }, { status: 404 }); + } + console.error("Error updating decision:", error); + return Response.json({ error: "Failed to update decision" }, { status: 500 }); + } + } + + private async handleGetConfig(): Promise<Response> { + try { + const config = await this.core.filesystem.loadConfig(); + if (!config) { + return Response.json({ error: "Configuration not found" }, { status: 404 }); + } + return Response.json(config); + } catch (error) { + console.error("Error loading config:", error); + return Response.json({ error: "Failed to load configuration" }, { status: 500 }); + } + } + + private async handleUpdateConfig(req: Request): Promise<Response> { + try { + const updatedConfig = await req.json(); + + // Validate configuration + if (!updatedConfig.projectName?.trim()) { + return Response.json({ error: "Project name is required" }, { status: 400 }); + } + + if (updatedConfig.defaultPort && (updatedConfig.defaultPort < 1 || updatedConfig.defaultPort > 65535)) { + return Response.json({ error: "Port must be between 1 and 65535" }, { status: 400 }); + } + + // Save configuration + await this.core.filesystem.saveConfig(updatedConfig); + + // Update local project name if changed + if (updatedConfig.projectName !== this.projectName) { + this.projectName = updatedConfig.projectName; + } + + // Notify connected clients so that they refresh configuration-dependent data (e.g., statuses) + this.broadcastTasksUpdated(); + + return Response.json(updatedConfig); + } catch (error) { + console.error("Error updating config:", error); + return Response.json({ error: "Failed to update configuration" }, { status: 500 }); + } + } + + private handleError(error: Error): Response { + console.error("Server Error:", error); + return new Response("Internal Server Error", { status: 500 }); + } + + // Draft handlers + private async handleListDrafts(): Promise<Response> { + try { + const drafts = await this.core.filesystem.listDrafts(); + return Response.json(drafts); + } catch (error) { + console.error("Error listing drafts:", error); + return Response.json([]); + } + } + + private async handlePromoteDraft(draftId: string): Promise<Response> { + try { + const success = await this.core.promoteDraft(draftId); + if (!success) { + return Response.json({ error: "Draft not found" }, { status: 404 }); + } + return Response.json({ success: true }); + } catch (error) { + console.error("Error promoting draft:", error); + return Response.json({ error: "Failed to promote draft" }, { status: 500 }); + } + } + + private async handleGetVersion(): Promise<Response> { + try { + const version = await getVersion(); + return Response.json({ version }); + } catch (error) { + console.error("Error getting version:", error); + return Response.json({ error: "Failed to get version" }, { status: 500 }); + } + } + + private async handleReorderTask(req: Request): Promise<Response> { + try { + const body = await req.json(); + const taskId = typeof body.taskId === "string" ? body.taskId : ""; + const targetStatus = typeof body.targetStatus === "string" ? body.targetStatus : ""; + const orderedTaskIds = Array.isArray(body.orderedTaskIds) ? body.orderedTaskIds : []; + + if (!taskId || !targetStatus || orderedTaskIds.length === 0) { + return Response.json( + { error: "Missing required fields: taskId, targetStatus, and orderedTaskIds" }, + { status: 400 }, + ); + } + + const { updatedTask } = await this.core.reorderTask({ + taskId, + targetStatus, + orderedTaskIds, + commitMessage: `Reorder tasks in ${targetStatus}`, + }); + + return Response.json({ success: true, task: updatedTask }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to reorder task"; + // Cross-branch and validation errors are client errors (400), not server errors (500) + const isCrossBranchError = message.includes("exists in branch"); + const isValidationError = message.includes("not found") || message.includes("Missing required"); + const status = isCrossBranchError || isValidationError ? 400 : 500; + if (status === 500) { + console.error("Error reordering task:", error); + } + return Response.json({ error: message }, { status }); + } + } + + private async handleCleanupPreview(req: Request): Promise<Response> { + try { + const url = new URL(req.url); + const ageParam = url.searchParams.get("age"); + + if (!ageParam) { + return Response.json({ error: "Missing age parameter" }, { status: 400 }); + } + + const age = Number.parseInt(ageParam, 10); + if (Number.isNaN(age) || age < 0) { + return Response.json({ error: "Invalid age parameter" }, { status: 400 }); + } + + // Get Done tasks older than specified days + const tasksToCleanup = await this.core.getDoneTasksByAge(age); + + // Return preview of tasks to be cleaned up + const preview = tasksToCleanup.map((task) => ({ + id: task.id, + title: task.title, + updatedDate: task.updatedDate, + createdDate: task.createdDate, + })); + + return Response.json({ + count: preview.length, + tasks: preview, + }); + } catch (error) { + console.error("Error getting cleanup preview:", error); + return Response.json({ error: "Failed to get cleanup preview" }, { status: 500 }); + } + } + + private async handleCleanupExecute(req: Request): Promise<Response> { + try { + const { age } = await req.json(); + + if (age === undefined || age === null) { + return Response.json({ error: "Missing age parameter" }, { status: 400 }); + } + + const ageInDays = Number.parseInt(age, 10); + if (Number.isNaN(ageInDays) || ageInDays < 0) { + return Response.json({ error: "Invalid age parameter" }, { status: 400 }); + } + + // Get Done tasks older than specified days + const tasksToCleanup = await this.core.getDoneTasksByAge(ageInDays); + + if (tasksToCleanup.length === 0) { + return Response.json({ + success: true, + movedCount: 0, + message: "No tasks to clean up", + }); + } + + // Move tasks to completed folder + let successCount = 0; + const failedTasks: string[] = []; + + for (const task of tasksToCleanup) { + try { + const success = await this.core.completeTask(task.id); + if (success) { + successCount++; + } else { + failedTasks.push(task.id); + } + } catch (error) { + console.error(`Failed to complete task ${task.id}:`, error); + failedTasks.push(task.id); + } + } + + // Notify listeners to refresh + this.broadcastTasksUpdated(); + + return Response.json({ + success: true, + movedCount: successCount, + totalCount: tasksToCleanup.length, + failedTasks: failedTasks.length > 0 ? failedTasks : undefined, + message: `Moved ${successCount} of ${tasksToCleanup.length} tasks to completed folder`, + }); + } catch (error) { + console.error("Error executing cleanup:", error); + return Response.json({ error: "Failed to execute cleanup" }, { status: 500 }); + } + } + + // Sequences handlers + private async handleGetSequences(): Promise<Response> { + const data = await this.core.listActiveSequences(); + return Response.json(data); + } + + private async handleMoveSequence(req: Request): Promise<Response> { + try { + const body = await req.json(); + const taskId = String(body.taskId || "").trim(); + const moveToUnsequenced = Boolean(body.unsequenced === true); + const targetSequenceIndex = body.targetSequenceIndex !== undefined ? Number(body.targetSequenceIndex) : undefined; + + if (!taskId) return Response.json({ error: "taskId is required" }, { status: 400 }); + + const next = await this.core.moveTaskInSequences({ + taskId, + unsequenced: moveToUnsequenced, + targetSequenceIndex, + }); + return Response.json(next); + } catch (error) { + const message = (error as Error)?.message || "Invalid request"; + return Response.json({ error: message }, { status: 400 }); + } + } + + private async handleGetStatistics(): Promise<Response> { + try { + // Load tasks using the same logic as CLI overview + const { tasks, drafts, statuses } = await this.core.loadAllTasksForStatistics(); + + // Calculate statistics using the exact same function as CLI + const statistics = getTaskStatistics(tasks, drafts, statuses); + + // Convert Maps to objects for JSON serialization + const response = { + ...statistics, + statusCounts: Object.fromEntries(statistics.statusCounts), + priorityCounts: Object.fromEntries(statistics.priorityCounts), + }; + + return Response.json(response); + } catch (error) { + console.error("Error getting statistics:", error); + return Response.json({ error: "Failed to get statistics" }, { status: 500 }); + } + } + + private async handleGetStatus(): Promise<Response> { + try { + const config = await this.core.filesystem.loadConfig(); + return Response.json({ + initialized: !!config, + projectPath: this.core.filesystem.rootDir, + }); + } catch (error) { + console.error("Error getting status:", error); + return Response.json({ + initialized: false, + projectPath: this.core.filesystem.rootDir, + }); + } + } + + private async handleInit(req: Request): Promise<Response> { + try { + const body = await req.json(); + const projectName = typeof body.projectName === "string" ? body.projectName.trim() : ""; + const integrationMode = body.integrationMode as "mcp" | "cli" | "none" | undefined; + const mcpClients = Array.isArray(body.mcpClients) ? body.mcpClients : []; + const agentInstructions = Array.isArray(body.agentInstructions) ? body.agentInstructions : []; + const installClaudeAgentFlag = Boolean(body.installClaudeAgent); + const advancedConfig = body.advancedConfig || {}; + + // Input validation (browser layer responsibility) + if (!projectName) { + return Response.json({ error: "Project name is required" }, { status: 400 }); + } + + // Check if already initialized (for browser, we don't allow re-init) + const existingConfig = await this.core.filesystem.loadConfig(); + if (existingConfig) { + return Response.json({ error: "Project is already initialized" }, { status: 400 }); + } + + // Call shared core init function + const result = await initializeProject(this.core, { + projectName, + integrationMode: integrationMode || "none", + mcpClients, + agentInstructions, + installClaudeAgent: installClaudeAgentFlag, + advancedConfig, + existingConfig: null, + }); + + // Update server's project name + this.projectName = result.projectName; + + // Ensure config watcher is set up now that config file exists + if (this.contentStore) { + this.contentStore.ensureConfigWatcher(); + } + + return Response.json({ + success: result.success, + projectName: result.projectName, + mcpResults: result.mcpResults, + }); + } catch (error) { + console.error("Error initializing project:", error); + const message = error instanceof Error ? error.message : "Failed to initialize project"; + return Response.json({ error: message }, { status: 500 }); + } + } +} diff --git a/src/test/acceptance-criteria-manager.test.ts b/src/test/acceptance-criteria-manager.test.ts new file mode 100644 index 0000000..0faacf6 --- /dev/null +++ b/src/test/acceptance-criteria-manager.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { AcceptanceCriteriaManager } from "../markdown/structured-sections.ts"; + +describe("AcceptanceCriteriaManager", () => { + it("removes a single criterion without affecting other sections", () => { + const base = AcceptanceCriteriaManager.formatAcceptanceCriteria([ + { checked: false, text: "First", index: 1 }, + { checked: false, text: "Second", index: 2 }, + { checked: false, text: "Third", index: 3 }, + ]); + const content = `## Description\n\nSomething\n\n${base}\n\n## Notes\nExtra`; + const updated = AcceptanceCriteriaManager.removeCriterionByIndex(content, 2); + expect(updated).toContain("- [ ] #1 First"); + expect(updated).toContain("- [ ] #2 Third"); + expect(updated).toContain("## Notes"); + expect(updated).not.toContain("Second"); + }); + + it("toggles a criterion and persists state", () => { + const base = AcceptanceCriteriaManager.formatAcceptanceCriteria([{ checked: false, text: "Only", index: 1 }]); + const updated = AcceptanceCriteriaManager.checkCriterionByIndex(base, 1, true); + expect(updated).toContain("- [x] #1 Only"); + }); +}); diff --git a/src/test/acceptance-criteria-structured.test.ts b/src/test/acceptance-criteria-structured.test.ts new file mode 100644 index 0000000..f4d639e --- /dev/null +++ b/src/test/acceptance-criteria-structured.test.ts @@ -0,0 +1,55 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { parseTask } from "../markdown/parser.ts"; + +const TEMP_DIR = join(process.cwd(), ".tmp-structured-ac-test"); + +describe("Structured Acceptance Criteria parsing", () => { + beforeAll(() => { + try { + rmSync(TEMP_DIR, { recursive: true, force: true }); + } catch {} + mkdirSync(TEMP_DIR, { recursive: true }); + }); + + afterAll(() => { + try { + rmSync(TEMP_DIR, { recursive: true, force: true }); + } catch {} + }); + + it("parses acceptance criteria items with checked state and index", () => { + const content = `--- +id: task-999 +title: Demo +status: To Do +assignee: [] +created_date: 2025-01-01 +labels: [] +dependencies: [] +--- + +## Description + +X + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 First +- [x] #2 Second +<!-- AC:END --> +`; + + const task = parseTask(content); + expect(task.acceptanceCriteriaItems?.length).toBe(2); + expect(task.acceptanceCriteriaItems?.[0]).toEqual({ index: 1, text: "First", checked: false }); + expect(task.acceptanceCriteriaItems?.[1]).toEqual({ index: 2, text: "Second", checked: true }); + + // Derived legacy-friendly text remains accessible by mapping items + expect(task.acceptanceCriteriaItems?.map((item) => `#${item.index} ${item.text}`)).toEqual([ + "#1 First", + "#2 Second", + ]); + }); +}); diff --git a/src/test/acceptance-criteria.test.ts b/src/test/acceptance-criteria.test.ts new file mode 100644 index 0000000..8eea59c --- /dev/null +++ b/src/test/acceptance-criteria.test.ts @@ -0,0 +1,653 @@ +import { afterEach, beforeEach, describe, expect, it, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { AcceptanceCriteriaManager } from "../markdown/structured-sections.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("Acceptance Criteria CLI", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-acceptance-criteria"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("AC Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("task create with acceptance criteria", () => { + it("should create task with single acceptance criterion using -ac", async () => { + const result = await $`bun ${CLI_PATH} task create "Test Task" --ac "Must work correctly"`.cwd(TEST_DIR).quiet(); + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 Must work correctly"); + }); + + it("should create task with multiple criteria using multiple --ac flags", async () => { + const result = + await $`bun ${CLI_PATH} task create "Test Task" --ac "Criterion 1" --ac "Criterion 2" --ac "Criterion 3"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("- [ ] #1 Criterion 1"); + expect(task?.rawContent).toContain("- [ ] #2 Criterion 2"); + expect(task?.rawContent).toContain("- [ ] #3 Criterion 3"); + }); + + it("should treat comma-separated text as single criterion", async () => { + const result = await $`bun ${CLI_PATH} task create "Test Task" --ac "Criterion 1, Criterion 2, Criterion 3"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + // Should create single criterion with commas intact + expect(task?.rawContent).toContain("- [ ] #1 Criterion 1, Criterion 2, Criterion 3"); + // Should NOT create multiple criteria + expect(task?.rawContent).not.toContain("- [ ] #2"); + }); + + it("should create task with criteria using --acceptance-criteria", async () => { + const result = await $`bun ${CLI_PATH} task create "Test Task" --acceptance-criteria "Full flag test"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 Full flag test"); + }); + + it("should create task with both description and acceptance criteria", async () => { + const result = + await $`bun ${CLI_PATH} task create "Test Task" -d "Task description" --ac "Must pass tests" --ac "Must be documented"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Description"); + expect(task?.rawContent).toContain("Task description"); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 Must pass tests"); + expect(task?.rawContent).toContain("- [ ] #2 Must be documented"); + }); + }); + + describe("task edit with acceptance criteria", () => { + beforeEach(async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Existing Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-19", + labels: [], + dependencies: [], + rawContent: "## Description\n\nExisting task description", + }, + false, + ); + }); + + it("should add acceptance criteria to existing task", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --ac "New criterion 1" --ac "New criterion 2"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Description"); + expect(task?.rawContent).toContain("Existing task description"); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 New criterion 1"); + expect(task?.rawContent).toContain("- [ ] #2 New criterion 2"); + }); + + it("consolidates duplicate Acceptance Criteria sections with markers into one", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-9", + title: "Dup AC Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-19", + labels: [], + dependencies: [], + rawContent: + "## Description\n\nX\n\n## Acceptance Criteria\n<!-- AC:BEGIN -->\n- [ ] #1 Old A\n<!-- AC:END -->\n\n## Acceptance Criteria\n<!-- AC:BEGIN -->\n- [ ] #1 Old B\n<!-- AC:END -->", + }, + false, + ); + + // Add a new criterion via CLI; this triggers consolidation + const result = await $`bun ${CLI_PATH} task edit 9 --ac "New C"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const task = await core.filesystem.loadTask("task-9"); + expect(task).not.toBeNull(); + const body = task?.rawContent || ""; + // Only one header and one marker pair should remain + expect((body.match(/## Acceptance Criteria/g) || []).length).toBe(1); + expect((body.match(/<!-- AC:BEGIN -->/g) || []).length).toBe(1); + expect((body.match(/<!-- AC:END -->/g) || []).length).toBe(1); + // New content should be present and renumbered + expect(body).toContain("- [ ] #1 Old A"); + expect(body).toContain("- [ ] #2 Old B"); + expect(body).toContain("- [ ] #3 New C"); + }); + + it("consolidates legacy and marked AC sections to a single marked section", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-10", + title: "Mixed AC Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-19", + labels: [], + dependencies: [], + rawContent: + "## Description\n\nY\n\n## Acceptance Criteria\n\n- [ ] Legacy 1\n- [ ] Legacy 2\n\n## Acceptance Criteria\n<!-- AC:BEGIN -->\n- [ ] #1 Marked 1\n<!-- AC:END -->", + }, + false, + ); + + const result = await $`bun ${CLI_PATH} task edit 10 --ac "Marked 2"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const task = await core.filesystem.loadTask("task-10"); + expect(task).not.toBeNull(); + const body = task?.rawContent || ""; + expect((body.match(/## Acceptance Criteria/g) || []).length).toBe(1); + expect((body.match(/<!-- AC:BEGIN -->/g) || []).length).toBe(1); + expect((body.match(/<!-- AC:END -->/g) || []).length).toBe(1); + // Final section should be marked format and renumbered + expect(body).toContain("- [ ] #1 Marked 1"); + expect(body).toContain("- [ ] #2 Marked 2"); + // No legacy-only lines remaining + expect(body).not.toContain("Legacy 1"); + expect(body).not.toContain("Legacy 2"); + }); + + it("should add to existing acceptance criteria", async () => { + // First add some criteria via CLI to avoid direct body mutation + const res = await $`bun ${CLI_PATH} task edit 1 --ac "Old criterion 1" --ac "Old criterion 2"` + .cwd(TEST_DIR) + .quiet(); + expect(res.exitCode).toBe(0); + + // Now add new criterion + const result = await $`bun ${CLI_PATH} task edit 1 --ac "New criterion"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 Old criterion 1"); + expect(task?.rawContent).toContain("- [ ] #2 Old criterion 2"); + expect(task?.rawContent).toContain("- [ ] #3 New criterion"); + }); + + it("should update title and add acceptance criteria together", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 -t "Updated Title" --ac "Must be updated" --ac "Must work"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.title).toBe("Updated Title"); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 Must be updated"); + expect(task?.rawContent).toContain("- [ ] #2 Must work"); + }); + }); + + describe("acceptance criteria parsing", () => { + it("should handle empty criteria gracefully", async () => { + // Skip the --ac flag entirely when empty, as the shell API doesn't handle empty strings the same way + const result = await $`bun ${CLI_PATH} task create "Test Task"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + // Should not add acceptance criteria section for empty input + expect(task?.rawContent).not.toContain("## Acceptance Criteria"); + }); + + it("should trim whitespace from criteria", async () => { + const result = + await $`bun ${CLI_PATH} task create "Test Task" --ac " Criterion with spaces " --ac " Another one "` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("- [ ] #1 Criterion with spaces"); + expect(task?.rawContent).toContain("- [ ] #2 Another one"); + }); + }); + + describe("new AC management features", () => { + beforeEach(async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-19", + labels: [], + dependencies: [], + rawContent: `## Description + +Test task with acceptance criteria + +## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 First criterion +- [ ] #2 Second criterion +- [ ] #3 Third criterion +<!-- AC:END -->`, + }, + false, + ); + }); + + it("should add new acceptance criteria with --ac", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --ac "Fourth criterion" --ac "Fifth criterion"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task?.rawContent).toContain("- [ ] #1 First criterion"); + expect(task?.rawContent).toContain("- [ ] #2 Second criterion"); + expect(task?.rawContent).toContain("- [ ] #3 Third criterion"); + expect(task?.rawContent).toContain("- [ ] #4 Fourth criterion"); + expect(task?.rawContent).toContain("- [ ] #5 Fifth criterion"); + }); + + it("should remove acceptance criterion by index with --remove-ac", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --remove-ac 2`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task?.rawContent).toContain("- [ ] #1 First criterion"); + expect(task?.rawContent).not.toContain("Second criterion"); + expect(task?.rawContent).toContain("- [ ] #2 Third criterion"); // Renumbered + }); + + it("removes acceptance criteria section after deleting all items", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --remove-ac 1 --remove-ac 2 --remove-ac 3` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + const body = task?.rawContent || ""; + expect(body).not.toContain("## Acceptance Criteria"); + expect(body).not.toContain("<!-- AC:BEGIN -->"); + expect(body).not.toContain("<!-- AC:END -->"); + }); + + it("should check acceptance criterion by index with --check-ac", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --check-ac 2`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task?.rawContent).toContain("- [ ] #1 First criterion"); + expect(task?.rawContent).toContain("- [x] #2 Second criterion"); + expect(task?.rawContent).toContain("- [ ] #3 Third criterion"); + }); + + it("should uncheck acceptance criterion by index with --uncheck-ac", async () => { + // First check a criterion + await $`bun ${CLI_PATH} task edit 1 --check-ac 1`.cwd(TEST_DIR).quiet(); + + // Then uncheck it + const result = await $`bun ${CLI_PATH} task edit 1 --uncheck-ac 1`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task?.rawContent).toContain("- [ ] #1 First criterion"); + }); + + it("should handle multiple operations in one command", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --check-ac 1 --remove-ac 2 --ac "New criterion"` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task?.rawContent).toContain("- [x] #1 First criterion"); + expect(task?.rawContent).not.toContain("Second criterion"); + expect(task?.rawContent).toContain("- [ ] #2 Third criterion"); // Renumbered + expect(task?.rawContent).toContain("- [ ] #3 New criterion"); + }); + + it("should error on invalid index for --remove-ac", async () => { + try { + await $`bun ${CLI_PATH} task edit 1 --remove-ac 10`.cwd(TEST_DIR).quiet(); + expect(true).toBe(false); // Should not reach here + } catch (error: unknown) { + const e = error as { exitCode?: number; stderr?: unknown }; + expect(e.exitCode).not.toBe(0); + const msg = e.stderr == null ? "" : String(e.stderr); + expect(msg).toContain("Acceptance criterion #10 not found"); + } + }); + + it("should error on invalid index for --check-ac", async () => { + try { + await $`bun ${CLI_PATH} task edit 1 --check-ac 10`.cwd(TEST_DIR).quiet(); + expect(true).toBe(false); // Should not reach here + } catch (error: unknown) { + const e = error as { exitCode?: number; stderr?: unknown }; + expect(e.exitCode).not.toBe(0); + const msg = e.stderr == null ? "" : String(e.stderr); + expect(msg).toContain("Acceptance criterion #10 not found"); + } + }); + + it("should error on non-numeric index", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --remove-ac abc`.cwd(TEST_DIR).quiet().nothrow(); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("Invalid index"); + }); + + it("should error on zero index", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --remove-ac 0`.cwd(TEST_DIR).quiet().nothrow(); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("Invalid index"); + }); + + it("should error on negative index", async () => { + const result = await $`bun ${CLI_PATH} task edit 1 --remove-ac=-1`.cwd(TEST_DIR).quiet().nothrow(); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("Invalid index"); + }); + }); + + describe("stable format migration", () => { + it("should convert old format to stable format when editing", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-2", + title: "Old Format Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-19", + labels: [], + dependencies: [], + rawContent: `## Description + +## Acceptance Criteria + +- [ ] Old format criterion 1 +- [x] Old format criterion 2`, + }, + false, + ); + + const result = await $`bun ${CLI_PATH} task edit 2 --ac "New criterion"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const task = await core.filesystem.loadTask("task-2"); + expect(task?.rawContent).toContain("<!-- AC:BEGIN -->"); + expect(task?.rawContent).toContain("- [ ] #1 Old format criterion 1"); + expect(task?.rawContent).toContain("- [x] #2 Old format criterion 2"); + expect(task?.rawContent).toContain("- [ ] #3 New criterion"); + expect(task?.rawContent).toContain("<!-- AC:END -->"); + }); + }); +}); + +describe("AcceptanceCriteriaManager unit tests", () => { + let TEST_DIR_UNIT: string; + const CLI_PATH_UNIT = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR_UNIT = createUniqueTestDir("test-acceptance-criteria-unit"); + await rm(TEST_DIR_UNIT, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR_UNIT, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR_UNIT).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR_UNIT).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR_UNIT).quiet(); + + const core = new Core(TEST_DIR_UNIT); + await core.initializeProject("AC Unit Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR_UNIT); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should parse criteria with stable markers", () => { + const content = `## Acceptance Criteria +<!-- AC:BEGIN --> +- [ ] #1 First criterion +- [x] #2 Second criterion +- [ ] #3 Third criterion +<!-- AC:END -->`; + + const criteria = AcceptanceCriteriaManager.parseAcceptanceCriteria(content); + expect(criteria).toHaveLength(3); + expect(criteria[0]).toEqual({ checked: false, text: "First criterion", index: 1 }); + expect(criteria[1]).toEqual({ checked: true, text: "Second criterion", index: 2 }); + expect(criteria[2]).toEqual({ checked: false, text: "Third criterion", index: 3 }); + }); + + test("should format criteria with proper numbering", () => { + const criteria = [ + { checked: false, text: "First", index: 1 }, + { checked: true, text: "Second", index: 2 }, + ]; + + const formatted = AcceptanceCriteriaManager.formatAcceptanceCriteria(criteria); + expect(formatted).toContain("## Acceptance Criteria"); + expect(formatted).toContain("<!-- AC:BEGIN -->"); + expect(formatted).toContain("- [ ] #1 First"); + expect(formatted).toContain("- [x] #2 Second"); + expect(formatted).toContain("<!-- AC:END -->"); + }); + + test("preserves markdown headings inside acceptance criteria when updating", () => { + const base = `## Acceptance Criteria +<!-- AC:BEGIN --> +### Critical +- [ ] #1 Must pass authentication + +### Optional +- [ ] #2 Show detailed logs +<!-- AC:END -->`; + + const updated = AcceptanceCriteriaManager.updateContent(base, [ + { index: 1, text: "Must pass authentication", checked: true }, + { index: 2, text: "Show detailed logs", checked: false }, + { index: 3, text: "Document audit trail", checked: false }, + ]); + + const bodyMatch = updated.match(/<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/); + expect(bodyMatch).not.toBeNull(); + const body = bodyMatch?.[1] || ""; + expect(body).toContain("### Critical"); + expect(body).toContain("### Optional"); + expect(body).toContain("- [x] #1 Must pass authentication"); + expect(body).toContain("- [ ] #2 Show detailed logs"); + expect(body).toContain("- [ ] #3 Document audit trail"); + const orderIndex = body.indexOf("- [ ] #3 Document audit trail"); + expect(orderIndex).toBeGreaterThan(body.indexOf("### Optional")); + + const reduced = AcceptanceCriteriaManager.updateContent(updated, [ + { index: 1, text: "Must pass authentication", checked: false }, + ]); + const reducedBody = reduced.match(/<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/)?.[1] || ""; + expect(reducedBody).toContain("### Critical"); + expect(reducedBody).toContain("### Optional"); + expect(reducedBody).toContain("- [ ] #1 Must pass authentication"); + expect(reducedBody).not.toContain("Show detailed logs"); + }); + + describe("Multi-value CLI operations", () => { + it("should support multiple --ac flags in task create", async () => { + const result = + await $`bun run ${CLI_PATH_UNIT} task create "Multi AC Test" --ac "First" --ac "Second" --ac "Third"`.cwd( + TEST_DIR_UNIT, + ); + expect(result.exitCode).toBe(0); + + // Parse task ID from output + const taskId = result.stdout.toString().match(/Created task (task-\d+)/)?.[1]; + expect(taskId).toBeTruthy(); + + // Verify ACs were created + const taskResult = await $`bun run ${CLI_PATH_UNIT} task ${taskId} --plain`.cwd(TEST_DIR_UNIT); + expect(taskResult.stdout.toString()).toContain("- [ ] #1 First"); + expect(taskResult.stdout.toString()).toContain("- [ ] #2 Second"); + expect(taskResult.stdout.toString()).toContain("- [ ] #3 Third"); + }); + + it("should support multiple --check-ac flags in single command", async () => { + // Create task with multiple ACs + const createResult = + await $`bun run ${CLI_PATH_UNIT} task create "Check Test" --ac "First" --ac "Second" --ac "Third" --ac "Fourth"`.cwd( + TEST_DIR_UNIT, + ); + const taskId = createResult.stdout.toString().match(/Created task (task-\d+)/)?.[1]; + + // Check multiple ACs at once + const checkResult = await $`bun run ${CLI_PATH_UNIT} task edit ${taskId} --check-ac 1 --check-ac 3`.cwd( + TEST_DIR_UNIT, + ); + expect(checkResult.exitCode).toBe(0); + + // Verify correct ACs were checked + const taskResult = await $`bun run ${CLI_PATH_UNIT} task ${taskId} --plain`.cwd(TEST_DIR_UNIT); + expect(taskResult.stdout.toString()).toContain("- [x] #1 First"); + expect(taskResult.stdout.toString()).toContain("- [ ] #2 Second"); + expect(taskResult.stdout.toString()).toContain("- [x] #3 Third"); + expect(taskResult.stdout.toString()).toContain("- [ ] #4 Fourth"); + }); + + it("should support mixed AC operations in single command", async () => { + // Create task with multiple ACs + const createResult = + await $`bun run ${CLI_PATH_UNIT} task create "Mixed Test" --ac "First" --ac "Second" --ac "Third" --ac "Fourth"`.cwd( + TEST_DIR_UNIT, + ); + const taskId = createResult.stdout.toString().match(/Created task (task-\d+)/)?.[1]; + + // Check some ACs first + await $`bun run ${CLI_PATH_UNIT} task edit ${taskId} --check-ac 1 --check-ac 2 --check-ac 3`.cwd(TEST_DIR_UNIT); + + // Now do mixed operations: uncheck 1, keep 2 checked, check 4 + const mixedResult = await $`bun run ${CLI_PATH_UNIT} task edit ${taskId} --uncheck-ac 1 --check-ac 4`.cwd( + TEST_DIR_UNIT, + ); + expect(mixedResult.exitCode).toBe(0); + + // Verify final state + const taskResult = await $`bun run ${CLI_PATH_UNIT} task ${taskId} --plain`.cwd(TEST_DIR_UNIT); + expect(taskResult.stdout.toString()).toContain("- [ ] #1 First"); // unchecked + expect(taskResult.stdout.toString()).toContain("- [x] #2 Second"); // remained checked + expect(taskResult.stdout.toString()).toContain("- [x] #3 Third"); // remained checked + expect(taskResult.stdout.toString()).toContain("- [x] #4 Fourth"); // newly checked + }); + + it("should support multiple --remove-ac flags with proper renumbering", async () => { + // Create task with 5 ACs + const createResult = + await $`bun run ${CLI_PATH_UNIT} task create "Remove Test" --ac "First" --ac "Second" --ac "Third" --ac "Fourth" --ac "Fifth"`.cwd( + TEST_DIR_UNIT, + ); + const taskId = createResult.stdout.toString().match(/Created task (task-\d+)/)?.[1]; + + // Remove ACs 2 and 4 (should be processed in descending order to avoid index shifting) + const removeResult = await $`bun run ${CLI_PATH_UNIT} task edit ${taskId} --remove-ac 2 --remove-ac 4`.cwd( + TEST_DIR_UNIT, + ); + expect(removeResult.exitCode).toBe(0); + + // Verify remaining ACs are properly renumbered + const taskResult = await $`bun run ${CLI_PATH_UNIT} task ${taskId} --plain`.cwd(TEST_DIR_UNIT); + expect(taskResult.stdout.toString()).toContain("- [ ] #1 First"); // original #1 + expect(taskResult.stdout.toString()).toContain("- [ ] #2 Third"); // original #3 -> #2 + expect(taskResult.stdout.toString()).toContain("- [ ] #3 Fifth"); // original #5 -> #3 + expect(taskResult.stdout.toString()).not.toContain("Second"); // removed + expect(taskResult.stdout.toString()).not.toContain("Fourth"); // removed + }); + + it("should handle invalid indices gracefully in multi-value operations", async () => { + // Create task with 2 ACs + const createResult = await $`bun run ${CLI_PATH_UNIT} task create "Invalid Test" --ac "First" --ac "Second"`.cwd( + TEST_DIR_UNIT, + ); + const taskId = createResult.stdout.toString().match(/Created task (task-\d+)/)?.[1]; + + // Try to check valid and invalid indices + const checkResult = await $`bun run ${CLI_PATH_UNIT} task edit ${taskId} --check-ac 1 --check-ac 5` + .cwd(TEST_DIR_UNIT) + .nothrow(); + expect(checkResult.exitCode).toBe(1); + expect(checkResult.stderr.toString()).toContain("Acceptance criterion #5 not found"); + }); + }); +}); diff --git a/src/test/agent-instructions.test.ts b/src/test/agent-instructions.test.ts new file mode 100644 index 0000000..dba16a1 --- /dev/null +++ b/src/test/agent-instructions.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + _loadAgentGuideline, + AGENT_GUIDELINES, + addAgentInstructions, + CLAUDE_GUIDELINES, + COPILOT_GUIDELINES, + ensureMcpGuidelines, + GEMINI_GUIDELINES, + README_GUIDELINES, +} from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("addAgentInstructions", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-agent-instructions"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("creates guideline files when none exist", async () => { + await addAgentInstructions(TEST_DIR); + const agents = await Bun.file(join(TEST_DIR, "AGENTS.md")).text(); + const claude = await Bun.file(join(TEST_DIR, "CLAUDE.md")).text(); + const gemini = await Bun.file(join(TEST_DIR, "GEMINI.md")).text(); + const copilot = await Bun.file(join(TEST_DIR, ".github/copilot-instructions.md")).text(); + + // Check that files contain the markers and content + expect(agents).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(agents).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(agents).toContain(await _loadAgentGuideline(AGENT_GUIDELINES)); + + expect(claude).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(claude).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(claude).toContain(await _loadAgentGuideline(CLAUDE_GUIDELINES)); + + expect(gemini).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(gemini).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(gemini).toContain(await _loadAgentGuideline(GEMINI_GUIDELINES)); + + expect(copilot).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(copilot).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(copilot).toContain(await _loadAgentGuideline(COPILOT_GUIDELINES)); + }); + + it("appends guideline files when they already exist", async () => { + await Bun.write(join(TEST_DIR, "AGENTS.md"), "Existing\n"); + await addAgentInstructions(TEST_DIR); + const agents = await Bun.file(join(TEST_DIR, "AGENTS.md")).text(); + expect(agents.startsWith("Existing\n")).toBe(true); + expect(agents).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(agents).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(agents).toContain(await _loadAgentGuideline(AGENT_GUIDELINES)); + }); + + it("creates only selected files", async () => { + await addAgentInstructions(TEST_DIR, undefined, ["AGENTS.md", "README.md"]); + + const agentsExists = await Bun.file(join(TEST_DIR, "AGENTS.md")).exists(); + const claudeExists = await Bun.file(join(TEST_DIR, "CLAUDE.md")).exists(); + const geminiExists = await Bun.file(join(TEST_DIR, "GEMINI.md")).exists(); + const copilotExists = await Bun.file(join(TEST_DIR, ".github/copilot-instructions.md")).exists(); + const readme = await Bun.file(join(TEST_DIR, "README.md")).text(); + + expect(agentsExists).toBe(true); + expect(claudeExists).toBe(false); + expect(geminiExists).toBe(false); + expect(copilotExists).toBe(false); + expect(readme).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(readme).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(readme).toContain(await _loadAgentGuideline(README_GUIDELINES)); + }); + + it("loads guideline content from file paths", async () => { + const pathGuideline = join(__dirname, "../guidelines/agent-guidelines.md"); + const content = await _loadAgentGuideline(pathGuideline); + expect(content).toContain("# Instructions for the usage of Backlog.md CLI Tool"); + }); + + it("does not duplicate content when run multiple times (idempotent)", async () => { + // First run + await addAgentInstructions(TEST_DIR); + const firstRun = await Bun.file(join(TEST_DIR, "CLAUDE.md")).text(); + + // Second run - should not duplicate content + await addAgentInstructions(TEST_DIR); + const secondRun = await Bun.file(join(TEST_DIR, "CLAUDE.md")).text(); + + expect(firstRun).toBe(secondRun); + }); + + it("preserves existing content and adds Backlog.md content only once", async () => { + const existingContent = "# My Existing Claude Instructions\n\nThis is my custom content.\n"; + await Bun.write(join(TEST_DIR, "CLAUDE.md"), existingContent); + + // First run + await addAgentInstructions(TEST_DIR, undefined, ["CLAUDE.md"]); + const firstRun = await Bun.file(join(TEST_DIR, "CLAUDE.md")).text(); + + // Second run - should not duplicate Backlog.md content + await addAgentInstructions(TEST_DIR, undefined, ["CLAUDE.md"]); + const secondRun = await Bun.file(join(TEST_DIR, "CLAUDE.md")).text(); + + expect(firstRun).toBe(secondRun); + expect(firstRun).toContain(existingContent); + expect(firstRun).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(firstRun).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + + // Count occurrences of the marker to ensure it's only there once + const startMarkerCount = (firstRun.match(/<!-- BACKLOG\.MD GUIDELINES START -->/g) || []).length; + const endMarkerCount = (firstRun.match(/<!-- BACKLOG\.MD GUIDELINES END -->/g) || []).length; + expect(startMarkerCount).toBe(1); + expect(endMarkerCount).toBe(1); + }); + + it("handles different file types with appropriate markers", async () => { + const existingContent = "existing content\n"; + + // Test AGENTS.md (markdown with HTML comments) + await Bun.write(join(TEST_DIR, "AGENTS.md"), existingContent); + await addAgentInstructions(TEST_DIR, undefined, ["AGENTS.md"]); + const agentsContent = await Bun.file(join(TEST_DIR, "AGENTS.md")).text(); + expect(agentsContent).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(agentsContent).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + }); + + it("replaces CLI guidelines with MCP nudge when switching modes", async () => { + const agentsPath = join(TEST_DIR, "AGENTS.md"); + const cliBlock = [ + "Preface content", + "<!-- BACKLOG.MD GUIDELINES START -->", + "CLI instructions here", + "<!-- BACKLOG.MD GUIDELINES END -->", + "Footer line", + "", + ].join("\n"); + await Bun.write(agentsPath, cliBlock); + + await ensureMcpGuidelines(TEST_DIR, "AGENTS.md"); + const updated = await Bun.file(agentsPath).text(); + + expect(updated).not.toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(updated).not.toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(updated).toContain("<!-- BACKLOG.MD MCP GUIDELINES START -->"); + expect(updated).toContain("<!-- BACKLOG.MD MCP GUIDELINES END -->"); + expect(updated).toContain("Preface content"); + expect(updated).toContain("Footer line"); + }); + + it("replaces MCP nudge with CLI guidelines when switching modes", async () => { + const agentsPath = join(TEST_DIR, "AGENTS.md"); + const mcpBlock = [ + "Header", + "<!-- BACKLOG.MD MCP GUIDELINES START -->", + "MCP reminder here", + "<!-- BACKLOG.MD MCP GUIDELINES END -->", + "", + ].join("\n"); + await Bun.write(agentsPath, mcpBlock); + + await addAgentInstructions(TEST_DIR, undefined, ["AGENTS.md"]); + const updated = await Bun.file(agentsPath).text(); + + expect(updated).toContain("<!-- BACKLOG.MD GUIDELINES START -->"); + expect(updated).toContain("<!-- BACKLOG.MD GUIDELINES END -->"); + expect(updated).not.toContain("<!-- BACKLOG.MD MCP GUIDELINES START -->"); + expect(updated).not.toContain("<!-- BACKLOG.MD MCP GUIDELINES END -->"); + expect(updated).toContain("Header"); + }); +}); diff --git a/src/test/agent-selection.test.ts b/src/test/agent-selection.test.ts new file mode 100644 index 0000000..f1db06b --- /dev/null +++ b/src/test/agent-selection.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { type AgentSelectionValue, PLACEHOLDER_AGENT_VALUE, processAgentSelection } from "../utils/agent-selection.ts"; + +const AGENTS_MD = "AGENTS.md" as const; +const CLAUDE_MD = "CLAUDE.md" as const; +const GEMINI_MD = "GEMINI.md" as const; + +describe("processAgentSelection", () => { + it("returns explicit selections", () => { + const result = processAgentSelection({ selected: [AGENTS_MD, CLAUDE_MD] }); + expect(result.needsRetry).toBe(false); + expect(result.files).toEqual([AGENTS_MD, CLAUDE_MD]); + expect(result.skipped).toBe(false); + }); + + it("auto-selects highlighted item when none selected and fallback enabled", () => { + const result = processAgentSelection({ selected: [], highlighted: GEMINI_MD, useHighlightFallback: true }); + expect(result.needsRetry).toBe(false); + expect(result.files).toEqual([GEMINI_MD]); + expect(result.skipped).toBe(false); + }); + + it("does not auto-select highlight when fallback disabled", () => { + const result = processAgentSelection({ selected: [], highlighted: CLAUDE_MD }); + expect(result.needsRetry).toBe(true); + expect(result.files).toEqual([]); + expect(result.skipped).toBe(false); + }); + + it("ignores placeholder highlight even when fallback enabled", () => { + const result = processAgentSelection({ + selected: [], + highlighted: PLACEHOLDER_AGENT_VALUE, + useHighlightFallback: true, + }); + expect(result.needsRetry).toBe(true); + expect(result.files).toEqual([]); + expect(result.skipped).toBe(false); + }); + + it("requires retry when nothing highlighted or selected", () => { + const result = processAgentSelection({ selected: [] }); + expect(result.needsRetry).toBe(true); + expect(result.files).toEqual([]); + expect(result.skipped).toBe(false); + }); + + it("filters out 'none' when combined with other selections", () => { + const result = processAgentSelection({ selected: ["none", AGENTS_MD] as AgentSelectionValue[] }); + expect(result.needsRetry).toBe(false); + expect(result.files).toEqual([AGENTS_MD]); + expect(result.skipped).toBe(false); + }); + + it("reports skip when only 'none' is selected", () => { + const result = processAgentSelection({ selected: ["none"] }); + expect(result.needsRetry).toBe(false); + expect(result.files).toEqual([]); + expect(result.skipped).toBe(true); + }); + + it("dedupes selections while preserving order", () => { + const result = processAgentSelection({ + selected: [AGENTS_MD, CLAUDE_MD, AGENTS_MD, "none", CLAUDE_MD], + }); + expect(result.needsRetry).toBe(false); + expect(result.files).toEqual([AGENTS_MD, CLAUDE_MD]); + expect(result.skipped).toBe(false); + }); +}); diff --git a/src/test/append-implementation-notes.test.ts b/src/test/append-implementation-notes.test.ts new file mode 100644 index 0000000..b677a00 --- /dev/null +++ b/src/test/append-implementation-notes.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { extractStructuredSection } from "../markdown/structured-sections.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("Append Implementation Notes via task edit --append-notes", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-append-notes"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email "test@example.com"`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Append Notes Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // ignore + } + }); + + it("appends to existing Implementation Notes with single blank line separation", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Existing notes", + status: "To Do", + assignee: [], + createdDate: "2025-09-10 00:00", + labels: [], + dependencies: [], + description: "Test description", + implementationNotes: "Original notes", + }, + false, + ); + + // Append twice in one call and once again afterwards + let res = await $`bun ${CLI_PATH} task edit 1 --append-notes "First addition" --append-notes "Second addition"` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(res.exitCode).toBe(0); + + res = await $`bun ${CLI_PATH} task edit 1 --append-notes "Third addition"`.cwd(TEST_DIR).quiet().nothrow(); + expect(res.exitCode).toBe(0); + + const updatedBody = await core.getTaskContent("task-1"); + expect(updatedBody).not.toBeNull(); + + const body = extractStructuredSection(updatedBody ?? "", "implementationNotes") || ""; + expect(body).toBe("Original notes\n\nFirst addition\n\nSecond addition\n\nThird addition"); + }); + + it("creates Implementation Notes at correct position when missing (after Plan)", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-2", + title: "No notes yet", + status: "To Do", + assignee: [], + createdDate: "2025-09-10 00:00", + labels: [], + dependencies: [], + description: "Desc here", + acceptanceCriteriaItems: [{ index: 1, text: "Do X", checked: false }], + implementationPlan: "1. A\n2. B", + }, + false, + ); + + const res = await $`bun ${CLI_PATH} task edit 2 --append-notes "Notes after plan"`.cwd(TEST_DIR).quiet().nothrow(); + expect(res.exitCode).toBe(0); + + const content = (await core.getTaskContent("task-2")) ?? ""; + const notesContent = extractStructuredSection(content, "implementationNotes") || ""; + expect(notesContent).toBe("Notes after plan"); + const planMarker = "<!-- SECTION:PLAN:BEGIN -->"; + const notesMarker = "<!-- SECTION:NOTES:BEGIN -->"; + expect(content.indexOf(planMarker)).toBeGreaterThan(-1); + expect(content.indexOf(notesMarker)).toBeGreaterThan(content.indexOf(planMarker)); + }); + + it("supports multi-line appended content and preserves literal newlines", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-3", + title: "Multiline append", + status: "To Do", + assignee: [], + createdDate: "2025-09-10 00:00", + labels: [], + dependencies: [], + description: "Simple description", + }, + false, + ); + + // Pass a JS string containing real newlines as an argument + const multiline = "Line1\nLine2\n\nPara2"; + const res = await $`bun ${[CLI_PATH, "task", "edit", "3", "--append-notes", multiline]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(res.exitCode).toBe(0); + + const updatedBody = await core.getTaskContent("task-3"); + const body = extractStructuredSection(updatedBody ?? "", "implementationNotes") || ""; + expect(body).toContain("Line1\nLine2\n\nPara2"); + }); + + it("allows combining --notes (replace) with --append-notes (append)", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-4", + title: "Mix flags", + status: "To Do", + assignee: [], + createdDate: "2025-09-10 00:00", + labels: [], + dependencies: [], + description: "Description only", + }, + false, + ); + + const res = await $`bun ${CLI_PATH} task edit 4 --notes "Replace" --append-notes "Append"` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + + // Should succeed: --notes replaces existing, then --append-notes appends + expect(res.exitCode).toBe(0); + const updatedBody = await core.getTaskContent("task-4"); + const body = extractStructuredSection(updatedBody ?? "", "implementationNotes") || ""; + expect(body).toBe("Replace\n\nAppend"); + }); +}); diff --git a/src/test/auto-commit.test.ts b/src/test/auto-commit.test.ts new file mode 100644 index 0000000..269a27a --- /dev/null +++ b/src/test/auto-commit.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { BacklogConfig, Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Auto-commit configuration", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-auto-commit"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test Auto-commit Project", true); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("Config migration", () => { + it("should include autoCommit in default config with false value", async () => { + const config = await core.filesystem.loadConfig(); + expect(config).toBeDefined(); + expect(config?.autoCommit).toBe(false); + }); + + it("should migrate existing config to include autoCommit", async () => { + // Create config without autoCommit + const oldConfig: BacklogConfig = { + projectName: "Test Project", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + }; + await core.filesystem.saveConfig(oldConfig); + + // Trigger migration + await core.ensureConfigMigrated(); + + const migratedConfig = await core.filesystem.loadConfig(); + expect(migratedConfig).toBeDefined(); + expect(migratedConfig?.autoCommit).toBe(false); + }); + }); + + describe("Core operations with autoCommit disabled", () => { + beforeEach(async () => { + // Set autoCommit to false + const config = await core.filesystem.loadConfig(); + if (config) { + config.autoCommit = false; + await core.filesystem.saveConfig(config); + } + }); + + it("should not auto-commit when creating task with autoCommit disabled in config", async () => { + const task: Task = { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + + await core.createTask(task); + + // Check that there are uncommitted changes + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(false); + }); + + it("should auto-commit when explicitly passing true to createTask", async () => { + const task: Task = { + id: "task-2", + title: "Test Task 2", + status: "To Do", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + + await core.createTask(task, true); + + // Check that working directory is clean (changes were committed) + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(true); + }); + + it("should not auto-commit when updating task with autoCommit disabled in config", async () => { + // First create a task with explicit commit + const task: Task = { + id: "task-3", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + await core.createTask(task, true); + + // Update the task (should not auto-commit) + await core.updateTaskFromInput("task-3", { title: "Updated Task" }); + + // Check that there are uncommitted changes + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(false); + }); + + it("should not auto-commit when archiving task with autoCommit disabled in config", async () => { + // First create a task with explicit commit + const task: Task = { + id: "task-4", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + await core.createTask(task, true); + + // Archive the task (should not auto-commit) + await core.archiveTask("task-4"); + + // Check that there are uncommitted changes + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(false); + }); + }); + + describe("Core operations with autoCommit enabled", () => { + beforeEach(async () => { + // Set autoCommit to true + const config = await core.filesystem.loadConfig(); + if (config) { + config.autoCommit = true; + await core.filesystem.saveConfig(config); + } + + // Commit the config change to start with a clean state + const git = await core.getGitOps(); + await git.addFile(join(TEST_DIR, "backlog", "config.yml")); + await git.commitChanges("Update autoCommit config for test"); + }); + + it("should auto-commit when creating task with autoCommit enabled in config", async () => { + const task: Task = { + id: "task-5", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + + await core.createTask(task); + + // Check that working directory is clean (changes were committed) + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(true); + }); + + it("should not auto-commit when explicitly passing false to createTask", async () => { + const task: Task = { + id: "task-6", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + + await core.createTask(task, false); + + // Check that there are uncommitted changes + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(false); + }); + }); + + describe("Draft operations", () => { + beforeEach(async () => { + // Set autoCommit to false + const config = await core.filesystem.loadConfig(); + if (config) { + config.autoCommit = false; + await core.filesystem.saveConfig(config); + } + }); + + it("should respect autoCommit config for draft operations", async () => { + const task: Task = { + id: "draft-1", + title: "Test Draft", + status: "Draft", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + + await core.createDraft(task); + + // Check that there are uncommitted changes + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(false); + }); + + it("should respect autoCommit config for promote draft operations", async () => { + // First create a draft with explicit commit + const task: Task = { + id: "draft-2", + title: "Test Draft", + status: "Draft", + assignee: [], + createdDate: "2025-07-07", + labels: [], + dependencies: [], + description: "Test description", + }; + await core.createDraft(task, true); + + // Promote the draft (should not auto-commit) + await core.promoteDraft("draft-2"); + + // Check that there are uncommitted changes + const git = await core.getGitOps(); + const isClean = await git.isClean(); + expect(isClean).toBe(false); + }); + }); +}); diff --git a/src/test/board-command.test.ts b/src/test/board-command.test.ts new file mode 100644 index 0000000..aada604 --- /dev/null +++ b/src/test/board-command.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Board command integration", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-board-command"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests - required for CI + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test Board Project"); + + // Disable remote operations for tests to prevent background git fetches + const config = await core.filesystem.loadConfig(); + if (config) { + config.remoteOperations = false; + await core.filesystem.saveConfig(config); + } + + // Create some test tasks + const tasksDir = core.filesystem.tasksDir; + await writeFile( + join(tasksDir, "task-1 - Test Task One.md"), + `--- +id: task-1 +title: Test Task One +status: To Do +assignee: [] +created_date: '2025-07-05' +labels: [] +dependencies: [] +--- + +## Description + +This is a test task for board testing.`, + ); + + await writeFile( + join(tasksDir, "task-2 - Test Task Two.md"), + `--- +id: task-2 +title: Test Task Two +status: In Progress +assignee: [] +created_date: '2025-07-05' +labels: [] +dependencies: [] +--- + +## Description + +This is another test task for board testing.`, + ); + }); + + afterEach(async () => { + // Wait a bit to ensure any background operations complete + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("Board loading", () => { + it("should load board without errors", async () => { + // This test verifies that the board command data loading works correctly + const tasks = await core.filesystem.listTasks(); + expect(tasks.length).toBe(2); + + // Test that we can prepare the board data without running the interactive UI + expect(() => { + const options = { + core, + initialView: "kanban" as const, + tasks: tasks.map((t) => ({ ...t, status: t.status || "" })), + }; + + // Verify board options are valid + expect(options.core).toBeDefined(); + expect(options.initialView).toBe("kanban"); + expect(options.tasks).toBeDefined(); + expect(options.tasks.length).toBe(2); + expect(options.tasks[0]?.status).toBe("To Do"); + expect(options.tasks[1]?.status).toBe("In Progress"); + }).not.toThrow(); + }); + + it("should handle empty task list gracefully", async () => { + // Remove test tasks + const tasksDir = core.filesystem.tasksDir; + await rm(join(tasksDir, "task-1 - Test Task One.md")).catch(() => {}); + await rm(join(tasksDir, "task-2 - Test Task Two.md")).catch(() => {}); + + const tasks = await core.filesystem.listTasks(); + expect(tasks.length).toBe(0); + + // Should handle empty task list properly + expect(() => { + const options = { + core, + initialView: "kanban" as const, + tasks: [], + }; + + // Verify empty task list is handled correctly + expect(options.core).toBeDefined(); + expect(options.initialView).toBe("kanban"); + expect(options.tasks).toBeDefined(); + expect(options.tasks.length).toBe(0); + }).not.toThrow(); + }); + + it("should validate ViewSwitcher initialization with kanban view", async () => { + // This specifically tests the ViewSwitcher setup that was failing + const { ViewSwitcher } = await import("../ui/view-switcher.ts"); + + const initialState = { + type: "kanban" as const, + kanbanData: { + tasks: [], + statuses: [], + isLoading: true, + }, + }; + + // This should not throw + const viewSwitcher = new ViewSwitcher({ + core, + initialState, + }); + + expect(viewSwitcher.getState().type).toBe("kanban"); + expect(viewSwitcher.getState().kanbanData?.isLoading).toBe(true); + + // Clean up to prevent background operations after test + viewSwitcher.cleanup(); + }); + + it("should handle getKanbanData method correctly", async () => { + // Test the specific method that was failing in the error + const { ViewSwitcher } = await import("../ui/view-switcher.ts"); + + const initialState = { + type: "kanban" as const, + kanbanData: { + tasks: [], + statuses: [], + isLoading: true, + }, + }; + + const viewSwitcher = new ViewSwitcher({ + core, + initialState, + }); + + try { + // Mock the getKanbanData method to avoid remote git operations + viewSwitcher.getKanbanData = async () => { + // Mock config since it's not fully available in this test environment + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || ["To Do", "In Progress"]; + return { + tasks: await core.filesystem.listTasks(), + statuses: statuses || [], + }; + }; + + // This should not throw "viewSwitcher?.getKanbanData is not a function" + await expect(async () => { + const kanbanData = await viewSwitcher.getKanbanData(); + expect(kanbanData).toBeDefined(); + expect(Array.isArray(kanbanData.tasks)).toBe(true); + expect(Array.isArray(kanbanData.statuses)).toBe(true); + }).not.toThrow(); + } finally { + // Always cleanup in finally block + viewSwitcher.cleanup(); + } + }); + }); + + describe("Cross-branch task resolution", () => { + it("should handle getLatestTaskStatesForIds with proper parameters", async () => { + // Test the function that was missing the filesystem parameter + const { getLatestTaskStatesForIds } = await import("../core/cross-branch-tasks.ts"); + + const tasks = await core.filesystem.listTasks(); + const taskIds = tasks.map((t) => t.id); + + // This should not throw "fs is not defined" + await expect(async () => { + const result = await getLatestTaskStatesForIds(core.gitOps, core.filesystem, taskIds); + expect(result).toBeInstanceOf(Map); + }).not.toThrow(); + }); + }); +}); diff --git a/src/test/board-config-simple.test.ts b/src/test/board-config-simple.test.ts new file mode 100644 index 0000000..98bb6ca --- /dev/null +++ b/src/test/board-config-simple.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Core } from "../core/backlog.ts"; +import type { BacklogConfig, Task } from "../types/index.ts"; + +describe("Board loading with checkActiveBranches config", () => { + const createTestTask = (id: string, status = "To Do"): Task => ({ + id, + title: `Test Task ${id}`, + status, + assignee: [], + createdDate: "2025-01-08", + labels: ["test"], + dependencies: [], + description: `This is test task ${id}`, + }); + + it("should respect checkActiveBranches=false in Core.loadTasks", async () => { + // Create a mock Core with controlled filesystem and git operations + const mockFs = { + loadConfig: async () => + ({ + projectName: "Test", + statuses: ["To Do", "In Progress", "Done"], + defaultStatus: "To Do", + checkActiveBranches: false, + activeBranchDays: 30, + }) as BacklogConfig, + listTasks: async () => [createTestTask("task-1")], + listDrafts: async () => [], + }; + + const mockGit = { + hasGit: async () => true, + isInsideGitRepo: async () => true, + fetch: async () => {}, + listRecentRemoteBranches: async () => [], + listRecentBranches: async () => ["main"], + listAllBranches: async () => ["main"], + listFilesInTree: async () => [], + getBranchLastModifiedMap: async () => new Map<string, Date>(), + getCurrentBranch: async () => "main", + }; + + // Track progress messages + const progressMessages: string[] = []; + + // Create a Core instance (we'll use a temporary directory) + const tempDir = join(tmpdir(), `test-board-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const core = new Core(tempDir); + + // Override the filesystem and git operations + Object.assign(core.filesystem, mockFs); + Object.assign(core.gitOps, mockGit); + + // Load tasks and capture progress messages + try { + await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Should have skipped cross-branch checking + const skipMessage = progressMessages.find((msg) => + msg.includes("Skipping cross-branch check (disabled in config)"), + ); + expect(skipMessage).toBeDefined(); + + // Should NOT have done cross-branch checking + const crossBranchMessage = progressMessages.find((msg) => msg.includes("Resolving task states across branches")); + expect(crossBranchMessage).toBeUndefined(); + } catch (_error) { + // Expected since we're using mocked operations + // The important part is checking the progress messages + } + }); + + it("should respect checkActiveBranches=true in Core.loadTasks", async () => { + // Create a mock Core with controlled filesystem and git operations + const mockFs = { + loadConfig: async () => + ({ + projectName: "Test", + statuses: ["To Do", "In Progress", "Done"], + defaultStatus: "To Do", + checkActiveBranches: true, + activeBranchDays: 30, + }) as BacklogConfig, + listTasks: async () => [createTestTask("task-1")], + listDrafts: async () => [], + }; + + const mockGit = { + hasGit: async () => true, + isInsideGitRepo: async () => true, + fetch: async () => {}, + listRecentRemoteBranches: async () => [], + listRecentBranches: async () => ["main"], + listAllBranches: async () => ["main"], + listFilesInTree: async () => [], + getBranchLastModifiedMap: async () => new Map<string, Date>(), + getCurrentBranch: async () => "main", + }; + + // Track progress messages + const progressMessages: string[] = []; + + // Create a Core instance + const tempDir = join(tmpdir(), `test-board-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const core = new Core(tempDir); + + // Override the filesystem and git operations + Object.assign(core.filesystem, mockFs); + Object.assign(core.gitOps, mockGit); + + // Load tasks and capture progress messages + try { + await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Should have done cross-branch checking + const crossBranchMessage = progressMessages.find((msg) => msg.includes("Resolving task states across branches")); + expect(crossBranchMessage).toBeDefined(); + + // Should NOT have skipped + const skipMessage = progressMessages.find((msg) => + msg.includes("Skipping cross-branch check (disabled in config)"), + ); + expect(skipMessage).toBeUndefined(); + } catch (_error) { + // Expected since we're using mocked operations + // The important part is checking the progress messages + } + }); + + it("should handle undefined checkActiveBranches (defaults to true)", async () => { + // Create a mock Core with config that doesn't specify checkActiveBranches + const mockFs = { + loadConfig: async () => + ({ + projectName: "Test", + statuses: ["To Do", "In Progress", "Done"], + defaultStatus: "To Do", + // checkActiveBranches is undefined - should default to true + }) as BacklogConfig, + listTasks: async () => [createTestTask("task-1")], + listDrafts: async () => [], + }; + + const mockGit = { + hasGit: async () => true, + isInsideGitRepo: async () => true, + fetch: async () => {}, + listRecentRemoteBranches: async () => [], + listRecentBranches: async () => ["main"], + listAllBranches: async () => ["main"], + listFilesInTree: async () => [], + getBranchLastModifiedMap: async () => new Map<string, Date>(), + getCurrentBranch: async () => "main", + }; + + // Track progress messages + const progressMessages: string[] = []; + + // Create a Core instance + const tempDir = join(tmpdir(), `test-board-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const core = new Core(tempDir); + + // Override the filesystem and git operations + Object.assign(core.filesystem, mockFs); + Object.assign(core.gitOps, mockGit); + + // Load tasks and capture progress messages + try { + await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Should default to performing cross-branch checking + const crossBranchMessage = progressMessages.find((msg) => msg.includes("Resolving task states across branches")); + expect(crossBranchMessage).toBeDefined(); + } catch (_error) { + // Expected since we're using mocked operations + } + }); +}); diff --git a/src/test/board-loading.test.ts b/src/test/board-loading.test.ts new file mode 100644 index 0000000..62cdd5f --- /dev/null +++ b/src/test/board-loading.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { BacklogConfig, Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Board Loading with checkActiveBranches", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-board-loading"); + core = new Core(TEST_DIR); + await core.filesystem.ensureBacklogStructure(); + + // Initialize git repository for testing + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize project with default config + await core.initializeProject("Test Project", false); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + describe("Core.loadTasks()", () => { + const createTestTask = (id: string, status = "To Do"): Task => ({ + id, + title: `Test Task ${id}`, + status, + assignee: [], + createdDate: "2025-01-08", + labels: ["test"], + dependencies: [], + description: `This is test task ${id}`, + }); + + beforeEach(async () => { + // Create some test tasks + await core.createTask(createTestTask("task-1", "To Do"), false); + await core.createTask(createTestTask("task-2", "In Progress"), false); + await core.createTask(createTestTask("task-3", "Done"), false); + + // Commit them to have a clean state + await $`git add .`.cwd(TEST_DIR).quiet(); + await $`git commit -m "Add test tasks"`.cwd(TEST_DIR).quiet(); + }); + + it("should load tasks with default configuration", async () => { + const tasks = await core.loadTasks(); + + expect(tasks).toHaveLength(3); + expect(tasks.find((t) => t.id === "task-1")).toBeDefined(); + expect(tasks.find((t) => t.id === "task-2")).toBeDefined(); + expect(tasks.find((t) => t.id === "task-3")).toBeDefined(); + }); + + it("should skip cross-branch checking when checkActiveBranches is false", async () => { + // Update config to disable cross-branch checking + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("Config not loaded"); + const updatedConfig: BacklogConfig = { + ...config, + checkActiveBranches: false, + }; + await core.filesystem.saveConfig(updatedConfig); + + // Track progress messages + const progressMessages: string[] = []; + const tasks = await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Verify we got tasks + expect(tasks).toHaveLength(3); + + // Verify we skipped cross-branch checking + const skipMessage = progressMessages.find((msg) => + msg.includes("Skipping cross-branch check (disabled in config)"), + ); + expect(skipMessage).toBeDefined(); + + // Verify we didn't do cross-branch checking + const crossBranchMessage = progressMessages.find((msg) => msg.includes("Resolving task states across branches")); + expect(crossBranchMessage).toBeUndefined(); + }); + + it("should perform cross-branch checking when checkActiveBranches is true", async () => { + // Update config to enable cross-branch checking (default) + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("Config not loaded"); + const updatedConfig: BacklogConfig = { + ...config, + checkActiveBranches: true, + activeBranchDays: 7, + }; + await core.filesystem.saveConfig(updatedConfig); + + // Track progress messages + const progressMessages: string[] = []; + const tasks = await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Verify we got tasks + expect(tasks).toHaveLength(3); + + // Verify we performed cross-branch checking + const crossBranchMessage = progressMessages.find((msg) => msg.includes("Resolving task states across branches")); + expect(crossBranchMessage).toBeDefined(); + + // Verify we didn't skip + const skipMessage = progressMessages.find((msg) => + msg.includes("Skipping cross-branch check (disabled in config)"), + ); + expect(skipMessage).toBeUndefined(); + }); + + it("should respect activeBranchDays configuration", async () => { + // Create a new branch with an old commit date + await $`git checkout -b old-branch`.cwd(TEST_DIR).quiet(); + await core.createTask(createTestTask("task-4", "To Do"), false); + await $`git add .`.cwd(TEST_DIR).quiet(); + + // Commit with an old date (40 days ago) + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 40); + const dateStr = oldDate.toISOString(); + await $`GIT_AUTHOR_DATE="${dateStr}" GIT_COMMITTER_DATE="${dateStr}" git commit -m "Old task"` + .cwd(TEST_DIR) + .quiet(); + + await $`git checkout main`.cwd(TEST_DIR).quiet(); + + // Set activeBranchDays to 30 (should exclude the old branch) + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("Config not loaded"); + const updatedConfig: BacklogConfig = { + ...config, + checkActiveBranches: true, + activeBranchDays: 30, + }; + await core.filesystem.saveConfig(updatedConfig); + + // Track progress messages + const progressMessages: string[] = []; + const tasks = await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // The task-4 from old branch should not be included if branch checking is working + // However, since we're in main branch, we should only see the 3 main tasks + expect(tasks).toHaveLength(3); + expect(tasks.find((t) => t.id === "task-4")).toBeUndefined(); + + // Check that branch checking happened with the right days + const _branchCheckMessage = progressMessages.find( + (msg) => msg.includes("branches") && (msg.includes("30 days") || msg.includes("from 30 days")), + ); + // The message format might vary, so we just check that some branch-related message exists + const anyBranchMessage = progressMessages.find((msg) => msg.includes("branch")); + expect(anyBranchMessage).toBeDefined(); + }); + + it("should handle cancellation via AbortSignal", async () => { + const controller = new AbortController(); + + // Cancel immediately + controller.abort(); + + // Should throw an error + await expect(core.loadTasks(undefined, controller.signal)).rejects.toThrow("Loading cancelled"); + }); + + it("should handle empty task list gracefully", async () => { + // Remove all tasks + await $`rm -rf backlog/tasks/*`.cwd(TEST_DIR).quiet(); + + const tasks = await core.loadTasks(); + expect(tasks).toEqual([]); + }); + + it("should pass progress callbacks correctly", async () => { + const progressMessages: string[] = []; + const progressCallback = mock((msg: string) => { + progressMessages.push(msg); + }); + + await core.loadTasks(progressCallback); + + // Verify callback was called + expect(progressCallback).toHaveBeenCalled(); + expect(progressMessages.length).toBeGreaterThan(0); + + // Should have some expected messages + const hasLoadingMessage = progressMessages.some( + (msg) => msg.includes("Loading") || msg.includes("Checking") || msg.includes("Skipping"), + ); + expect(hasLoadingMessage).toBe(true); + }); + }); + + describe("Config integration", () => { + it("should use default values when config properties are undefined", async () => { + // Save a minimal config without the branch-related settings + const minimalConfig: BacklogConfig = { + projectName: "Test Project", + statuses: ["To Do", "In Progress", "Done"], + defaultStatus: "To Do", + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + }; + await core.filesystem.saveConfig(minimalConfig); + + // Create a task to ensure we have something to load + await core.createTask( + { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-01-08", + labels: [], + dependencies: [], + rawContent: "Test", + }, + false, + ); + + const progressMessages: string[] = []; + const tasks = await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Should still work with defaults + expect(tasks).toBeDefined(); + expect(tasks.length).toBeGreaterThanOrEqual(0); + + // When checkActiveBranches is undefined, it defaults to true, so should perform checking + const crossBranchMessage = progressMessages.find((msg) => msg.includes("Resolving task states across branches")); + expect(crossBranchMessage).toBeDefined(); + }); + + it("should handle config with checkActiveBranches explicitly set to false", async () => { + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("Config not loaded"); + await core.filesystem.saveConfig({ + ...config, + checkActiveBranches: false, + }); + + const progressMessages: string[] = []; + await core.loadTasks((msg) => { + progressMessages.push(msg); + }); + + // Should skip cross-branch checking + const skipMessage = progressMessages.find((msg) => + msg.includes("Skipping cross-branch check (disabled in config)"), + ); + expect(skipMessage).toBeDefined(); + }); + }); +}); diff --git a/src/test/board-render.test.ts b/src/test/board-render.test.ts new file mode 100644 index 0000000..16c4b4c --- /dev/null +++ b/src/test/board-render.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import type { Task } from "../types/index.ts"; +import { type ColumnData, shouldRebuildColumns } from "../ui/board.ts"; + +function createTask(id: string, status: string): Task { + return { + id, + title: `Title for ${id}`, + status, + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: [], + description: "", + }; +} + +function makeColumns(taskIds: string[][], status: string): ColumnData[] { + return taskIds.map((ids) => ({ + status, + tasks: ids.map((id) => createTask(id, status)), + })); +} + +describe("shouldRebuildColumns", () => { + it("returns false when columns and task ordering are unchanged", () => { + const previous = makeColumns([["task-1", "task-2"]], "In Progress"); + const next = makeColumns([["task-1", "task-2"]], "In Progress"); + + expect(shouldRebuildColumns(previous, next)).toBe(false); + }); + + it("returns true when a column loses items", () => { + const previous = makeColumns([["task-1", "task-2"]], "In Progress"); + const next = makeColumns([["task-1"]], "In Progress"); + + expect(shouldRebuildColumns(previous, next)).toBe(true); + }); + + it("returns true when column task ordering changes", () => { + const previous = makeColumns([["task-1", "task-2"]], "In Progress"); + const next = makeColumns([["task-2", "task-1"]], "In Progress"); + + expect(shouldRebuildColumns(previous, next)).toBe(true); + }); + + it("returns true when number of columns changes", () => { + const previous = makeColumns([["task-1"]], "In Progress"); + const next = makeColumns([["task-1"], ["task-2"]], "In Progress"); + + expect(shouldRebuildColumns(previous, next)).toBe(true); + }); +}); diff --git a/src/test/board-ui-selection.test.ts b/src/test/board-ui-selection.test.ts new file mode 100644 index 0000000..fa663c9 --- /dev/null +++ b/src/test/board-ui-selection.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "bun:test"; +import type { Task } from "../types/index.ts"; +import { compareTaskIds } from "../utils/task-sorting.ts"; + +describe("board UI task selection", () => { + it("compareTaskIds sorts tasks numerically by ID", () => { + const tasks: Task[] = [ + { + id: "task-10", + title: "Task 10", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-2", + title: "Task 2", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-1", + title: "Task 1", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-20", + title: "Task 20", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + ]; + + const sorted = [...tasks].sort((a, b) => compareTaskIds(a.id, b.id)); + expect(sorted[0]?.id).toBe("task-1"); + expect(sorted[1]?.id).toBe("task-2"); + expect(sorted[2]?.id).toBe("task-10"); + expect(sorted[3]?.id).toBe("task-20"); + }); + + it("compareTaskIds handles decimal task IDs correctly", () => { + const tasks: Task[] = [ + { + id: "task-1.10", + title: "Task 1.10", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-1.2", + title: "Task 1.2", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-1.1", + title: "Task 1.1", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + ]; + + const sorted = [...tasks].sort((a, b) => compareTaskIds(a.id, b.id)); + expect(sorted[0]?.id).toBe("task-1.1"); + expect(sorted[1]?.id).toBe("task-1.2"); + expect(sorted[2]?.id).toBe("task-1.10"); + }); + + it("simulates board view task selection with sorted tasks", () => { + // This test simulates the bug scenario where tasks are displayed in sorted order + // but selection uses unsorted array + const unsortedTasks: Task[] = [ + { + id: "task-10", + title: "Should be third when sorted", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-2", + title: "Should be second when sorted", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-1", + title: "Should be first when sorted", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + ]; + + // Simulate the display order (sorted) + const sortedTasks = [...unsortedTasks].sort((a, b) => compareTaskIds(a.id, b.id)); + const _displayItems = sortedTasks.map((t) => `${t.id} - ${t.title}`); + + // User clicks on index 0 (expects task-1) + const selectedIndex = 0; + + // Bug: using unsorted array with sorted display index + const wrongTask = unsortedTasks[selectedIndex]; + expect(wrongTask?.id).toBe("task-10"); // Wrong! + + // Fix: using sorted array with sorted display index + const correctTask = sortedTasks[selectedIndex]; + expect(correctTask?.id).toBe("task-1"); // Correct! + }); + + it("ensures consistent ordering between display and selection", () => { + const tasks: Task[] = [ + { + id: "task-5", + title: "E", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-3", + title: "C", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-1", + title: "A", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-4", + title: "D", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + { + id: "task-2", + title: "B", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + description: "", + }, + ]; + + // Both display and selection should use the same sorted array + const sortedTasks = [...tasks].sort((a, b) => compareTaskIds(a.id, b.id)); + + // Verify each index maps to the correct task + for (let i = 0; i < sortedTasks.length; i++) { + const displayedTask = sortedTasks[i]; + const selectedTask = sortedTasks[i]; // Should be the same! + expect(selectedTask?.id).toBe(displayedTask?.id ?? ""); + } + + // Verify specific selections + expect(sortedTasks[0]?.id).toBe("task-1"); + expect(sortedTasks[1]?.id).toBe("task-2"); + expect(sortedTasks[2]?.id).toBe("task-3"); + expect(sortedTasks[3]?.id).toBe("task-4"); + expect(sortedTasks[4]?.id).toBe("task-5"); + }); +}); diff --git a/src/test/board-ui.test.ts b/src/test/board-ui.test.ts new file mode 100644 index 0000000..5fd9d24 --- /dev/null +++ b/src/test/board-ui.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import type { Task } from "../types/index.ts"; +import type { ColumnData } from "../ui/board.ts"; +import { shouldRebuildColumns } from "../ui/board.ts"; + +// Helper to create a minimal valid Task for testing +const createTestTask = (id: string, title: string, status: string): Task => ({ + id, + title, + status, + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: [], +}); + +describe("Board TUI Logic", () => { + describe("shouldRebuildColumns", () => { + it("should return true if column counts differ", () => { + const current: ColumnData[] = [{ status: "ToDo", tasks: [] }]; + const next: ColumnData[] = [ + { status: "ToDo", tasks: [] }, + { status: "Done", tasks: [] }, + ]; + expect(shouldRebuildColumns(current, next)).toBe(true); + }); + + it("should return true if statuses differ", () => { + const current: ColumnData[] = [{ status: "ToDo", tasks: [] }]; + const next: ColumnData[] = [{ status: "Done", tasks: [] }]; + expect(shouldRebuildColumns(current, next)).toBe(true); + }); + + it("should return true if task counts differ", () => { + const task1 = createTestTask("1", "t1", "ToDo"); + const current: ColumnData[] = [{ status: "ToDo", tasks: [task1] }]; + const next: ColumnData[] = [{ status: "ToDo", tasks: [] }]; + expect(shouldRebuildColumns(current, next)).toBe(true); + }); + + it("should return true if task IDs differ (order change)", () => { + const task1 = createTestTask("1", "t1", "ToDo"); + const task2 = createTestTask("2", "t2", "ToDo"); + + const current: ColumnData[] = [{ status: "ToDo", tasks: [task1, task2] }]; + const next: ColumnData[] = [{ status: "ToDo", tasks: [task2, task1] }]; + expect(shouldRebuildColumns(current, next)).toBe(true); + }); + + it("should return false if columns and tasks are identical", () => { + const task1 = createTestTask("1", "t1", "ToDo"); + const task2 = createTestTask("2", "t2", "ToDo"); + + const current: ColumnData[] = [{ status: "ToDo", tasks: [task1, task2] }]; + const next: ColumnData[] = [{ status: "ToDo", tasks: [task1, task2] }]; + expect(shouldRebuildColumns(current, next)).toBe(false); + }); + }); +}); diff --git a/src/test/board.test.ts b/src/test/board.test.ts new file mode 100644 index 0000000..48060a7 --- /dev/null +++ b/src/test/board.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildKanbanStatusGroups, exportKanbanBoardToFile } from "../board.ts"; +import type { Task } from "../types/index.ts"; + +describe("exportKanbanBoardToFile", () => { + it("creates file and overwrites board content", async () => { + const dir = await mkdtemp(join(tmpdir(), "board-export-")); + const file = join(dir, "README.md"); + const tasks: Task[] = [ + { + id: "task-1", + title: "First", + status: "To Do", + assignee: [], + createdDate: "", + labels: [], + dependencies: [], + }, + ]; + + await exportKanbanBoardToFile(tasks, ["To Do"], file, "TestProject"); + const initial = await Bun.file(file).text(); + expect(initial).toContain("TASK-1"); + expect(initial).toContain("# Kanban Board Export (powered by Backlog.md)"); + expect(initial).toContain("Project: TestProject"); + + await exportKanbanBoardToFile(tasks, ["To Do"], file, "TestProject"); + const second = await Bun.file(file).text(); + const occurrences = second.split("TASK-1").length - 1; + expect(occurrences).toBe(1); // Should overwrite, not append + + await rm(dir, { recursive: true, force: true }); + }); + + it("sorts all columns by updatedDate descending, then by ID", async () => { + const dir = await mkdtemp(join(tmpdir(), "board-export-")); + const file = join(dir, "README.md"); + const tasks: Task[] = [ + { + id: "task-1", + title: "First", + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + updatedDate: "2025-01-08 10:00", + labels: [], + dependencies: [], + }, + { + id: "task-3", + title: "Third", + status: "To Do", + assignee: [], + createdDate: "2025-01-03", + updatedDate: "2025-01-09 10:00", + labels: [], + dependencies: [], + }, + { + id: "task-2", + title: "Second", + status: "Done", + assignee: [], + createdDate: "2025-01-02", + updatedDate: "2025-01-10 12:00", + labels: [], + dependencies: [], + }, + { + id: "task-4", + title: "Fourth", + status: "Done", + assignee: [], + createdDate: "2025-01-04", + updatedDate: "2025-01-05 10:00", + labels: [], + dependencies: [], + }, + { + id: "task-5", + title: "Fifth", + status: "Done", + assignee: [], + createdDate: "2025-01-05", + updatedDate: "2025-01-10 14:00", + labels: [], + dependencies: [], + }, + ]; + + await exportKanbanBoardToFile(tasks, ["To Do", "Done"], file, "TestProject"); + const content = await Bun.file(file).text(); + + // Split content into lines for easier testing + const lines = content.split("\n"); + + // Find rows containing our tasks (updated to match uppercase format) + const task1Row = lines.find((line) => line.includes("TASK-1")); + const task3Row = lines.find((line) => line.includes("TASK-3")); + const task2Row = lines.find((line) => line.includes("TASK-2")); + const task4Row = lines.find((line) => line.includes("TASK-4")); + const task5Row = lines.find((line) => line.includes("TASK-5")); + + if (!task1Row || !task2Row || !task3Row || !task4Row || !task5Row) { + throw new Error("Expected task rows not found in exported board content"); + } + + // Check that To Do tasks are ordered by updatedDate (task-3 has newer date than task-1) + const task3Index = lines.indexOf(task3Row); + const task1Index = lines.indexOf(task1Row); + expect(task3Index).toBeLessThan(task1Index); + + // Check that Done tasks are ordered by updatedDate + const task5Index = lines.indexOf(task5Row); + const task2Index = lines.indexOf(task2Row); + const task4Index = lines.indexOf(task4Row); + expect(task5Index).toBeLessThan(task2Index); // task-5 before task-2 + expect(task2Index).toBeLessThan(task4Index); // task-2 before task-4 + + await rm(dir, { recursive: true, force: true }); + }); + + it("formats tasks with new styling rules", async () => { + const dir = await mkdtemp(join(tmpdir(), "board-export-")); + const file = join(dir, "README.md"); + const tasks: Task[] = [ + { + id: "task-204", + title: "Test Task", + status: "To Do", + assignee: ["alice", "bob"], + createdDate: "2025-01-01", + labels: ["enhancement", "ui"], + dependencies: [], + }, + { + id: "task-205", + title: "Subtask Example", + status: "To Do", + assignee: [], + createdDate: "2025-01-02", + labels: [], + dependencies: [], + parentTaskId: "task-204", + }, + ]; + + await exportKanbanBoardToFile(tasks, ["To Do"], file, "TestProject"); + const content = await Bun.file(file).text(); + + // Check uppercase task IDs + expect(content).toContain("**TASK-204**"); + expect(content).toContain("└─ **TASK-205**"); + + // Check assignee formatting with @ prefix + expect(content).toContain("[@alice, @bob]"); + + // Check label formatting with # prefix and italics + expect(content).toContain("*#enhancement #ui*"); + + // Check that tasks without assignees/labels don't have empty brackets + expect(content).not.toContain("[]"); + expect(content).not.toContain("**TASK-205** - Subtask Example<br>"); + + await rm(dir, { recursive: true, force: true }); + }); + + it("handles assignees with existing @ symbols correctly", async () => { + const dir = await mkdtemp(join(tmpdir(), "board-export-")); + const file = join(dir, "README.md"); + const tasks: Task[] = [ + { + id: "task-100", + title: "Test @ Handling", + status: "To Do", + assignee: ["@claude", "alice", "@bob"], + createdDate: "2025-01-01", + labels: [], + dependencies: [], + }, + ]; + + await exportKanbanBoardToFile(tasks, ["To Do"], file, "TestProject"); + const content = await Bun.file(file).text(); + + // Check that we don't get double @ symbols + expect(content).toContain("[@claude, @alice, @bob]"); + expect(content).not.toContain("@@claude"); + expect(content).not.toContain("@@bob"); + + await rm(dir, { recursive: true, force: true }); + }); +}); + +describe("buildKanbanStatusGroups", () => { + it("returns configured statuses even when there are no tasks", () => { + const { orderedStatuses, groupedTasks } = buildKanbanStatusGroups([], ["To Do", "In Progress", "Done"]); + expect(orderedStatuses).toEqual(["To Do", "In Progress", "Done"]); + expect(groupedTasks.get("To Do")).toEqual([]); + expect(groupedTasks.get("In Progress")).toEqual([]); + expect(groupedTasks.get("Done")).toEqual([]); + }); + + it("appends unknown statuses from tasks after configured ones", () => { + const tasks: Task[] = [ + { + id: "task-1", + title: "Blocked Task", + status: "Blocked", + assignee: [], + createdDate: "2025-01-02", + labels: [], + dependencies: [], + }, + { + id: "task-2", + title: "Lowercase todo", + status: "to do", + assignee: [], + createdDate: "2025-01-03", + labels: [], + dependencies: [], + }, + ]; + + const { orderedStatuses, groupedTasks } = buildKanbanStatusGroups(tasks, ["To Do"]); + expect(orderedStatuses).toEqual(["To Do", "Blocked"]); + expect(groupedTasks.get("To Do")?.map((t) => t.id)).toEqual(["task-2"]); + expect(groupedTasks.get("Blocked")?.map((t) => t.id)).toEqual(["task-1"]); + }); +}); diff --git a/src/test/build.test.ts b/src/test/build.test.ts new file mode 100644 index 0000000..6613941 --- /dev/null +++ b/src/test/build.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { platform } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const isWindows = platform() === "win32"; +const executableName = isWindows ? "backlog.exe" : "backlog"; + +describe("CLI packaging", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-build"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should build and run compiled executable", async () => { + const OUTFILE = join(TEST_DIR, executableName); + + // Read version from package.json + const packageJson = await Bun.file("package.json").json(); + const version = packageJson.version; + + try { + await $`bun build src/cli.ts --compile --define __EMBEDDED_VERSION__="\"${version}\"" --outfile ${OUTFILE}`.quiet(); + } catch (error: unknown) { + // Skip test if build fails due to cross-filesystem issues (e.g., virtiofs) + // This is environment-specific and doesn't indicate a code problem + const err = error as { stderr?: { toString(): string } }; + const errorMsg = err?.stderr?.toString() || String(error); + if (errorMsg.includes("failed to rename") || errorMsg.includes("ENOENT")) { + console.warn("Skipping build test due to cross-filesystem limitation"); + return; + } + throw error; + } + + const helpResult = await $`${OUTFILE} --help`.quiet(); + const helpOutput = helpResult.stdout.toString(); + expect(helpOutput).toContain("Backlog.md - Project management CLI"); + + // Also test version command + const versionResult = await $`${OUTFILE} --version`.quiet(); + const versionOutput = versionResult.stdout.toString().trim(); + expect(versionOutput).toBe(version); + }); +}); diff --git a/src/test/bun-options.test.ts b/src/test/bun-options.test.ts new file mode 100644 index 0000000..cb49bef --- /dev/null +++ b/src/test/bun-options.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +describe("BUN_OPTIONS environment variable handling", () => { + let originalBunOptions: string | undefined; + + beforeEach(() => { + // Save original BUN_OPTIONS value + originalBunOptions = process.env.BUN_OPTIONS; + }); + + afterEach(() => { + // Restore original BUN_OPTIONS value + if (originalBunOptions !== undefined) { + process.env.BUN_OPTIONS = originalBunOptions; + } else { + delete process.env.BUN_OPTIONS; + } + }); + + it("should temporarily isolate BUN_OPTIONS during CLI parsing", () => { + // Set BUN_OPTIONS to simulate the conflict scenario from GitHub issue #168 + process.env.BUN_OPTIONS = "--bun --silent"; + + // Save original value (simulating CLI startup) + const savedBunOptions = process.env.BUN_OPTIONS; + + // Clear during CLI parsing to prevent Commander.js conflicts + if (process.env.BUN_OPTIONS) { + delete process.env.BUN_OPTIONS; + } + + // Verify BUN_OPTIONS is cleared during parsing + expect(process.env.BUN_OPTIONS).toBeUndefined(); + + // Restore after parsing (simulating CLI completion) + if (savedBunOptions) { + process.env.BUN_OPTIONS = savedBunOptions; + } + + // Verify BUN_OPTIONS is restored for subsequent commands + expect(process.env.BUN_OPTIONS).toBe("--bun --silent"); + }); + + it("should handle missing BUN_OPTIONS gracefully", () => { + // Ensure BUN_OPTIONS is not set + delete process.env.BUN_OPTIONS; + + // Save original value (should be undefined) + const savedBunOptions = process.env.BUN_OPTIONS; + + // Execute the CLI initialization logic + if (process.env.BUN_OPTIONS) { + delete process.env.BUN_OPTIONS; + } + + // Verify no error occurs and BUN_OPTIONS remains undefined + expect(process.env.BUN_OPTIONS).toBeUndefined(); + + // Restore logic should not crash + if (savedBunOptions) { + process.env.BUN_OPTIONS = savedBunOptions; + } + + // Should still be undefined + expect(process.env.BUN_OPTIONS).toBeUndefined(); + }); + + it("should preserve BUN_OPTIONS for subsequent command usage", () => { + const testValues = ["--bun", "--config=./bunfig.toml --silent", "--env-file=.env.local"]; + + for (const value of testValues) { + // Set BUN_OPTIONS + process.env.BUN_OPTIONS = value; + + // Simulate the CLI save/clear/restore cycle + const savedBunOptions = process.env.BUN_OPTIONS; + + // Clear during parsing + if (process.env.BUN_OPTIONS) { + delete process.env.BUN_OPTIONS; + } + + // Verify it's cleared during parsing + expect(process.env.BUN_OPTIONS).toBeUndefined(); + + // Restore after parsing + if (savedBunOptions) { + process.env.BUN_OPTIONS = savedBunOptions; + } + + // Verify it's available for subsequent commands + expect(process.env.BUN_OPTIONS).toBe(value); + } + }); +}); diff --git a/src/test/checklist.test.ts b/src/test/checklist.test.ts new file mode 100644 index 0000000..993d57d --- /dev/null +++ b/src/test/checklist.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test"; +import { + alignAcceptanceCriteria, + CHECKBOX_PATTERNS, + type ChecklistItem, + extractAndFormatAcceptanceCriteria, + formatChecklist, + formatChecklistItem, + parseCheckboxLine, + parseCheckboxLines, +} from "../ui/checklist.ts"; + +describe("Checklist utilities", () => { + describe("CHECKBOX_PATTERNS", () => { + test("should match checked checkbox lines", () => { + const testCases = [ + "- [x] Checked item", + " - [x] Indented checked item", + "- [x] Item with extra spaces", + "-[x] No space after dash", + ]; + + for (const testCase of testCases) { + expect(CHECKBOX_PATTERNS.CHECKBOX_LINE.test(testCase)).toBe(true); + } + }); + + test("should match unchecked checkbox lines", () => { + const testCases = [ + "- [ ] Unchecked item", + " - [ ] Indented unchecked item", + "- [ ] Item with extra spaces", + "-[ ] No space after dash", + ]; + + for (const testCase of testCases) { + expect(CHECKBOX_PATTERNS.CHECKBOX_LINE.test(testCase)).toBe(true); + } + }); + + test("should not match non-checkbox lines", () => { + const testCases = [ + "Regular text", + "- Regular bullet point", + "- [ Missing closing bracket", + "- [y] Invalid checkbox state", + "[x] Missing dash prefix", + "## Header", + ]; + + for (const testCase of testCases) { + expect(CHECKBOX_PATTERNS.CHECKBOX_LINE.test(testCase)).toBe(false); + } + }); + + test("should match checkbox prefix pattern", () => { + expect(CHECKBOX_PATTERNS.CHECKBOX_PREFIX.test("- [x] ")).toBe(true); + expect(CHECKBOX_PATTERNS.CHECKBOX_PREFIX.test("- [ ] ")).toBe(true); + expect(CHECKBOX_PATTERNS.CHECKBOX_PREFIX.test("-[x] ")).toBe(true); + expect(CHECKBOX_PATTERNS.CHECKBOX_PREFIX.test("- [x]")).toBe(true); + }); + }); + + describe("parseCheckboxLine", () => { + test("should parse checked checkbox lines", () => { + const result = parseCheckboxLine("- [x] This is checked"); + expect(result).toEqual({ + text: "This is checked", + checked: true, + }); + }); + + test("should parse unchecked checkbox lines", () => { + const result = parseCheckboxLine("- [ ] This is unchecked"); + expect(result).toEqual({ + text: "This is unchecked", + checked: false, + }); + }); + + test("should handle indented checkboxes", () => { + const result = parseCheckboxLine(" - [x] Indented checkbox"); + expect(result).toEqual({ + text: "Indented checkbox", + checked: true, + }); + }); + + test("should handle extra whitespace", () => { + const result = parseCheckboxLine("- [x] Extra spaces around text "); + expect(result).toEqual({ + text: "Extra spaces around text", + checked: true, + }); + }); + + test("should return null for non-checkbox lines", () => { + expect(parseCheckboxLine("Regular text")).toBe(null); + expect(parseCheckboxLine("- Regular bullet")).toBe(null); + expect(parseCheckboxLine("## Header")).toBe(null); + }); + }); + + describe("formatChecklistItem", () => { + test("should format checked item with default options", () => { + const item: ChecklistItem = { text: "Test item", checked: true }; + const result = formatChecklistItem(item); + expect(result).toBe(" [x] Test item"); + }); + + test("should format unchecked item with default options", () => { + const item: ChecklistItem = { text: "Test item", checked: false }; + const result = formatChecklistItem(item); + expect(result).toBe(" [ ] Test item"); + }); + + test("should use custom symbols", () => { + const item: ChecklistItem = { text: "Test item", checked: true }; + const result = formatChecklistItem(item, { + checkedSymbol: "β˜‘", + uncheckedSymbol: "☐", + }); + expect(result).toBe(" β˜‘ Test item"); + }); + + test("should use custom padding", () => { + const item: ChecklistItem = { text: "Test item", checked: false }; + const result = formatChecklistItem(item, { + padding: " ", + }); + expect(result).toBe(" [ ] Test item"); + }); + }); + + describe("parseCheckboxLines", () => { + test("should parse multiple checkbox lines", () => { + const text = `- [x] First item +- [ ] Second item +- [x] Third item`; + + const result = parseCheckboxLines(text); + expect(result).toEqual([ + { text: "First item", checked: true }, + { text: "Second item", checked: false }, + { text: "Third item", checked: true }, + ]); + }); + + test("should ignore non-checkbox lines", () => { + const text = `- [x] Checkbox item +Regular text +- [ ] Another checkbox +## Header`; + + const result = parseCheckboxLines(text); + expect(result).toEqual([ + { text: "Checkbox item", checked: true }, + { text: "Another checkbox", checked: false }, + ]); + }); + }); + + describe("formatChecklist", () => { + test("should format multiple items consistently", () => { + const items: ChecklistItem[] = [ + { text: "First item", checked: true }, + { text: "Second item", checked: false }, + { text: "Third item", checked: true }, + ]; + + const result = formatChecklist(items); + expect(result).toEqual([" [x] First item", " [ ] Second item", " [x] Third item"]); + }); + }); + + describe("alignAcceptanceCriteria", () => { + test("should align checkbox items consistently", () => { + const criteriaSection = `- [x] First criterion +- [ ] Second criterion +- [x] Third criterion`; + + const result = alignAcceptanceCriteria(criteriaSection); + expect(result).toEqual([" [x] First criterion", " [ ] Second criterion", " [x] Third criterion"]); + }); + + test("should handle mixed content with consistent padding", () => { + const criteriaSection = `- [x] Checkbox item +Regular note +- [ ] Another checkbox`; + + const result = alignAcceptanceCriteria(criteriaSection); + expect(result).toEqual([" [x] Checkbox item", " Regular note", " [ ] Another checkbox"]); + }); + + test("should handle empty or whitespace-only lines", () => { + const criteriaSection = `- [x] First item + +- [ ] Second item + +- [x] Third item`; + + const result = alignAcceptanceCriteria(criteriaSection); + expect(result).toEqual([" [x] First item", " [ ] Second item", " [x] Third item"]); + }); + }); + + describe("extractAndFormatAcceptanceCriteria", () => { + test("should extract and format acceptance criteria from markdown", () => { + const content = `## Description +Some description here. + +## Acceptance Criteria +- [x] First criterion is done +- [ ] Second criterion pending +- [x] Third criterion complete + +## Implementation Notes +Some notes here.`; + + const result = extractAndFormatAcceptanceCriteria(content); + expect(result).toEqual([ + " [x] First criterion is done", + " [ ] Second criterion pending", + " [x] Third criterion complete", + ]); + }); + + test("should return empty array when no acceptance criteria section exists", () => { + const content = `## Description +Some description here. + +## Implementation Notes +Some notes here.`; + + const result = extractAndFormatAcceptanceCriteria(content); + expect(result).toEqual([]); + }); + + test("should handle case-insensitive section headers", () => { + const content = `## acceptance criteria +- [x] Test item +- [ ] Another test`; + + const result = extractAndFormatAcceptanceCriteria(content); + expect(result).toEqual([" [x] Test item", " [ ] Another test"]); + }); + }); + + describe("alignment consistency", () => { + test("all formatted items should start at the same column", () => { + const items: ChecklistItem[] = [ + { text: "Short", checked: true }, + { text: "Much longer item text here", checked: false }, + { text: "Medium length item", checked: true }, + ]; + + const formatted = formatChecklist(items); + + // All items should start with the same padding + for (const line of formatted) { + expect(line.startsWith(" ")).toBe(true); + expect(line.charAt(0)).toBe(" "); + } + + // All checkbox positions should be the same + const checkboxPositions = formatted.map((line) => line.indexOf("[")); + const firstPosition = checkboxPositions[0] ?? -1; + for (const position of checkboxPositions) { + expect(position).toBe(firstPosition); + } + }); + }); +}); diff --git a/src/test/claude-agent-install.test.ts b/src/test/claude-agent-install.test.ts new file mode 100644 index 0000000..0467e09 --- /dev/null +++ b/src/test/claude-agent-install.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { installClaudeAgent } from "../agent-instructions.ts"; +import { CLAUDE_AGENT_CONTENT } from "../constants/index.ts"; +import { createUniqueTestDir } from "./test-utils.ts"; + +describe("installClaudeAgent", () => { + let TEST_PROJECT: string; + + beforeEach(async () => { + TEST_PROJECT = createUniqueTestDir("test-claude-agent"); + await rm(TEST_PROJECT, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_PROJECT, { recursive: true }); + }); + + afterEach(async () => { + await rm(TEST_PROJECT, { recursive: true, force: true }).catch(() => {}); + }); + + it("creates .claude/agents directory in project root if it doesn't exist", async () => { + await installClaudeAgent(TEST_PROJECT); + + const agentDir = join(TEST_PROJECT, ".claude", "agents"); + expect(existsSync(agentDir)).toBe(true); + }); + + it("writes the project-manager-backlog.md file with correct content", async () => { + await installClaudeAgent(TEST_PROJECT); + + const agentPath = join(TEST_PROJECT, ".claude", "agents", "project-manager-backlog.md"); + const content = await Bun.file(agentPath).text(); + + expect(content).toBe(CLAUDE_AGENT_CONTENT); + expect(content).toContain("name: project-manager-backlog"); + expect(content).toContain( + "You are an expert project manager specializing in the backlog.md task management system", + ); + }); + + it("overwrites existing agent file", async () => { + const agentDir = join(TEST_PROJECT, ".claude", "agents"); + await mkdir(agentDir, { recursive: true }); + + const agentPath = join(TEST_PROJECT, ".claude", "agents", "project-manager-backlog.md"); + await Bun.write(agentPath, "Old content"); + + await installClaudeAgent(TEST_PROJECT); + + const content = await Bun.file(agentPath).text(); + expect(content).toBe(CLAUDE_AGENT_CONTENT); + expect(content).not.toContain("Old content"); + }); + + it("works with different project paths", async () => { + const subProjectPath = join(TEST_PROJECT, "subproject"); + await mkdir(subProjectPath, { recursive: true }); + + await installClaudeAgent(subProjectPath); + + const agentPath = join(subProjectPath, ".claude", "agents", "project-manager-backlog.md"); + expect(existsSync(agentPath)).toBe(true); + }); +}); diff --git a/src/test/cleanup.test.ts b/src/test/cleanup.test.ts new file mode 100644 index 0000000..53f5357 --- /dev/null +++ b/src/test/cleanup.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Cleanup functionality", () => { + let core: Core; + + // Sample data + const sampleTask: Task = { + id: "task-1", + title: "Test Task", + status: "Done", + assignee: [], + createdDate: "2025-07-21", + labels: [], + dependencies: [], + rawContent: "Test task description", + }; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cleanup"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project + core = new Core(TEST_DIR); + await core.initializeProject("Cleanup Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("Core functionality", () => { + it("should create completed directory in backlog structure", async () => { + await core.filesystem.ensureBacklogStructure(); + expect(core.filesystem.completedDir).toBe(join(TEST_DIR, "backlog", "completed")); + }); + + it("should move Done task to completed folder", async () => { + // Create a task + await core.createTask(sampleTask, false); + + // Verify task exists in active tasks + const activeTasks = await core.filesystem.listTasks(); + expect(activeTasks).toHaveLength(1); + expect(activeTasks[0]?.id).toBe("task-1"); + + // Move to completed + const success = await core.completeTask("task-1", false); + expect(success).toBe(true); + + // Verify task is no longer in active tasks + const activeTasksAfter = await core.filesystem.listTasks(); + expect(activeTasksAfter).toHaveLength(0); + + // Verify task is in completed tasks + const completedTasks = await core.filesystem.listCompletedTasks(); + expect(completedTasks).toHaveLength(1); + expect(completedTasks[0]?.id).toBe("task-1"); + expect(completedTasks[0]?.title).toBe("Test Task"); + }); + }); + + describe("getDoneTasksByAge", () => { + it("should filter Done tasks by age", async () => { + // Create old Done task (7 days ago) + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 7); + const oldTask: Task = { + ...sampleTask, + title: "Old Done Task", + createdDate: oldDate.toISOString().split("T")[0] as string, + updatedDate: oldDate.toISOString().split("T")[0] as string, + rawContent: "Old task description", + }; + await core.createTask(oldTask, false); + + // Create recent Done task (1 day ago) + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 1); + const recentTask: Task = { + ...sampleTask, + id: "task-2", + title: "Recent Done Task", + createdDate: recentDate.toISOString().split("T")[0] as string, + updatedDate: recentDate.toISOString().split("T")[0] as string, + rawContent: "Recent task description", + }; + await core.createTask(recentTask, false); + + // Create In Progress task + const activeTask: Task = { + ...sampleTask, + id: "task-3", + title: "Active Task", + status: "In Progress", + createdDate: oldDate.toISOString().split("T")[0] as string, + rawContent: "Active task description", + }; + await core.createTask(activeTask, false); + + // Get tasks older than 3 days + const oldTasks = await core.getDoneTasksByAge(3); + expect(oldTasks).toHaveLength(1); + expect(oldTasks[0]?.id).toBe("task-1"); + + // Get tasks older than 0 days (should include recent task too) + const allDoneTasks = await core.getDoneTasksByAge(0); + expect(allDoneTasks).toHaveLength(2); + }); + + it("should handle tasks without dates", async () => { + const task: Task = { + ...sampleTask, + title: "Task Without Date", + createdDate: "", + rawContent: "Task description", + }; + await core.createTask(task, false); + + const oldTasks = await core.getDoneTasksByAge(1); + expect(oldTasks).toHaveLength(0); // Should not include tasks without valid dates + }); + + it("should use updatedDate over createdDate when available", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 1); + + const task: Task = { + id: "task-1", + title: "Task with Both Dates", + status: "Done", + assignee: [], + createdDate: oldDate.toISOString().split("T")[0] as string, + updatedDate: recentDate.toISOString().split("T")[0] as string, + labels: [], + dependencies: [], + rawContent: "Task description", + }; + await core.createTask(task, false); + + // Should use updatedDate (recent) not createdDate (old) + const oldTasks = await core.getDoneTasksByAge(5); + expect(oldTasks).toHaveLength(0); // updatedDate is recent, so not old enough + + const recentTasks = await core.getDoneTasksByAge(0); + expect(recentTasks).toHaveLength(1); // updatedDate makes it recent + }); + }); + + describe("Error handling", () => { + it("should handle non-existent task gracefully", async () => { + const success = await core.completeTask("non-existent", false); + expect(success).toBe(false); + }); + + it("should return empty array for listCompletedTasks when no completed tasks exist", async () => { + const completedTasks = await core.filesystem.listCompletedTasks(); + expect(completedTasks).toHaveLength(0); + }); + }); +}); diff --git a/src/test/cli-agents.test.ts b/src/test/cli-agents.test.ts new file mode 100644 index 0000000..6bb9f42 --- /dev/null +++ b/src/test/cli-agents.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI agents command", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-agents-cli"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo first + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core + const core = new Core(TEST_DIR); + await core.initializeProject("Agents Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should show help when no options are provided", async () => { + const result = await $`bun ${cliPath} agents`.cwd(TEST_DIR).quiet(); + + expect(result.exitCode).toBe(0); + }); + + it("should show help text with agents --help", async () => { + const result = await $`bun ${cliPath} agents --help`.cwd(TEST_DIR).quiet(); + + expect(result.exitCode).toBe(0); + }); + + it("should update selected agent instruction files", async () => { + // Test the underlying functionality directly instead of the interactive CLI + const core = new Core(TEST_DIR); + const { addAgentInstructions } = await import("../index.ts"); + + // Update AGENTS.md file + await expect(async () => { + await addAgentInstructions(TEST_DIR, core.gitOps, ["AGENTS.md"]); + }).not.toThrow(); + + // Verify the file was created + const agents = Bun.file(join(TEST_DIR, "AGENTS.md")); + expect(await agents.exists()).toBe(true); + const content = await agents.text(); + expect(content).toContain("Backlog.md"); + }); + + it("should handle user cancellation gracefully", async () => { + // Test that the function handles empty selection (cancellation) gracefully + const core = new Core(TEST_DIR); + const { addAgentInstructions } = await import("../index.ts"); + + // Test with empty array (simulates user cancellation) + await expect(async () => { + await addAgentInstructions(TEST_DIR, core.gitOps, []); + }).not.toThrow(); + + // No files should be created when selection is empty + const agents = Bun.file(join(TEST_DIR, "AGENTS.md")); + expect(await agents.exists()).toBe(false); + }); + + it("should fail when not in a backlog project", async () => { + // Use OS temp directory to ensure complete isolation from project + const tempDir = await import("node:os").then((os) => os.tmpdir()); + const nonBacklogDir = join(tempDir, `test-non-backlog-${Date.now()}-${Math.random().toString(36).substring(7)}`); + + // Ensure clean state first + await rm(nonBacklogDir, { recursive: true, force: true }).catch(() => {}); + + // Create a temporary directory that's not a backlog project + await mkdir(nonBacklogDir, { recursive: true }); + + // Initialize git repo + await $`git init`.cwd(nonBacklogDir).quiet(); + await $`git config user.name "Test User"`.cwd(nonBacklogDir).quiet(); + await $`git config user.email test@example.com`.cwd(nonBacklogDir).quiet(); + + const result = await $`bun ${cliPath} agents --update-instructions`.cwd(nonBacklogDir).nothrow().quiet(); + + expect(result.exitCode).toBe(1); + + // Cleanup + await rm(nonBacklogDir, { recursive: true, force: true }).catch(() => {}); + }); + + it("should update multiple selected files", async () => { + // Test updating multiple agent instruction files + const core = new Core(TEST_DIR); + const { addAgentInstructions } = await import("../index.ts"); + + // Test updating multiple files + await expect(async () => { + await addAgentInstructions(TEST_DIR, core.gitOps, ["AGENTS.md", "CLAUDE.md"]); + }).not.toThrow(); + + // Verify both files were created + const agents2 = Bun.file(join(TEST_DIR, "AGENTS.md")); + const claudeMd = Bun.file(join(TEST_DIR, "CLAUDE.md")); + + expect(await agents2.exists()).toBe(true); + expect(await claudeMd.exists()).toBe(true); + + const agentsContent = await agents2.text(); + const claudeContent = await claudeMd.text(); + + expect(agentsContent).toContain("Backlog.md"); + expect(claudeContent).toContain("Backlog.md"); + }); + + it("should update existing files correctly", async () => { + // Test that existing files are updated correctly (idempotent) + const core = new Core(TEST_DIR); + const { addAgentInstructions } = await import("../index.ts"); + + // First, create a file + await addAgentInstructions(TEST_DIR, core.gitOps, ["AGENTS.md"]); + + const agents3 = Bun.file(join(TEST_DIR, "AGENTS.md")); + expect(await agents3.exists()).toBe(true); + const _originalContent = await agents3.text(); + + // Update it again - should be idempotent + await expect(async () => { + await addAgentInstructions(TEST_DIR, core.gitOps, ["AGENTS.md"]); + }).not.toThrow(); + + // File should still exist and have consistent content + expect(await agents3.exists()).toBe(true); + const updatedContent = await agents3.text(); + expect(updatedContent).toContain("Backlog.md"); + // Should be idempotent - content should be similar (may have minor differences) + expect(updatedContent.length).toBeGreaterThan(0); + }); +}); diff --git a/src/test/cli-board-integration.test.ts b/src/test/cli-board-integration.test.ts new file mode 100644 index 0000000..9ca2245 --- /dev/null +++ b/src/test/cli-board-integration.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI Board Integration", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli-board-integration"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests - required for CI + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test CLI Board Project"); + + // Disable remote operations for tests to prevent background git fetches + const config = await core.filesystem.loadConfig(); + if (config) { + config.remoteOperations = false; + await core.filesystem.saveConfig(config); + } + + // Create test tasks + const tasksDir = core.filesystem.tasksDir; + await writeFile( + join(tasksDir, "task-1 - Board Test Task.md"), + `--- +id: task-1 +title: Board Test Task +status: To Do +assignee: [] +created_date: '2025-07-05' +labels: [] +dependencies: [] +--- + +## Description + +Test task for board CLI integration.`, + ); + }); + + afterEach(async () => { + // Wait a bit to ensure any background operations from listTasksWithMetadata complete + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should handle board command logic without crashing", async () => { + // Test the main board loading logic that was failing + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + // Load tasks like the CLI does + const [localTasks, _remoteTasks] = await Promise.all([ + core.listTasksWithMetadata(), + // Remote tasks would normally be loaded but will fail in test env - that's OK + Promise.resolve([]), + ]); + + // Verify basic functionality + expect(localTasks.length).toBe(1); + expect(localTasks[0]?.id).toBe("task-1"); + expect(localTasks[0]?.status).toBe("To Do"); + expect(statuses).toContain("To Do"); + + // Test that we can create the task map + const tasksById = new Map(localTasks.map((t) => [t.id, { ...t, source: "local" as const }])); + expect(tasksById.size).toBe(1); + expect(tasksById.get("task-1")?.title).toBe("Board Test Task"); + }); + + it("should properly handle cross-branch task resolution", async () => { + // Test the function that was missing filesystem parameter + const { getLatestTaskStatesForIds } = await import("../core/cross-branch-tasks.ts"); + + const tasks = await core.filesystem.listTasks(); + const taskIds = tasks.map((t) => t.id); + + // This should not throw "fs is not defined" or parameter errors + const result = await getLatestTaskStatesForIds(core.gitOps, core.filesystem, taskIds); + + expect(result).toBeInstanceOf(Map); + // The result may be empty in test environment without branches, but it shouldn't crash + }); + + it("should create ViewSwitcher with kanban view successfully", async () => { + // Test the specific ViewSwitcher initialization that was failing + const { ViewSwitcher } = await import("../ui/view-switcher.ts"); + + const initialState = { + type: "kanban" as const, + kanbanData: { + tasks: [], + statuses: [], + isLoading: true, + }, + }; + + // This should not throw + const viewSwitcher = new ViewSwitcher({ + core, + initialState, + }); + + // Immediately cleanup to prevent background operations + viewSwitcher.cleanup(); + + // Verify the ViewSwitcher has the required methods + expect(typeof viewSwitcher.getKanbanData).toBe("function"); + expect(typeof viewSwitcher.switchView).toBe("function"); + expect(typeof viewSwitcher.isKanbanReady).toBe("function"); + + // Mock the getKanbanData method to avoid remote git operations + viewSwitcher.getKanbanData = async () => { + // Mock config since it's not fully available in this test environment + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || ["To Do", "In Progress"]; + return { + tasks: await core.filesystem.listTasks(), + statuses: statuses || [], + }; + }; + + // Test that getKanbanData method exists and can be called + const kanbanData = await viewSwitcher.getKanbanData(); + expect(kanbanData).toBeDefined(); + expect(Array.isArray(kanbanData.tasks)).toBe(true); + expect(Array.isArray(kanbanData.statuses)).toBe(true); + + // Clean up again to be sure + viewSwitcher.cleanup(); + }); +}); diff --git a/src/test/cli-commit-behaviour.test.ts b/src/test/cli-commit-behaviour.test.ts new file mode 100644 index 0000000..34f567f --- /dev/null +++ b/src/test/cli-commit-behaviour.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { GitOperations } from "../git/operations.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +const CLI_PATH = join(process.cwd(), "src/cli.ts"); + +async function getCommitCountInTest(dir: string): Promise<number> { + const result = await $`git rev-list --all --count`.cwd(dir).quiet(); + return Number.parseInt(result.stdout.toString().trim(), 10); +} + +let TEST_DIR: string; + +describe("CLI Auto-Commit Behavior with autoCommit: false", () => { + let git: GitOperations; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli-commit-false"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repository first to avoid interactive prompts and ensure consistency + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + git = new GitOperations(TEST_DIR); + + await core.initializeProject("Commit Behavior Test", true); // auto-commit the initialization + + const config = await core.filesystem.loadConfig(); + if (config) { + config.autoCommit = false; + await core.filesystem.saveConfig(config); + // Commit the config change to have a clean state for tests + const configPath = join(TEST_DIR, "backlog", "config.yml"); + await git.addFile(configPath); + // Only commit if there are actual changes staged, to avoid errors on empty commits. + const diffProc = await $`git diff --staged --quiet`.cwd(TEST_DIR).nothrow().quiet(); + if (diffProc.exitCode === 1) { + await git.commitChanges("test: set autoCommit to false"); + } + } + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should not commit when creating a task if autoCommit is false", async () => { + const initialCommitCount = await getCommitCountInTest(TEST_DIR); + + const result = await $`bun ${CLI_PATH} task create "No-commit Task"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const finalCommitCount = await getCommitCountInTest(TEST_DIR); + const isClean = await git.isClean(); + + expect(finalCommitCount).toBe(initialCommitCount); + expect(isClean).toBe(false); + }); + + test("should not commit when creating a document if autoCommit is false", async () => { + const initialCommitCount = await getCommitCountInTest(TEST_DIR); + + const result = await $`bun ${CLI_PATH} doc create "No-commit Doc"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const finalCommitCount = await getCommitCountInTest(TEST_DIR); + const isClean = await git.isClean(); + + expect(finalCommitCount).toBe(initialCommitCount); + expect(isClean).toBe(false); + }); + + test("should not commit when creating a decision if autoCommit is false", async () => { + const initialCommitCount = await getCommitCountInTest(TEST_DIR); + + const result = await $`bun ${CLI_PATH} decision create "No-commit Decision"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const finalCommitCount = await getCommitCountInTest(TEST_DIR); + const isClean = await git.isClean(); + + expect(finalCommitCount).toBe(initialCommitCount); + expect(isClean).toBe(false); + }); +}); + +describe("CLI Auto-Commit Behavior with autoCommit: true", () => { + let git: GitOperations; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli-commit-true"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + git = new GitOperations(TEST_DIR); + + await core.initializeProject("Commit Behavior Test", true); + + const config = await core.filesystem.loadConfig(); + if (config) { + config.autoCommit = true; // Enable auto-commit for this test suite + await core.filesystem.saveConfig(config); + const configPath = join(TEST_DIR, "backlog", "config.yml"); + await git.addFile(configPath); + // Only commit if there are actual changes staged, to avoid errors on empty commits. + const diffProc = await $`git diff --staged --quiet`.cwd(TEST_DIR).nothrow().quiet(); + if (diffProc.exitCode === 1) { + await git.commitChanges("test: set autoCommit to true"); + } + } + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should commit when creating a task if autoCommit is true", async () => { + const initialCommitCount = await getCommitCountInTest(TEST_DIR); + + const result = await $`bun ${CLI_PATH} task create "Auto-commit Task"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + // Note: isClean() is omitted as createTask's commit strategy can leave the repo dirty. + const finalCommitCount = await getCommitCountInTest(TEST_DIR); + expect(finalCommitCount).toBe(initialCommitCount + 1); + }); + + test("should commit when creating a document if autoCommit is true", async () => { + const initialCommitCount = await getCommitCountInTest(TEST_DIR); + + const result = await $`bun ${CLI_PATH} doc create "Auto-commit Doc"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const finalCommitCount = await getCommitCountInTest(TEST_DIR); + const isClean = await git.isClean(); + + expect(finalCommitCount).toBe(initialCommitCount + 1); + expect(isClean).toBe(true); + }); + + test("should commit when creating a decision if autoCommit is true", async () => { + const initialCommitCount = await getCommitCountInTest(TEST_DIR); + + const result = await $`bun ${CLI_PATH} decision create "Auto-commit Decision"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const finalCommitCount = await getCommitCountInTest(TEST_DIR); + const isClean = await git.isClean(); + + expect(finalCommitCount).toBe(initialCommitCount + 1); + expect(isClean).toBe(true); + }); +}); diff --git a/src/test/cli-dependency.test.ts b/src/test/cli-dependency.test.ts new file mode 100644 index 0000000..44add36 --- /dev/null +++ b/src/test/cli-dependency.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createTaskPlatformAware, editTaskPlatformAware, viewTaskPlatformAware } from "./test-helpers.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +describe("CLI Dependency Support", () => { + let TEST_DIR: string; + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli-dependency"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repository first using the same pattern as other tests + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("test-project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should create task with single dependency using --dep", async () => { + // Create base task first + const result1 = await createTaskPlatformAware({ title: "Base Task" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + + // Create task with dependency + const result2 = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "task-1" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + expect(result2.stdout).toContain("Created task task-2"); + + // Verify dependency was set + const task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1"]); + }); + + test("should create task with single dependency using --depends-on", async () => { + // Create base task first + const result1 = await createTaskPlatformAware({ title: "Base Task" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + + // Create task with dependency + const result2 = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "task-1" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + expect(result2.stdout).toContain("Created task task-2"); + + // Verify dependency was set + const task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1"]); + }); + + test("should create task with multiple dependencies (comma-separated)", async () => { + // Create base tasks first + const result1 = await createTaskPlatformAware({ title: "Base Task 1" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + const result2 = await createTaskPlatformAware({ title: "Base Task 2" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + + // Create task with multiple dependencies + const result3 = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "task-1,task-2" }, TEST_DIR); + expect(result3.exitCode).toBe(0); + expect(result3.stdout).toContain("Created task task-3"); + + // Verify dependencies were set + const task = await core.filesystem.loadTask("task-3"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1", "task-2"]); + }); + + test("should create task with multiple dependencies (multiple flags)", async () => { + // Create base tasks first + const result1 = await createTaskPlatformAware({ title: "Base Task 1" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + const result2 = await createTaskPlatformAware({ title: "Base Task 2" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + + // Create task with multiple dependencies using multiple flags (simulated as comma-separated) + const result3 = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "task-1,task-2" }, TEST_DIR); + expect(result3.exitCode).toBe(0); + expect(result3.stdout).toContain("Created task task-3"); + + // Verify dependencies were set + const task = await core.filesystem.loadTask("task-3"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1", "task-2"]); + }); + + test("should normalize task IDs in dependencies", async () => { + // Create base task first + const result1 = await createTaskPlatformAware({ title: "Base Task" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + + // Create task with dependency using numeric ID (should be normalized to task-X) + const result2 = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "1" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + expect(result2.stdout).toContain("Created task task-2"); + + // Verify dependency was normalized + const task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1"]); + }); + + test("should fail when dependency task does not exist", async () => { + // Try to create task with non-existent dependency + const result = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "task-999" }, TEST_DIR); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("The following dependencies do not exist: task-999"); + }); + + test("should edit task to add dependencies", async () => { + // Create base tasks first + const result1 = await createTaskPlatformAware({ title: "Base Task 1" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + const result2 = await createTaskPlatformAware({ title: "Base Task 2" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + const result3 = await createTaskPlatformAware({ title: "Task to Edit" }, TEST_DIR); + expect(result3.exitCode).toBe(0); + + // Edit task to add dependencies + const result4 = await editTaskPlatformAware({ taskId: "task-3", dependencies: "task-1,task-2" }, TEST_DIR); + expect(result4.exitCode).toBe(0); + expect(result4.stdout).toContain("Updated task task-3"); + + // Verify dependencies were added + const task = await core.filesystem.loadTask("task-3"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1", "task-2"]); + }); + + test("should edit task to update dependencies", async () => { + // Create base tasks using platform-aware helper + const result1 = await createTaskPlatformAware({ title: "Base Task 1" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + const result2 = await createTaskPlatformAware({ title: "Base Task 2" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + const result3 = await createTaskPlatformAware({ title: "Base Task 3" }, TEST_DIR); + expect(result3.exitCode).toBe(0); + + // Create task with initial dependency + const result4 = await createTaskPlatformAware( + { + title: "Task with Dependency", + dependencies: "task-1", + }, + TEST_DIR, + ); + expect(result4.exitCode).toBe(0); + + // Edit task to change dependencies using platform-aware helper + const result5 = await editTaskPlatformAware( + { + taskId: "task-4", + dependencies: "task-2,task-3", + }, + TEST_DIR, + ); + expect(result5.exitCode).toBe(0); + + // Verify dependencies were updated (should replace, not append) + const task = await core.filesystem.loadTask("task-4"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-2", "task-3"]); + }); + + test("should handle dependencies on draft tasks", async () => { + // Create draft task first using platform-aware helper + const result1 = await createTaskPlatformAware( + { + title: "Draft Task", + draft: true, + }, + TEST_DIR, + ); + expect(result1.exitCode).toBe(0); + expect(result1.stdout).toContain("Created draft task-1"); + + // Create task that depends on draft + const result2 = await createTaskPlatformAware( + { + title: "Task depending on draft", + dependencies: "task-1", + }, + TEST_DIR, + ); + expect(result2.exitCode).toBe(0); + + // Verify dependency on draft was set + const task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + expect(task?.dependencies).toEqual(["task-1"]); + }); + + test("should display dependencies in plain text view", async () => { + // Create base task + const result1 = await createTaskPlatformAware({ title: "Base Task" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + + // Create task with dependency + const result2 = await createTaskPlatformAware({ title: "Dependent Task", dependencies: "task-1" }, TEST_DIR); + expect(result2.exitCode).toBe(0); + + // View task in plain text mode + const result3 = await viewTaskPlatformAware({ taskId: "task-2", plain: true }, TEST_DIR); + expect(result3.exitCode).toBe(0); + expect(result3.stdout).toContain("Dependencies: task-1"); + }); +}); diff --git a/src/test/cli-incrementing-ids.test.ts b/src/test/cli-incrementing-ids.test.ts new file mode 100644 index 0000000..52b5d0c --- /dev/null +++ b/src/test/cli-incrementing-ids.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { Decision, Document, Task } from "../types"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +let TEST_DIR: string; + +describe("CLI ID Incrementing Behavior", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli-incrementing-ids"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + core = new Core(TEST_DIR); + // Initialize git repository first to avoid interactive prompts and ensure consistency + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + await core.initializeProject("ID Incrementing Test"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should increment task IDs correctly", async () => { + const task1: Task = { + id: "task-1", + title: "First Task", + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: [], + description: "A test task.", + }; + await core.createTask(task1); + + const result = await $`bun ${CLI_PATH} task create "Second Task"`.cwd(TEST_DIR).quiet(); + + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toContain("Created task task-2"); + + const task2 = await core.filesystem.loadTask("task-2"); + expect(task2).toBeDefined(); + expect(task2?.title).toBe("Second Task"); + }); + + test("should increment document IDs correctly", async () => { + const doc1: Document = { + id: "doc-1", + title: "First Doc", + type: "other", + createdDate: "", + rawContent: "", + }; + await core.createDocument(doc1); + + const result = await $`bun ${CLI_PATH} doc create "Second Doc"`.cwd(TEST_DIR).quiet(); + + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toContain("Created document doc-2"); + + const docs = await core.filesystem.listDocuments(); + const doc2 = docs.find((d) => d.id === "doc-2"); + expect(doc2).toBeDefined(); + expect(doc2?.title).toBe("Second Doc"); + }); + + test("should increment decision IDs correctly", async () => { + const decision1: Decision = { + id: "decision-1", + title: "First Decision", + date: "", + status: "proposed", + context: "", + decision: "", + consequences: "", + rawContent: "", + }; + await core.createDecision(decision1); + + const result = await $`bun ${CLI_PATH} decision create "Second Decision"`.cwd(TEST_DIR).quiet(); + + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toContain("Created decision decision-2"); + + const decision2 = await core.filesystem.loadDecision("decision-2"); + expect(decision2).not.toBeNull(); + expect(decision2?.title).toBe("Second Decision"); + }); +}); diff --git a/src/test/cli-init-claude-default.test.ts b/src/test/cli-init-claude-default.test.ts new file mode 100644 index 0000000..9e7aa99 --- /dev/null +++ b/src/test/cli-init-claude-default.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; + +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +let TEST_DIR: string; + +describe("init Claude agent default", () => { + beforeEach(async () => { + TEST_DIR = join(process.cwd(), `.tmp-test-init-claude-${Math.random().toString(36).slice(2)}`); + await rm(TEST_DIR, { recursive: true, force: true }); + await mkdir(TEST_DIR, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + }); + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }); + }); + + it("does not install Claude agent by default in non-interactive mode", async () => { + // Use defaults, do not pass --install-claude-agent + const result = await $`bun ${CLI_PATH} init MyProj --defaults`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + // Verify that agent file was not created + const agentExists = await Bun.file(join(TEST_DIR, ".claude", "agents", "project-manager-backlog.md")).exists(); + expect(agentExists).toBe(false); + }); + + it("installs Claude agent when flag is true", async () => { + const result = await $`bun ${CLI_PATH} init MyProj --defaults --install-claude-agent true`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const agentExists = await Bun.file(join(TEST_DIR, ".claude", "agents", "project-manager-backlog.md")).exists(); + expect(agentExists).toBe(true); + }); +}); diff --git a/src/test/cli-parent-filter.test.ts b/src/test/cli-parent-filter.test.ts new file mode 100644 index 0000000..899324a --- /dev/null +++ b/src/test/cli-parent-filter.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI parent task filtering", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-parent-filter"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo first using shell API (same pattern as other tests) + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core (same pattern as other tests) + const core = new Core(TEST_DIR); + await core.initializeProject("Parent Filter Test Project"); + + // Create a parent task + await core.createTask( + { + id: "task-1", + title: "Parent task", + status: "To Do", + assignee: [], + createdDate: "2025-06-18", + labels: [], + dependencies: [], + description: "Parent task description", + }, + false, + ); + + // Create child tasks + await core.createTask( + { + id: "task-1.1", + title: "Child task 1", + status: "To Do", + assignee: [], + createdDate: "2025-06-18", + labels: [], + dependencies: [], + description: "Child task 1 description", + parentTaskId: "task-1", + }, + false, + ); + + await core.createTask( + { + id: "task-1.2", + title: "Child task 2", + status: "In Progress", + assignee: [], + createdDate: "2025-06-18", + labels: [], + dependencies: [], + description: "Child task 2 description", + parentTaskId: "task-1", + }, + false, + ); + + // Create another standalone task + await core.createTask( + { + id: "task-2", + title: "Standalone task", + status: "To Do", + assignee: [], + createdDate: "2025-06-18", + labels: [], + dependencies: [], + description: "Standalone task description", + }, + false, + ); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should filter tasks by parent with full task ID", async () => { + const result = await $`bun ${cliPath} task list --parent task-1 --plain`.cwd(TEST_DIR).quiet(); + + const exitCode = result.exitCode; + + if (exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(exitCode).toBe(0); + // Should contain only child tasks + expect(result.stdout.toString()).toContain("task-1.1 - Child task 1"); + expect(result.stdout.toString()).toContain("task-1.2 - Child task 2"); + // Should not contain parent or standalone tasks + expect(result.stdout.toString()).not.toContain("task-1 - Parent task"); + expect(result.stdout.toString()).not.toContain("task-2 - Standalone task"); + }); + + it("should filter tasks by parent with short task ID", async () => { + const result = await $`bun ${cliPath} task list --parent 1 --plain`.cwd(TEST_DIR).quiet(); + + const exitCode = result.exitCode; + + if (exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(exitCode).toBe(0); + // Should contain only child tasks + expect(result.stdout.toString()).toContain("task-1.1 - Child task 1"); + expect(result.stdout.toString()).toContain("task-1.2 - Child task 2"); + // Should not contain parent or standalone tasks + expect(result.stdout.toString()).not.toContain("task-1 - Parent task"); + expect(result.stdout.toString()).not.toContain("task-2 - Standalone task"); + }); + + it("should show error for non-existent parent task", async () => { + const result = await $`bun ${cliPath} task list --parent task-999 --plain`.cwd(TEST_DIR).nothrow().quiet(); + + const exitCode = result.exitCode; + + expect(exitCode).toBe(1); // CLI exits with error for non-existent parent + expect(result.stderr.toString()).toContain("Parent task task-999 not found."); + }); + + it("should show message when parent has no children", async () => { + const result = await $`bun ${cliPath} task list --parent task-2 --plain`.cwd(TEST_DIR).quiet(); + + const exitCode = result.exitCode; + + if (exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(exitCode).toBe(0); + expect(result.stdout.toString()).toContain("No child tasks found for parent task task-2."); + }); + + it("should work with -p shorthand flag", async () => { + const result = await $`bun ${cliPath} task list -p task-1 --plain`.cwd(TEST_DIR).quiet(); + + const exitCode = result.exitCode; + + if (exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(exitCode).toBe(0); + // Should contain only child tasks + expect(result.stdout.toString()).toContain("task-1.1 - Child task 1"); + expect(result.stdout.toString()).toContain("task-1.2 - Child task 2"); + }); + + it("should combine parent filter with status filter", async () => { + const result = await $`bun ${cliPath} task list --parent task-1 --status "To Do" --plain`.cwd(TEST_DIR).quiet(); + + const exitCode = result.exitCode; + + if (exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(exitCode).toBe(0); + // Should contain only child task with "To Do" status + expect(result.stdout.toString()).toContain("task-1.1 - Child task 1"); + // Should not contain child task with "In Progress" status + expect(result.stdout.toString()).not.toContain("task-1.2 - Child task 2"); + }); +}); diff --git a/src/test/cli-parent-shorthand.test.ts b/src/test/cli-parent-shorthand.test.ts new file mode 100644 index 0000000..0c80ab2 --- /dev/null +++ b/src/test/cli-parent-shorthand.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createTaskPlatformAware, getCliHelpPlatformAware } from "./test-helpers.ts"; + +describe("CLI parent shorthand option", () => { + let testDir: string; + + beforeAll(async () => { + testDir = await mkdtemp(join(tmpdir(), "backlog-test-")); + + // Initialize git repository first to avoid interactive prompts + await $`git init -b main`.cwd(testDir).quiet(); + await $`git config user.name "Test User"`.cwd(testDir).quiet(); + await $`git config user.email test@example.com`.cwd(testDir).quiet(); + + // Initialize backlog project using Core (simulating CLI) + const core = new Core(testDir); + await core.initializeProject("Test Project"); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("should accept -p as shorthand for --parent", async () => { + // Create parent task + const createParent = await createTaskPlatformAware({ title: "Parent Task" }, testDir); + expect(createParent.exitCode).toBe(0); + + // Create subtask using -p shorthand + const createSubtaskShort = await createTaskPlatformAware({ title: "Subtask with -p", parent: "task-1" }, testDir); + expect(createSubtaskShort.exitCode).toBe(0); + + // Find the created subtask file + const tasksDir = join(testDir, "backlog", "tasks"); + const files = await readdir(tasksDir); + const subtaskFiles = files.filter((f) => f.startsWith("task-1.1 - ") && f.endsWith(".md")); + expect(subtaskFiles.length).toBe(1); + + // Verify the subtask was created with correct parent + if (subtaskFiles[0]) { + const subtaskFile = await Bun.file(join(tasksDir, subtaskFiles[0])).text(); + expect(subtaskFile).toContain("parent_task_id: task-1"); + } + }); + + it("should work the same as --parent option", async () => { + // Create subtask using --parent + const createSubtaskLong = await createTaskPlatformAware( + { title: "Subtask with --parent", parent: "task-1" }, + testDir, + ); + expect(createSubtaskLong.exitCode).toBe(0); + + // Find both subtask files + const tasksDir = join(testDir, "backlog", "tasks"); + const files = await readdir(tasksDir); + const subtaskFiles1 = files.filter((f) => f.startsWith("task-1.1 - ") && f.endsWith(".md")); + const subtaskFiles2 = files.filter((f) => f.startsWith("task-1.2 - ") && f.endsWith(".md")); + + expect(subtaskFiles1.length).toBe(1); + expect(subtaskFiles2.length).toBe(1); + + // Verify both subtasks have the same parent + if (subtaskFiles1[0] && subtaskFiles2[0]) { + const subtask1 = await Bun.file(join(tasksDir, subtaskFiles1[0])).text(); + const subtask2 = await Bun.file(join(tasksDir, subtaskFiles2[0])).text(); + + expect(subtask1).toContain("parent_task_id: task-1"); + expect(subtask2).toContain("parent_task_id: task-1"); + } + }); + + it("should show -p in help text", async () => { + const helpResult = await getCliHelpPlatformAware(["task", "create", "--help"], testDir); + + expect(helpResult.stdout).toContain("-p, --parent <taskId>"); + expect(helpResult.stdout).toContain("specify parent task ID"); + }); +}); diff --git a/src/test/cli-plain-create-edit.test.ts b/src/test/cli-plain-create-edit.test.ts new file mode 100644 index 0000000..aa1a1f4 --- /dev/null +++ b/src/test/cli-plain-create-edit.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI --plain for task create/edit", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-plain-create-edit"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch {} + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo first using shell API (same as other tests) + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core + const core = new Core(TEST_DIR); + await core.initializeProject("Plain Create/Edit Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch {} + }); + + it("prints plain details after task create --plain", async () => { + const result = await $`bun ${cliPath} task create "Example" --desc "Hello" --plain`.cwd(TEST_DIR).quiet(); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + const out = result.stdout.toString(); + expect(result.exitCode).toBe(0); + // Begins with File: line and contains key sections + expect(out).toContain("File: "); + expect(out).toContain("Task task-1 - Example"); + expect(out).toContain("Status:"); + expect(out).toContain("Created:"); + expect(out).toContain("Description:"); + expect(out).toContain("Hello"); + expect(out).toContain("Acceptance Criteria:"); + // Should not contain TUI escape codes + expect(out).not.toContain("[?1049h"); + expect(out).not.toContain("\x1b"); + }); + + it("prints plain details after task edit --plain", async () => { + // Create base task first (without plain) + await $`bun ${cliPath} task create "Edit Me" --desc "First"`.cwd(TEST_DIR).quiet(); + + const result = await $`bun ${cliPath} task edit 1 -s "In Progress" --plain`.cwd(TEST_DIR).quiet(); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + const out = result.stdout.toString(); + expect(result.exitCode).toBe(0); + // Begins with File: line and contains updated details + expect(out).toContain("File: "); + expect(out).toContain("Task task-1 - Edit Me"); + expect(out).toContain("Status: β—’ In Progress"); + expect(out).toContain("Created:"); + expect(out).toContain("Updated:"); + expect(out).toContain("Description:"); + expect(out).toContain("Acceptance Criteria:"); + // Should not contain TUI escape codes + expect(out).not.toContain("[?1049h"); + expect(out).not.toContain("\x1b"); + }); +}); diff --git a/src/test/cli-plain-output.test.ts b/src/test/cli-plain-output.test.ts new file mode 100644 index 0000000..4973485 --- /dev/null +++ b/src/test/cli-plain-output.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI plain output for AI agents", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-plain-output"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo first using shell API (same pattern as other tests) + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core (same pattern as other tests) + const core = new Core(TEST_DIR); + await core.initializeProject("Plain Output Test Project"); + + // Create a test task + await core.createTask( + { + id: "task-1", + title: "Test task for plain output", + status: "To Do", + assignee: [], + createdDate: "2025-06-18", + labels: [], + dependencies: [], + description: "Test description", + }, + false, + ); + + // Create a test draft + await core.createDraft( + { + id: "task-2", + title: "Test draft for plain output", + status: "Draft", + assignee: [], + createdDate: "2025-06-18", + labels: [], + dependencies: [], + description: "Test draft description", + }, + false, + ); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should output plain text with task view --plain", async () => { + const result = await $`bun ${cliPath} task view 1 --plain`.cwd(TEST_DIR).quiet(); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(result.exitCode).toBe(0); + // Should contain the file path as first line + expect(result.stdout.toString()).toContain("File: "); + expect(result.stdout.toString()).toContain("task-1 - Test-task-for-plain-output.md"); + // Should contain the formatted task output + expect(result.stdout.toString()).toContain("Task task-1 - Test task for plain output"); + expect(result.stdout.toString()).toContain("Status: β—‹ To Do"); + expect(result.stdout.toString()).toContain("Created: 2025-06-18"); + expect(result.stdout.toString()).toContain("Description:"); + expect(result.stdout.toString()).toContain("Test description"); + expect(result.stdout.toString()).toContain("Acceptance Criteria:"); + // Should not contain TUI escape codes + expect(result.stdout.toString()).not.toContain("[?1049h"); + expect(result.stdout.toString()).not.toContain("\x1b"); + }); + + it("should output plain text with task <id> --plain shortcut", async () => { + // Verify task exists before running CLI command + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.id).toBe("task-1"); + + const result = await $`bun ${cliPath} task 1 --plain`.cwd(TEST_DIR).quiet(); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(result.exitCode).toBe(0); + // Should contain the file path as first line + expect(result.stdout.toString()).toContain("File: "); + expect(result.stdout.toString()).toContain("task-1 - Test-task-for-plain-output.md"); + // Should contain the formatted task output + expect(result.stdout.toString()).toContain("Task task-1 - Test task for plain output"); + expect(result.stdout.toString()).toContain("Status: β—‹ To Do"); + expect(result.stdout.toString()).toContain("Created: 2025-06-18"); + expect(result.stdout.toString()).toContain("Description:"); + expect(result.stdout.toString()).toContain("Test description"); + // Should not contain TUI escape codes + expect(result.stdout.toString()).not.toContain("[?1049h"); + expect(result.stdout.toString()).not.toContain("\x1b"); + }); + + it("should output plain text with draft view --plain", async () => { + const result = await $`bun ${cliPath} draft view 2 --plain`.cwd(TEST_DIR).quiet(); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(result.exitCode).toBe(0); + // Should contain the file path as first line + expect(result.stdout.toString()).toContain("File: "); + expect(result.stdout.toString()).toContain("task-2 - Test-draft-for-plain-output.md"); + // Should contain the formatted draft output + expect(result.stdout.toString()).toContain("Task task-2 - Test draft for plain output"); + expect(result.stdout.toString()).toContain("Status: β—‹ Draft"); + expect(result.stdout.toString()).toContain("Created: 2025-06-18"); + expect(result.stdout.toString()).toContain("Description:"); + expect(result.stdout.toString()).toContain("Test draft description"); + // Should not contain TUI escape codes + expect(result.stdout.toString()).not.toContain("[?1049h"); + expect(result.stdout.toString()).not.toContain("\x1b"); + }); + + it("should output plain text with draft <id> --plain shortcut", async () => { + // Verify draft exists before running CLI command + const core = new Core(TEST_DIR); + const draft = await core.filesystem.loadDraft("task-2"); + expect(draft).not.toBeNull(); + expect(draft?.id).toBe("task-2"); + + const result = await $`bun ${cliPath} draft 2 --plain`.cwd(TEST_DIR).quiet(); + + if (result.exitCode !== 0) { + console.error("STDOUT:", result.stdout.toString()); + console.error("STDERR:", result.stderr.toString()); + } + + expect(result.exitCode).toBe(0); + // Should contain the file path as first line + expect(result.stdout.toString()).toContain("File: "); + expect(result.stdout.toString()).toContain("task-2 - Test-draft-for-plain-output.md"); + // Should contain the formatted draft output + expect(result.stdout.toString()).toContain("Task task-2 - Test draft for plain output"); + expect(result.stdout.toString()).toContain("Status: β—‹ Draft"); + expect(result.stdout.toString()).toContain("Created: 2025-06-18"); + expect(result.stdout.toString()).toContain("Description:"); + expect(result.stdout.toString()).toContain("Test draft description"); + // Should not contain TUI escape codes + expect(result.stdout.toString()).not.toContain("[?1049h"); + expect(result.stdout.toString()).not.toContain("\x1b"); + }); + + // Task list already has --plain support and works correctly +}); diff --git a/src/test/cli-priority-filtering.test.ts b/src/test/cli-priority-filtering.test.ts new file mode 100644 index 0000000..d6888b7 --- /dev/null +++ b/src/test/cli-priority-filtering.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { $ } from "bun"; + +describe("CLI Priority Filtering", () => { + test("task list --priority high shows only high priority tasks", async () => { + const result = await $`bun run cli task list --priority high --plain`.quiet(); + expect(result.exitCode).toBe(0); + + // Should only show high priority tasks + const output = result.stdout.toString(); + if (output.includes("task-")) { + // If tasks exist, check they have HIGH priority indicators + expect(output).toMatch(/\[HIGH\]/); + // Should not contain other priority indicators + expect(output).not.toMatch(/\[MEDIUM\]/); + expect(output).not.toMatch(/\[LOW\]/); + } + }); + + test("task list --priority medium shows only medium priority tasks", async () => { + const result = await $`bun run cli task list --priority medium --plain`.quiet(); + expect(result.exitCode).toBe(0); + + const output = result.stdout.toString(); + if (output.includes("task-")) { + expect(output).toMatch(/\[MEDIUM\]/); + expect(output).not.toMatch(/\[HIGH\]/); + expect(output).not.toMatch(/\[LOW\]/); + } + }); + + test("task list --priority low shows only low priority tasks", async () => { + const result = await $`bun run cli task list --priority low --plain`.quiet(); + expect(result.exitCode).toBe(0); + + const output = result.stdout.toString(); + if (output.includes("task-")) { + expect(output).toMatch(/\[LOW\]/); + expect(output).not.toMatch(/\[HIGH\]/); + expect(output).not.toMatch(/\[MEDIUM\]/); + } + }); + + test("task list --priority invalid shows error", async () => { + const result = await $`bun run cli task list --priority invalid --plain`.nothrow().quiet(); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid priority: invalid"); + expect(result.stderr.toString()).toContain("Valid values are: high, medium, low"); + }); + + test("task list --sort priority sorts by priority", async () => { + const result = await $`bun run cli task list --sort priority --plain`.quiet(); + expect(result.exitCode).toBe(0); + + const output = result.stdout.toString(); + // If tasks exist, high priority should come before medium, which comes before low + if (output.includes("[HIGH]") && output.includes("[MEDIUM]")) { + const highIndex = output.indexOf("[HIGH]"); + const mediumIndex = output.indexOf("[MEDIUM]"); + expect(highIndex).toBeLessThan(mediumIndex); + } + if (output.includes("[MEDIUM]") && output.includes("[LOW]")) { + const mediumIndex = output.indexOf("[MEDIUM]"); + const lowIndex = output.indexOf("[LOW]"); + expect(mediumIndex).toBeLessThan(lowIndex); + } + }); + + test("task list --sort id sorts by task ID", async () => { + const result = await $`bun run cli task list --sort id --plain`.quiet(); + expect(result.exitCode).toBe(0); + // Should exit successfully - detailed sorting verification would require known test data + }); + + test("task list --sort invalid shows error", async () => { + const result = await $`bun run cli task list --sort invalid --plain`.nothrow().quiet(); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid sort field: invalid"); + expect(result.stderr.toString()).toContain("Valid values are: priority, id"); + }); + + test("task list combines priority filter with status filter", async () => { + const result = await $`bun run cli task list --priority high --status "To Do" --plain`.quiet(); + expect(result.exitCode).toBe(0); + + const output = result.stdout.toString(); + if (output.includes("task-")) { + // Should only show high priority tasks in "To Do" status + expect(output).toMatch(/\[HIGH\]/); + expect(output).toMatch(/To Do:/); + } + }); + + test("task list combines priority filter with sort", async () => { + const result = await $`bun run cli task list --priority high --sort id --plain`.quiet(); + expect(result.exitCode).toBe(0); + + const output = result.stdout.toString(); + if (output.includes("[HIGH]")) { + // Should only show high priority tasks, sorted by ID + expect(output).toMatch(/\[HIGH\]/); + expect(output).not.toMatch(/\[MEDIUM\]/); + expect(output).not.toMatch(/\[LOW\]/); + } + }); + + test("plain output includes priority indicators", async () => { + const result = await $`bun run cli task list --plain`.quiet(); + expect(result.exitCode).toBe(0); + + const output = result.stdout.toString(); + // If any priority tasks exist, they should have proper indicators + if (output.includes("task-")) { + // Should have proper format with optional priority indicators + expect(output).toMatch(/^\s*(\[HIGH\]|\[MEDIUM\]|\[LOW\])?\s*task-\d+\s+-\s+/m); + } + }); + + test("case insensitive priority filtering", async () => { + const upperResult = await $`bun run cli task list --priority HIGH --plain`.quiet(); + const lowerResult = await $`bun run cli task list --priority high --plain`.quiet(); + const mixedResult = await $`bun run cli task list --priority High --plain`.quiet(); + + expect(upperResult.exitCode).toBe(0); + expect(lowerResult.exitCode).toBe(0); + expect(mixedResult.exitCode).toBe(0); + + const [upperOutput, lowerOutput, mixedOutput] = [ + upperResult.stdout.toString(), + lowerResult.stdout.toString(), + mixedResult.stdout.toString(), + ]; + const listUpper = upperOutput.split("\n").filter((line) => line.includes("task-")); + const listLower = lowerOutput.split("\n").filter((line) => line.includes("task-")); + const listMixed = mixedOutput.split("\n").filter((line) => line.includes("task-")); + if (listLower.length > 0) { + expect(listUpper).toEqual(listLower); + expect(listMixed).toEqual(listLower); + } + + for (const output of [upperOutput, lowerOutput, mixedOutput]) { + if (output.includes("task-")) { + expect(output).toMatch(/\[HIGH\]/); + expect(output).not.toMatch(/\[MEDIUM\]/); + expect(output).not.toMatch(/\[LOW\]/); + } + } + }); +}); diff --git a/src/test/cli-search-command.test.ts b/src/test/cli-search-command.test.ts new file mode 100644 index 0000000..db96654 --- /dev/null +++ b/src/test/cli-search-command.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI search command", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli-search"); + await mkdir(TEST_DIR, { recursive: true }); + + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Search Command Project"); + + await core.createTask( + { + id: "task-1", + title: "Central search integration", + status: "To Do", + assignee: ["@codex"], + createdDate: "2025-09-18", + labels: ["search"], + dependencies: [], + rawContent: "Implements central search module", + description: "Implements central search module", + }, + false, + ); + + await core.createTask( + { + id: "task-2", + title: "High priority follow-up", + status: "In Progress", + assignee: ["@codex"], + createdDate: "2025-09-18", + labels: ["search"], + dependencies: [], + rawContent: "Follow-up work", + description: "Follow-up work", + priority: "high", + }, + false, + ); + + await core.filesystem.saveDocument({ + id: "doc-1", + title: "Search Architecture Notes", + type: "guide", + createdDate: "2025-09-18", + rawContent: "# Search Architecture Notes\nCentral search design", + }); + + await core.filesystem.saveDecision({ + id: "decision-1", + title: "Adopt centralized search", + date: "2025-09-18", + status: "accepted", + context: "Discussed search consolidation", + decision: "Adopt shared Fuse index", + consequences: "Unified search paths", + rawContent: "## Context\nDiscussed search consolidation\n\n## Decision\nAdopt shared Fuse index", + }); + }); + + afterEach(async () => { + await safeCleanup(TEST_DIR); + }); + + it("returns matching tasks, documents, and decisions in plain output", async () => { + const result = await $`bun ${cliPath} search central --plain`.cwd(TEST_DIR).quiet(); + + expect(result.exitCode).toBe(0); + const stdout = result.stdout.toString(); + expect(stdout).toContain("Tasks:"); + expect(stdout).toContain("task-1 - Central search integration"); + expect(stdout).toContain("Documents:"); + expect(stdout).toContain("doc-1 - Search Architecture Notes"); + expect(stdout).toContain("Decisions:"); + expect(stdout).toContain("decision-1 - Adopt centralized search"); + }); + + it("honors status and priority filters for task results", async () => { + const statusResult = await $`bun ${cliPath} search follow-up --type task --status "In Progress" --plain` + .cwd(TEST_DIR) + .quiet(); + expect(statusResult.exitCode).toBe(0); + const statusStdout = statusResult.stdout.toString(); + expect(statusStdout).toContain("task-2 - High priority follow-up"); + expect(statusStdout).not.toContain("task-1 - Central search integration"); + + const priorityResult = await $`bun ${cliPath} search follow-up --type task --priority high --plain` + .cwd(TEST_DIR) + .quiet(); + expect(priorityResult.exitCode).toBe(0); + const priorityStdout = priorityResult.stdout.toString(); + expect(priorityStdout).toContain("task-2 - High priority follow-up"); + }); + + it("applies result limit", async () => { + const result = await $`bun ${cliPath} search search --plain --limit 1`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + const stdout = result.stdout.toString(); + const taskMatches = stdout.match(/task-\d+ -/g) || []; + expect(taskMatches.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/test/cli-splash.test.ts b/src/test/cli-splash.test.ts new file mode 100644 index 0000000..ad3fad4 --- /dev/null +++ b/src/test/cli-splash.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("CLI Splash (bare run)", () => { + beforeEach(async () => { + TEST_DIR = join(process.cwd(), `.tmp-test-${Math.random().toString(36).slice(2)}`); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + }); + + it("prints minimal splash in non-initialized repo (non-TTY)", async () => { + const result = await $`bun ${CLI_PATH}`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(result.exitCode).toBe(0); + expect(out).toContain("Backlog.md v"); + expect(out).toContain("Docs: https://backlog.md"); + expect(out).toContain("backlog init"); + }); + + it("prints quickstart (initialized repo)", async () => { + // Initialize Git + project via Core + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name Test`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + const core = new Core(TEST_DIR); + await core.initializeProject("Splash Test"); + + const result = await $`bun ${CLI_PATH}`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(result.exitCode).toBe(0); + expect(out).toContain("Quickstart"); + expect(out).toContain("backlog task create"); + expect(out).toContain("backlog board"); + expect(out).not.toContain("backlog init"); + }); + + it("--help shows commander help, not splash", async () => { + const result = await $`bun ${CLI_PATH} --help`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(result.exitCode).toBe(0); + expect(out).toMatch(/Usage: .*backlog/); + }); + + it("--plain forces minimal splash", async () => { + const result = await $`bun ${CLI_PATH} --plain`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(result.exitCode).toBe(0); + expect(out).toContain("Backlog.md v"); + expect(out).toContain("Docs: https://backlog.md"); + }); +}); diff --git a/src/test/cli-zero-padded-ids.test.ts b/src/test/cli-zero-padded-ids.test.ts new file mode 100644 index 0000000..559e9b8 --- /dev/null +++ b/src/test/cli-zero-padded-ids.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +const CLI_PATH = join(process.cwd(), "src/cli.ts"); + +let TEST_DIR: string; + +describe("CLI Zero Padded IDs Feature", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-zero-padded-ids"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git and backlog project + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Padding Test", false); // No auto-commit for init + + // Enable zero padding in the config + const config = await core.filesystem.loadConfig(); + if (config) { + config.zeroPaddedIds = 3; + config.autoCommit = false; // Disable auto-commit for easier testing + await core.filesystem.saveConfig(config); + } + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should create a task with a zero-padded ID", async () => { + const result = await $`bun ${CLI_PATH} task create "Padded Task"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const tasksDir = join(TEST_DIR, "backlog", "tasks"); + const files = await readdir(tasksDir); + expect(files.length).toBe(1); + expect(files[0]).toStartWith("task-001"); + }); + + test("should create a document with a zero-padded ID", async () => { + const result = await $`bun ${CLI_PATH} doc create "Padded Doc"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const docsDir = join(TEST_DIR, "backlog", "docs"); + const files = await readdir(docsDir); + expect(files.length).toBe(1); + expect(files[0]).toStartWith("doc-001"); + }); + + test("should create a decision with a zero-padded ID", async () => { + const result = await $`bun ${CLI_PATH} decision create "Padded Decision"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const decisionsDir = join(TEST_DIR, "backlog", "decisions"); + const files = await readdir(decisionsDir); + expect(files.length).toBe(1); + expect(files[0]).toStartWith("decision-001"); + }); + + test("should correctly increment a padded task ID", async () => { + await $`bun ${CLI_PATH} task create "First Padded Task"`.cwd(TEST_DIR).quiet(); + const result = await $`bun ${CLI_PATH} task create "Second Padded Task"`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const tasksDir = join(TEST_DIR, "backlog", "tasks"); + const files = await readdir(tasksDir); + expect(files.length).toBe(2); + expect(files.some((file) => file.startsWith("task-002"))).toBe(true); + }); + + test("should create a sub-task with a zero-padded ID", async () => { + // Create parent task first + await $`bun ${CLI_PATH} task create "Parent Task"`.cwd(TEST_DIR).quiet(); + + // Create sub-task + const result = await $`bun ${CLI_PATH} task create "Padded Sub-task" -p task-001`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const tasksDir = join(TEST_DIR, "backlog", "tasks"); + const files = await readdir(tasksDir); + expect(files.length).toBe(2); + expect(files.some((file) => file.startsWith("task-001.01"))).toBe(true); + }); +}); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts new file mode 100644 index 0000000..5630e9c --- /dev/null +++ b/src/test/cli.test.ts @@ -0,0 +1,1501 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core, isGitRepository } from "../index.ts"; +import { parseTask } from "../markdown/parser.ts"; +import { extractStructuredSection } from "../markdown/structured-sections.ts"; +import type { Decision, Document, Task } from "../types/index.ts"; +import { listTasksPlatformAware, viewTaskPlatformAware } from "./test-helpers.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("CLI Integration", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-cli"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("backlog init command", () => { + it("should initialize backlog project in existing git repo", async () => { + // Set up a git repository + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core (simulating CLI) + const core = new Core(TEST_DIR); + await core.initializeProject("CLI Test Project", true); + + // Verify directory structure was created + const configExists = await Bun.file(join(TEST_DIR, "backlog", "config.yml")).exists(); + expect(configExists).toBe(true); + + // Verify config content + const config = await core.filesystem.loadConfig(); + expect(config?.projectName).toBe("CLI Test Project"); + expect(config?.statuses).toEqual(["To Do", "In Progress", "Done"]); + expect(config?.defaultStatus).toBe("To Do"); + + // Verify git commit was created + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toContain("Initialize backlog project: CLI Test Project"); + }); + + it("should create all required directories", async () => { + // Set up a git repository + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Directory Test"); + + // Check all expected directories exist + const expectedDirs = [ + "backlog", + "backlog/tasks", + "backlog/drafts", + "backlog/archive", + "backlog/archive/tasks", + "backlog/archive/drafts", + "backlog/docs", + "backlog/decisions", + ]; + + for (const dir of expectedDirs) { + try { + const stats = await stat(join(TEST_DIR, dir)); + expect(stats.isDirectory()).toBe(true); + } catch { + // If stat fails, directory doesn't exist + expect(false).toBe(true); + } + } + }); + + it("should handle project names with special characters", async () => { + // Set up a git repository + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + const specialProjectName = "My-Project_2024 (v1.0)"; + await core.initializeProject(specialProjectName); + + const config = await core.filesystem.loadConfig(); + expect(config?.projectName).toBe(specialProjectName); + }); + + it("should work when git repo exists", async () => { + // Set up existing git repo + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const isRepo = await isGitRepository(TEST_DIR); + expect(isRepo).toBe(true); + + const core = new Core(TEST_DIR); + await core.initializeProject("Existing Repo Test"); + + const config = await core.filesystem.loadConfig(); + expect(config?.projectName).toBe("Existing Repo Test"); + }); + + it("should accept optional project name parameter", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Test the CLI implementation by directly using the Core functionality + const core = new Core(TEST_DIR); + await core.initializeProject("Test Project"); + + const config = await core.filesystem.loadConfig(); + expect(config?.projectName).toBe("Test Project"); + }); + + it("should create agent instruction files when requested", async () => { + // Set up a git repository + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + // Simulate the agent instructions being added + const core = new Core(TEST_DIR); + await core.initializeProject("Agent Test Project"); + + // Import and call addAgentInstructions directly (simulating user saying "y") + const { addAgentInstructions } = await import("../index.ts"); + await addAgentInstructions(TEST_DIR, core.gitOps); + + // Verify agent files were created + const agentsFile = await Bun.file(join(TEST_DIR, "AGENTS.md")).exists(); + const claudeFile = await Bun.file(join(TEST_DIR, "CLAUDE.md")).exists(); + // .cursorrules removed; Cursor now uses AGENTS.md + const geminiFile = await Bun.file(join(TEST_DIR, "GEMINI.md")).exists(); + const copilotFile = await Bun.file(join(TEST_DIR, ".github/copilot-instructions.md")).exists(); + + expect(agentsFile).toBe(true); + expect(claudeFile).toBe(true); + expect(geminiFile).toBe(true); + expect(copilotFile).toBe(true); + + // Verify content + const agentsContent = await Bun.file(join(TEST_DIR, "AGENTS.md")).text(); + const claudeContent = await Bun.file(join(TEST_DIR, "CLAUDE.md")).text(); + const geminiContent = await Bun.file(join(TEST_DIR, "GEMINI.md")).text(); + const copilotContent = await Bun.file(join(TEST_DIR, ".github/copilot-instructions.md")).text(); + expect(agentsContent.length).toBeGreaterThan(0); + expect(claudeContent.length).toBeGreaterThan(0); + expect(geminiContent.length).toBeGreaterThan(0); + expect(copilotContent.length).toBeGreaterThan(0); + }); + + it("should allow skipping agent instructions with 'none' selection", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const output = await $`bun ${CLI_PATH} init TestProj --defaults --agent-instructions none`.cwd(TEST_DIR).text(); + + const agentsFile = await Bun.file(join(TEST_DIR, "AGENTS.md")).exists(); + const claudeFile = await Bun.file(join(TEST_DIR, "CLAUDE.md")).exists(); + expect(agentsFile).toBe(false); + expect(claudeFile).toBe(false); + expect(output).toContain("AI Integration: CLI commands (legacy)"); + expect(output).toContain("Skipping agent instruction files per selection."); + }); + + it("should print minimal summary when advanced settings are skipped", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const output = await $`bun ${CLI_PATH} init SummaryProj --defaults --agent-instructions none` + .cwd(TEST_DIR) + .text(); + + expect(output).toContain("Initialization Summary:"); + expect(output).toContain("Project Name: SummaryProj"); + expect(output).toContain("AI Integration: CLI commands (legacy)"); + expect(output).toContain("Advanced settings: unchanged"); + expect(output).not.toContain("Remote operations:"); + expect(output).not.toContain("Zero-padded IDs:"); + }); + + it("should support MCP integration mode via flag", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const output = await $`bun ${CLI_PATH} init McpProj --defaults --integration-mode mcp`.cwd(TEST_DIR).text(); + + expect(output).toContain("AI Integration: MCP connector"); + expect(output).toContain("Agent instruction files: guidance is provided through the MCP connector."); + expect(output).toContain("MCP server name: backlog"); + expect(output).toContain("MCP client setup: skipped (non-interactive)"); + const agentsFile = await Bun.file(join(TEST_DIR, "AGENTS.md")).exists(); + const claudeFile = await Bun.file(join(TEST_DIR, "CLAUDE.md")).exists(); + expect(agentsFile).toBe(false); + expect(claudeFile).toBe(false); + }); + + it("should default to MCP integration when no mode is specified", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const output = await $`bun ${CLI_PATH} init DefaultMcpProj --defaults`.cwd(TEST_DIR).text(); + + expect(output).toContain("AI Integration: MCP connector"); + expect(output).toContain("MCP server name: backlog"); + expect(output).toContain("MCP client setup: skipped (non-interactive)"); + }); + + it("should allow skipping AI integration via flag", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const output = await $`bun ${CLI_PATH} init SkipProj --defaults --integration-mode none`.cwd(TEST_DIR).text(); + + expect(output).not.toContain("AI Integration:"); + expect(output).toContain("AI integration skipped"); + const agentsFile = await Bun.file(join(TEST_DIR, "AGENTS.md")).exists(); + const claudeFile = await Bun.file(join(TEST_DIR, "CLAUDE.md")).exists(); + expect(agentsFile).toBe(false); + expect(claudeFile).toBe(false); + }); + + it("should reject MCP integration when agent instruction flags are provided", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + let failed = false; + let combinedOutput = ""; + try { + await $`bun ${CLI_PATH} init ConflictProj --defaults --integration-mode mcp --agent-instructions claude` + .cwd(TEST_DIR) + .text(); + } catch (err) { + failed = true; + const e = err as { stdout?: unknown; stderr?: unknown }; + combinedOutput = String(e.stdout ?? "") + String(e.stderr ?? ""); + } + + expect(failed).toBe(true); + expect(combinedOutput).toContain("cannot be combined"); + }); + + it("should ignore 'none' when other agent instructions are provided", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + await $`bun ${CLI_PATH} init TestProj --defaults --agent-instructions agents,none`.cwd(TEST_DIR).quiet(); + + const agentsFile = await Bun.file(join(TEST_DIR, "AGENTS.md")).exists(); + expect(agentsFile).toBe(true); + }); + + it("should error on invalid agent instruction value", async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + let failed = false; + try { + await $`bun ${CLI_PATH} init InvalidProj --defaults --agent-instructions notreal`.cwd(TEST_DIR).quiet(); + } catch (e) { + failed = true; + const err = e as { stdout?: unknown; stderr?: unknown }; + const out = String(err.stdout ?? "") + String(err.stderr ?? ""); + expect(out).toContain("Invalid agent instruction: notreal"); + expect(out).toContain("Valid options are: cursor, claude, agents, gemini, copilot, none"); + } + + expect(failed).toBe(true); + }); + }); + + describe("git integration", () => { + beforeEach(async () => { + // Set up a git repository + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + }); + + it("should create initial commit with backlog structure", async () => { + const core = new Core(TEST_DIR); + await core.initializeProject("Git Integration Test", true); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toBe("backlog: Initialize backlog project: Git Integration Test"); + + // Verify git status is clean after initialization + const isClean = await core.gitOps.isClean(); + expect(isClean).toBe(true); + }); + }); + + describe("task list command", () => { + beforeEach(async () => { + // Set up a git repository and initialize backlog + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("List Test Project", true); + }); + + it("should show 'No tasks found' when no tasks exist", async () => { + const core = new Core(TEST_DIR); + const tasks = await core.filesystem.listTasks(); + expect(tasks).toHaveLength(0); + }); + + it("should list tasks grouped by status", async () => { + const core = new Core(TEST_DIR); + + // Create test tasks with different statuses + await core.createTask( + { + id: "task-1", + title: "First Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "First test task", + }, + false, + ); + + await core.createTask( + { + id: "task-2", + title: "Second Task", + status: "Done", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Second test task", + }, + false, + ); + + await core.createTask( + { + id: "task-3", + title: "Third Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Third test task", + }, + false, + ); + + const tasks = await core.filesystem.listTasks(); + expect(tasks).toHaveLength(3); + + // Verify tasks are grouped correctly by status + const todoTasks = tasks.filter((t) => t.status === "To Do"); + const doneTasks = tasks.filter((t) => t.status === "Done"); + + expect(todoTasks).toHaveLength(2); + expect(doneTasks).toHaveLength(1); + expect(todoTasks.map((t) => t.id)).toEqual(["task-1", "task-3"]); + expect(doneTasks.map((t) => t.id)).toEqual(["task-2"]); + }); + + it("should respect config status order", async () => { + const core = new Core(TEST_DIR); + + // Load and verify default config status order + const config = await core.filesystem.loadConfig(); + expect(config?.statuses).toEqual(["To Do", "In Progress", "Done"]); + }); + + it("should filter tasks by status", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-1", + title: "First Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "First test task", + }, + false, + ); + await core.createTask( + { + id: "task-2", + title: "Second Task", + status: "Done", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Second test task", + }, + false, + ); + + const result = await $`bun ${CLI_PATH} task list --plain --status Done`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(out).toContain("Done:"); + expect(out).toContain("task-2 - Second Task"); + expect(out).not.toContain("task-1"); + }); + + it("should filter tasks by status case-insensitively", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-1", + title: "First Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "First test task", + }, + true, + ); + await core.createTask( + { + id: "task-2", + title: "Second Task", + status: "Done", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Second test task", + }, + true, + ); + + const testCases = ["done", "DONE", "DoNe"]; + + for (const status of testCases) { + const result = await $`bun ${CLI_PATH} task list --plain --status ${status}`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(out).toContain("Done:"); + expect(out).toContain("task-2 - Second Task"); + expect(out).not.toContain("task-1"); + } + + // Test with -s flag + const resultShort = await listTasksPlatformAware({ plain: true, status: "done" }, TEST_DIR); + const outShort = resultShort.stdout; + expect(outShort).toContain("Done:"); + expect(outShort).toContain("task-2 - Second Task"); + expect(outShort).not.toContain("task-1"); + }); + + it("should filter tasks by assignee", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-1", + title: "Assigned Task", + status: "To Do", + assignee: ["alice"], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Assigned task", + }, + false, + ); + await core.createTask( + { + id: "task-2", + title: "Unassigned Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Other task", + }, + false, + ); + + const result = await $`bun ${CLI_PATH} task list --plain --assignee alice`.cwd(TEST_DIR).quiet(); + const out = result.stdout.toString(); + expect(out).toContain("task-1 - Assigned Task"); + expect(out).not.toContain("task-2 - Unassigned Task"); + }); + }); + + describe("task view command", () => { + beforeEach(async () => { + // Set up a git repository and initialize backlog + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("View Test Project"); + }); + + it("should display task details with markdown formatting", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + const testTask = { + id: "task-1", + title: "Test View Task", + status: "To Do", + assignee: ["testuser"], + createdDate: "2025-06-08", + labels: ["test", "cli"], + dependencies: [], + rawContent: "This is a test task for view command", + }; + + await core.createTask(testTask, false); + + // Load the task back + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask).not.toBeNull(); + expect(loadedTask?.id).toBe("task-1"); + expect(loadedTask?.title).toBe("Test View Task"); + expect(loadedTask?.status).toBe("To Do"); + expect(loadedTask?.assignee).toEqual(["testuser"]); + expect(loadedTask?.labels).toEqual(["test", "cli"]); + expect(loadedTask?.rawContent).toBe("This is a test task for view command"); + }); + + it("should handle task IDs with and without 'task-' prefix", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-5", + title: "Prefix Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Testing task ID normalization", + }, + false, + ); + + // Test loading with full task-5 ID + const taskWithPrefix = await core.filesystem.loadTask("task-5"); + expect(taskWithPrefix?.id).toBe("task-5"); + + // Test loading with just numeric ID (5) + const taskWithoutPrefix = await core.filesystem.loadTask("5"); + // The filesystem loadTask should handle normalization + expect(taskWithoutPrefix?.id).toBe("task-5"); + }); + + it("should return null for non-existent tasks", async () => { + const core = new Core(TEST_DIR); + + const nonExistentTask = await core.filesystem.loadTask("task-999"); + expect(nonExistentTask).toBeNull(); + }); + + it("should not modify task files (read-only operation)", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + const originalTask = { + id: "task-1", + title: "Read Only Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: ["readonly"], + dependencies: [], + rawContent: "Original description", + }; + + await core.createTask(originalTask, false); + + // Load the task (simulating view operation) + const viewedTask = await core.filesystem.loadTask("task-1"); + + // Load again to verify nothing changed + const secondView = await core.filesystem.loadTask("task-1"); + + expect(viewedTask).toEqual(secondView); + expect(viewedTask?.title).toBe("Read Only Test"); + expect(viewedTask?.rawContent).toBe("Original description"); + }); + }); + + describe("task shortcut command", () => { + beforeEach(async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Shortcut Test Project"); + }); + + it("should display formatted task details like the view command", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-1", + title: "Shortcut Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Shortcut description", + }, + false, + ); + + const resultShortcut = await viewTaskPlatformAware({ taskId: "1", plain: true }, TEST_DIR); + const resultView = await viewTaskPlatformAware({ taskId: "1", plain: true, useViewCommand: true }, TEST_DIR); + + const outShortcut = resultShortcut.stdout; + const outView = resultView.stdout; + + expect(outShortcut).toBe(outView); + expect(outShortcut).toContain("Task task-1 - Shortcut Task"); + }); + }); + + describe("task edit command", () => { + beforeEach(async () => { + // Set up a git repository and initialize backlog + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Edit Test Project", true); + }); + + it("should update task title, description, and status", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-1", + title: "Original Title", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Original description", + }, + false, + ); + + // Load and edit the task + const task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + + await core.updateTaskFromInput( + "task-1", + { + title: "Updated Title", + description: "Updated description", + status: "In Progress", + }, + false, + ); + + // Verify changes were persisted + const updatedTask = await core.filesystem.loadTask("task-1"); + expect(updatedTask?.title).toBe("Updated Title"); + expect(extractStructuredSection(updatedTask?.rawContent || "", "description")).toBe("Updated description"); + expect(updatedTask?.status).toBe("In Progress"); + const today = new Date().toISOString().slice(0, 16).replace("T", " "); + expect(updatedTask?.updatedDate).toBe(today); + }); + + it("should update assignee", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-2", + title: "Assignee Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Testing assignee updates", + }, + false, + ); + + // Update assignee + await core.updateTaskFromInput("task-2", { assignee: ["newuser@example.com"] }, false); + + // Verify assignee was updated + const updatedTask = await core.filesystem.loadTask("task-2"); + expect(updatedTask?.assignee).toEqual(["newuser@example.com"]); + }); + + it("should replace all labels with new labels", async () => { + const core = new Core(TEST_DIR); + + // Create a test task with existing labels + await core.createTask( + { + id: "task-3", + title: "Label Replace Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: ["old1", "old2"], + dependencies: [], + rawContent: "Testing label replacement", + }, + false, + ); + + // Replace all labels + await core.updateTaskFromInput("task-3", { labels: ["new1", "new2", "new3"] }, false); + + // Verify labels were replaced + const updatedTask = await core.filesystem.loadTask("task-3"); + expect(updatedTask?.labels).toEqual(["new1", "new2", "new3"]); + }); + + it("should add labels without replacing existing ones", async () => { + const core = new Core(TEST_DIR); + + // Create a test task with existing labels + await core.createTask( + { + id: "task-4", + title: "Label Add Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: ["existing"], + dependencies: [], + rawContent: "Testing label addition", + }, + false, + ); + + // Add new labels + await core.updateTaskFromInput("task-4", { addLabels: ["added1", "added2"] }, false); + + // Verify labels were added + const updatedTask = await core.filesystem.loadTask("task-4"); + expect(updatedTask?.labels).toEqual(["existing", "added1", "added2"]); + }); + + it("should remove specific labels", async () => { + const core = new Core(TEST_DIR); + + // Create a test task with multiple labels + await core.createTask( + { + id: "task-5", + title: "Label Remove Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: ["keep1", "remove", "keep2"], + dependencies: [], + rawContent: "Testing label removal", + }, + false, + ); + + // Remove specific label + await core.updateTaskFromInput("task-5", { removeLabels: ["remove"] }, false); + + // Verify label was removed + const updatedTask = await core.filesystem.loadTask("task-5"); + expect(updatedTask?.labels).toEqual(["keep1", "keep2"]); + }); + + it("should handle non-existent task gracefully", async () => { + const core = new Core(TEST_DIR); + + const nonExistentTask = await core.filesystem.loadTask("task-999"); + expect(nonExistentTask).toBeNull(); + }); + + it("should automatically set updated_date field when editing", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-6", + title: "Updated Date Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + rawContent: "Testing updated date", + }, + false, + ); + + // Edit the task (without manually setting updatedDate) + await core.updateTaskFromInput("task-6", { title: "Updated Title" }, false); + + // Verify updated_date was automatically set to today's date + const updatedTask = await core.filesystem.loadTask("task-6"); + const today = new Date().toISOString().slice(0, 16).replace("T", " "); + expect(updatedTask?.updatedDate).toBe(today); + expect(updatedTask?.createdDate).toBe("2025-06-07"); // Should remain unchanged + }); + + it("should commit changes automatically", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-7", + title: "Commit Test", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Testing auto-commit", + }, + false, + ); + + // Edit the task with auto-commit enabled + await core.updateTaskFromInput("task-7", { title: "Updated for Commit" }, true); + + // Verify the task was updated (this confirms the update functionality works) + const updatedTask = await core.filesystem.loadTask("task-7"); + expect(updatedTask?.title).toBe("Updated for Commit"); + + // For now, just verify that updateTask with autoCommit=true doesn't throw + // The actual git commit functionality is tested at the Core level + }); + + it("should preserve YAML frontmatter formatting", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-8", + title: "YAML Test", + status: "To Do", + assignee: ["testuser"], + createdDate: "2025-06-08", + labels: ["yaml", "test"], + dependencies: ["task-1"], + rawContent: "Testing YAML preservation", + }, + false, + ); + + // Edit the task + await core.updateTaskFromInput( + "task-8", + { + title: "Updated YAML Test", + status: "In Progress", + }, + false, + ); + + // Verify all frontmatter fields are preserved + const updatedTask = await core.filesystem.loadTask("task-8"); + expect(updatedTask?.id).toBe("task-8"); + expect(updatedTask?.title).toBe("Updated YAML Test"); + expect(updatedTask?.status).toBe("In Progress"); + expect(updatedTask?.assignee).toEqual(["testuser"]); + expect(updatedTask?.createdDate).toBe("2025-06-08"); + const today = new Date().toISOString().slice(0, 16).replace("T", " "); + expect(updatedTask?.updatedDate).toBe(today); + expect(updatedTask?.labels).toEqual(["yaml", "test"]); + expect(updatedTask?.dependencies).toEqual(["task-1"]); + expect(updatedTask?.rawContent).toBe("Testing YAML preservation"); + }); + }); + + describe("task archive and state transition commands", () => { + beforeEach(async () => { + // Set up a git repository and initialize backlog + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Archive Test Project"); + }); + + it("should archive a task", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-1", + title: "Archive Test Task", + status: "Done", + assignee: [], + createdDate: "2025-06-08", + labels: ["completed"], + dependencies: [], + rawContent: "Task ready for archiving", + }, + false, + ); + + // Archive the task + const success = await core.archiveTask("task-1", false); + expect(success).toBe(true); + + // Verify task is no longer in tasks directory + const task = await core.filesystem.loadTask("task-1"); + expect(task).toBeNull(); + + // Verify task exists in archive + const { readdir } = await import("node:fs/promises"); + const archiveFiles = await readdir(join(TEST_DIR, "backlog", "archive", "tasks")); + expect(archiveFiles.some((f) => f.startsWith("task-1"))).toBe(true); + }); + + it("should handle archiving non-existent task", async () => { + const core = new Core(TEST_DIR); + + const success = await core.archiveTask("task-999", false); + expect(success).toBe(false); + }); + + it("should demote task to drafts", async () => { + const core = new Core(TEST_DIR); + + // Create a test task + await core.createTask( + { + id: "task-2", + title: "Demote Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: ["needs-revision"], + dependencies: [], + rawContent: "Task that needs to go back to drafts", + }, + false, + ); + + // Demote the task + const success = await core.demoteTask("task-2", false); + expect(success).toBe(true); + + // Verify task is no longer in tasks directory + const task = await core.filesystem.loadTask("task-2"); + expect(task).toBeNull(); + + // Verify task now exists as a draft + const draft = await core.filesystem.loadDraft("task-2"); + expect(draft?.id).toBe("task-2"); + expect(draft?.title).toBe("Demote Test Task"); + }); + + it("should promote draft to tasks", async () => { + const core = new Core(TEST_DIR); + + // Create a test draft + await core.createDraft( + { + id: "task-3", + title: "Promote Test Draft", + status: "Draft", + assignee: [], + createdDate: "2025-06-08", + labels: ["ready"], + dependencies: [], + rawContent: "Draft ready for promotion", + }, + false, + ); + + // Promote the draft + const success = await core.promoteDraft("task-3", false); + expect(success).toBe(true); + + // Verify draft is no longer in drafts directory + const draft = await core.filesystem.loadDraft("task-3"); + expect(draft).toBeNull(); + + // Verify draft now exists as a task + const task = await core.filesystem.loadTask("task-3"); + expect(task?.id).toBe("task-3"); + expect(task?.title).toBe("Promote Test Draft"); + }); + + it("should archive a draft", async () => { + const core = new Core(TEST_DIR); + + // Create a test draft + await core.createDraft( + { + id: "task-4", + title: "Archive Test Draft", + status: "Draft", + assignee: [], + createdDate: "2025-06-08", + labels: ["cancelled"], + dependencies: [], + rawContent: "Draft that should be archived", + }, + false, + ); + + // Archive the draft + const success = await core.archiveDraft("task-4", false); + expect(success).toBe(true); + + // Verify draft is no longer in drafts directory + const draft = await core.filesystem.loadDraft("task-4"); + expect(draft).toBeNull(); + + // Verify draft exists in archive + const { readdir } = await import("node:fs/promises"); + const archiveFiles = await readdir(join(TEST_DIR, "backlog", "archive", "drafts")); + expect(archiveFiles.some((f) => f.startsWith("task-4"))).toBe(true); + }); + + it("should handle promoting non-existent draft", async () => { + const core = new Core(TEST_DIR); + + const success = await core.promoteDraft("task-999", false); + expect(success).toBe(false); + }); + + it("should handle demoting non-existent task", async () => { + const core = new Core(TEST_DIR); + + const success = await core.demoteTask("task-999", false); + expect(success).toBe(false); + }); + + it("should handle archiving non-existent draft", async () => { + const core = new Core(TEST_DIR); + + const success = await core.archiveDraft("task-999", false); + expect(success).toBe(false); + }); + + it("should commit archive operations automatically", async () => { + const core = new Core(TEST_DIR); + + // Create and archive a task with auto-commit + await core.createTask( + { + id: "task-5", + title: "Commit Archive Test", + status: "Done", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "Testing auto-commit on archive", + }, + false, + ); + + const success = await core.archiveTask("task-5", true); // autoCommit = true + expect(success).toBe(true); + + // Verify operation completed successfully + const task = await core.filesystem.loadTask("task-5"); + expect(task).toBeNull(); + }); + + it("should preserve task content through state transitions", async () => { + const core = new Core(TEST_DIR); + + // Create a task with rich content + const originalTask = { + id: "task-6", + title: "Content Preservation Test", + status: "In Progress", + assignee: ["testuser"], + createdDate: "2025-06-08", + labels: ["important", "preservation-test"], + dependencies: ["task-1", "task-2"], + rawContent: "This task has rich metadata that should be preserved through transitions", + }; + + await core.createTask(originalTask, false); + + // Demote to draft + await core.demoteTask("task-6", false); + const asDraft = await core.filesystem.loadDraft("task-6"); + + expect(asDraft?.title).toBe(originalTask.title); + expect(asDraft?.assignee).toEqual(originalTask.assignee); + expect(asDraft?.labels).toEqual(originalTask.labels); + expect(asDraft?.dependencies).toEqual(originalTask.dependencies); + expect(asDraft?.rawContent).toContain(originalTask.rawContent); + + // Promote back to task + await core.promoteDraft("task-6", false); + const backToTask = await core.filesystem.loadTask("task-6"); + + expect(backToTask?.title).toBe(originalTask.title); + expect(backToTask?.assignee).toEqual(originalTask.assignee); + expect(backToTask?.labels).toEqual(originalTask.labels); + expect(backToTask?.dependencies).toEqual(originalTask.dependencies); + expect(backToTask?.rawContent).toContain(originalTask.rawContent); + }); + }); + + describe("doc and decision commands", () => { + beforeEach(async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Doc Test Project"); + }); + + it("should create and list documents", async () => { + const core = new Core(TEST_DIR); + const doc: Document = { + id: "doc-1", + title: "Guide", + type: "guide", + createdDate: "2025-06-08", + rawContent: "Content", + }; + await core.createDocument(doc, false); + + const docs = await core.filesystem.listDocuments(); + expect(docs).toHaveLength(1); + expect(docs[0]?.title).toBe("Guide"); + }); + + it("should create and list decisions", async () => { + const core = new Core(TEST_DIR); + const decision: Decision = { + id: "decision-1", + title: "Choose Stack", + date: "2025-06-08", + status: "accepted", + context: "context", + decision: "decide", + consequences: "conseq", + rawContent: "", + }; + await core.createDecision(decision, false); + const decisions = await core.filesystem.listDecisions(); + expect(decisions).toHaveLength(1); + expect(decisions[0]?.title).toBe("Choose Stack"); + }); + }); + + describe("board view command", () => { + beforeEach(async () => { + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Board Test Project", true); + }); + + it("should display kanban board with tasks grouped by status", async () => { + const core = new Core(TEST_DIR); + + // Create test tasks with different statuses + await core.createTask( + { + id: "task-1", + title: "Todo Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "A task in todo", + }, + false, + ); + + await core.createTask( + { + id: "task-2", + title: "Progress Task", + status: "In Progress", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "A task in progress", + }, + false, + ); + + await core.createTask( + { + id: "task-3", + title: "Done Task", + status: "Done", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "A completed task", + }, + false, + ); + + const tasks = await core.filesystem.listTasks(); + expect(tasks).toHaveLength(3); + + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + expect(statuses).toEqual(["To Do", "In Progress", "Done"]); + + // Test the kanban board generation + const { generateKanbanBoardWithMetadata } = await import("../board.ts"); + const board = generateKanbanBoardWithMetadata(tasks, statuses, "Test Project"); + + // Verify board contains all statuses and tasks (now on separate lines) + expect(board).toContain("To Do"); + expect(board).toContain("In Progress"); + expect(board).toContain("Done"); + expect(board).toContain("TASK-1"); + expect(board).toContain("Todo Task"); + expect(board).toContain("TASK-2"); + expect(board).toContain("Progress Task"); + expect(board).toContain("TASK-3"); + expect(board).toContain("Done Task"); + + // Verify board structure (now includes metadata header) + const lines = board.split("\n"); + expect(board).toContain("# Kanban Board Export"); + expect(board).toContain("To Do"); + expect(board).toContain("In Progress"); + expect(board).toContain("Done"); + expect(board).toContain("|"); // Table structure + expect(lines.length).toBeGreaterThan(5); // Should have content rows + }); + + it("should handle empty project with default statuses", async () => { + const core = new Core(TEST_DIR); + + const tasks = await core.filesystem.listTasks(); + expect(tasks).toHaveLength(0); + + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + const { generateKanbanBoardWithMetadata } = await import("../board.ts"); + const board = generateKanbanBoardWithMetadata(tasks, statuses, "Test Project"); + + // Should return board with metadata, configured status columns, and empty-state message + expect(board).toContain("# Kanban Board Export"); + expect(board).toContain("| To Do | In Progress | Done |"); + expect(board).toContain("No tasks found"); + }); + + it("should support vertical layout option", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-1", + title: "Todo Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "A task in todo", + }, + false, + ); + + const tasks = await core.filesystem.listTasks(); + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + const { generateKanbanBoardWithMetadata } = await import("../board.ts"); + const board = generateKanbanBoardWithMetadata(tasks, statuses, "Test Project"); + + // Should contain proper board structure + expect(board).toContain("# Kanban Board Export"); + expect(board).toContain("To Do"); + expect(board).toContain("TASK-1"); + expect(board).toContain("Todo Task"); + }); + + it("should support --vertical shortcut flag", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-1", + title: "Shortcut Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-09", + labels: [], + dependencies: [], + rawContent: "Testing vertical shortcut", + }, + false, + ); + + const tasks = await core.filesystem.listTasks(); + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + // Test that --vertical flag produces vertical layout + const { generateKanbanBoardWithMetadata } = await import("../board.ts"); + const board = generateKanbanBoardWithMetadata(tasks, statuses, "Test Project"); + + // Should contain proper board structure + expect(board).toContain("# Kanban Board Export"); + expect(board).toContain("To Do"); + expect(board).toContain("TASK-1"); + expect(board).toContain("Shortcut Task"); + }); + + it("should merge task status from remote branches", async () => { + const core = new Core(TEST_DIR); + + const task = { + id: "task-1", + title: "Remote Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-09", + labels: [], + dependencies: [], + rawContent: "from remote", + } as Task; + + await core.createTask(task, true); + + // set up remote repository + const remoteDir = join(TEST_DIR, "remote.git"); + await $`git init --bare -b main ${remoteDir}`.quiet(); + await $`git remote add origin ${remoteDir}`.cwd(TEST_DIR).quiet(); + await $`git push -u origin main`.cwd(TEST_DIR).quiet(); + + // create branch with updated status + await $`git checkout -b feature`.cwd(TEST_DIR).quiet(); + await core.updateTaskFromInput("task-1", { status: "Done" }, true); + await $`git push -u origin feature`.cwd(TEST_DIR).quiet(); + + // Update remote-tracking branches to ensure they are recognized + await $`git remote update origin --prune`.cwd(TEST_DIR).quiet(); + + // switch back to main where status is still To Do + await $`git checkout main`.cwd(TEST_DIR).quiet(); + + await core.gitOps.fetch(); + const branches = await core.gitOps.listRemoteBranches(); + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + const localTasks = await core.filesystem.listTasks(); + const tasksById = new Map(localTasks.map((t) => [t.id, t])); + + for (const branch of branches) { + const ref = `origin/${branch}`; + const files = await core.gitOps.listFilesInTree(ref, "backlog/tasks"); + for (const file of files) { + const content = await core.gitOps.showFile(ref, file); + const remoteTask = parseTask(content); + const existing = tasksById.get(remoteTask.id); + const currentIdx = existing ? statuses.indexOf(existing.status) : -1; + const newIdx = statuses.indexOf(remoteTask.status); + if (!existing || newIdx > currentIdx || currentIdx === -1 || newIdx === currentIdx) { + tasksById.set(remoteTask.id, remoteTask); + } + } + } + + const final = tasksById.get("task-1"); + expect(final?.status).toBe("Done"); + }); + + it("should default to view when no subcommand is provided", async () => { + const core = new Core(TEST_DIR); + + await core.createTask( + { + id: "task-99", + title: "Default Cmd Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-10", + labels: [], + dependencies: [], + rawContent: "test", + }, + false, + ); + + const resultDefault = await $`bun ${["src/cli.ts", "board"]}`.cwd(TEST_DIR).quiet().nothrow(); + const resultView = await $`bun ${["src/cli.ts", "board", "view"]}`.cwd(TEST_DIR).quiet().nothrow(); + + expect(resultDefault.stdout.toString()).toBe(resultView.stdout.toString()); + }); + + it("should export kanban board to file", async () => { + const core = new Core(TEST_DIR); + + // Create test tasks + await core.createTask( + { + id: "task-1", + title: "Export Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-09", + labels: [], + dependencies: [], + rawContent: "Testing board export", + }, + false, + ); + + const { exportKanbanBoardToFile } = await import("../index.ts"); + const outputPath = join(TEST_DIR, "test-export.md"); + const tasks = await core.filesystem.listTasks(); + const config = await core.filesystem.loadConfig(); + const statuses = config?.statuses || []; + + await exportKanbanBoardToFile(tasks, statuses, outputPath, "TestProject"); + + // Verify file was created and contains expected content + const content = await Bun.file(outputPath).text(); + expect(content).toContain("To Do"); + expect(content).toContain("TASK-1"); + expect(content).toContain("Export Test Task"); + expect(content).toContain("# Kanban Board Export (powered by Backlog.md)"); + expect(content).toContain("Project: TestProject"); + + // Test overwrite behavior + await exportKanbanBoardToFile(tasks, statuses, outputPath, "TestProject"); + const overwrittenContent = await Bun.file(outputPath).text(); + const occurrences = overwrittenContent.split("TASK-1").length - 1; + expect(occurrences).toBe(1); // Should appear once after overwrite + }); + }); +}); diff --git a/src/test/code-path.test.ts b/src/test/code-path.test.ts new file mode 100644 index 0000000..857d649 --- /dev/null +++ b/src/test/code-path.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; +import { + CODE_PATH_PATTERNS, + extractCodePaths, + isCodePath, + styleCodePath, + transformCodePaths, + transformCodePathsPlain, +} from "../ui/code-path.ts"; + +describe("Code path utilities", () => { + describe("CODE_PATH_PATTERNS", () => { + test("should match backticked file paths", () => { + const testCases = [ + "`src/cli.ts`", + "`package.json`", + "`/Users/name/project/file.ts`", + "`./relative/path.js`", + "`../parent/file.md`", + "`C:\\Windows\\file.exe`", + ]; + + for (const testCase of testCases) { + // Reset regex for each test case + CODE_PATH_PATTERNS.BACKTICKED_PATH.lastIndex = 0; + expect(CODE_PATH_PATTERNS.BACKTICKED_PATH.test(testCase)).toBe(true); + } + }); + + test("should not match non-path backticks", () => { + const testCases = ["`just code`", "`function name`", "`variable`", "`123`"]; + + for (const testCase of testCases) { + // Reset regex lastIndex + CODE_PATH_PATTERNS.BACKTICKED_PATH.lastIndex = 0; + const match = testCase.match(CODE_PATH_PATTERNS.BACKTICKED_PATH); + if (match) { + const content = match[0].slice(1, -1); + expect(isCodePath(content)).toBe(false); + } + } + }); + }); + + describe("isCodePath", () => { + test("should detect file paths with extensions", () => { + expect(isCodePath("src/cli.ts")).toBe(true); + expect(isCodePath("package.json")).toBe(true); + expect(isCodePath("file.md")).toBe(true); + expect(isCodePath("/full/path/file.js")).toBe(true); + }); + + test("should detect paths with separators", () => { + expect(isCodePath("src/utils")).toBe(true); + expect(isCodePath("folder/subfolder")).toBe(true); + expect(isCodePath("/absolute/path")).toBe(true); + expect(isCodePath("C:\\Windows\\path")).toBe(true); + }); + + test("should not detect non-paths", () => { + expect(isCodePath("just text")).toBe(false); + expect(isCodePath("function")).toBe(false); + expect(isCodePath("variable")).toBe(false); + expect(isCodePath("123")).toBe(false); + }); + }); + + describe("extractCodePaths", () => { + test("should extract file paths from text", () => { + const text = "Check `src/cli.ts` and `package.json` for details."; + const result = extractCodePaths(text); + expect(result).toEqual(["src/cli.ts", "package.json"]); + }); + + test("should ignore non-path backticks", () => { + const text = "Use `function` to call `src/cli.ts` method."; + const result = extractCodePaths(text); + expect(result).toEqual(["src/cli.ts"]); + }); + + test("should handle empty or no matches", () => { + expect(extractCodePaths("No paths here")).toEqual([]); + expect(extractCodePaths("Only `variables` here")).toEqual([]); + expect(extractCodePaths("")).toEqual([]); + }); + + test("should handle complex paths", () => { + const text = "Files: `/absolute/path/file.ts`, `./relative/file.js`, `../parent/file.md`"; + const result = extractCodePaths(text); + expect(result).toEqual(["/absolute/path/file.ts", "./relative/file.js", "../parent/file.md"]); + }); + }); + + describe("styleCodePath", () => { + test("should wrap path in gray styling tags", () => { + const result = styleCodePath("src/cli.ts"); + expect(result).toBe("{gray-fg}`src/cli.ts`{/gray-fg}"); + }); + + test("should handle paths with special characters", () => { + const result = styleCodePath("/path/with-dashes_and.underscores.ts"); + expect(result).toBe("{gray-fg}`/path/with-dashes_and.underscores.ts`{/gray-fg}"); + }); + }); + + describe("transformCodePaths", () => { + test("should style isolated code paths", () => { + const text = "Check this file: `src/cli.ts`"; + const result = transformCodePaths(text); + expect(result).toBe("Check this file:\n{gray-fg}`src/cli.ts`{/gray-fg}"); + }); + + test("should extract and separate multiple paths in prose", () => { + const text = "Modify `src/cli.ts` and `src/ui/board.ts` to implement the feature."; + const result = transformCodePaths(text); + expect(result).toBe( + "Modify and to implement the feature.\n{gray-fg}`src/cli.ts`{/gray-fg}\n{gray-fg}`src/ui/board.ts`{/gray-fg}", + ); + }); + + test("should preserve line breaks", () => { + const text = "First line with `file1.ts`\nSecond line with `file2.js`"; + const result = transformCodePaths(text); + expect(result).toContain("First line with\n{gray-fg}`file1.ts`{/gray-fg}"); + expect(result).toContain("Second line with\n{gray-fg}`file2.js`{/gray-fg}"); + }); + + test("should handle text without code paths", () => { + const text = "This is just regular text with `variables` and `functions`."; + const result = transformCodePaths(text); + expect(result).toBe(text); + }); + + test("should handle empty input", () => { + expect(transformCodePaths("")).toBe(""); + // biome-ignore lint/suspicious/noExplicitAny: testing null/undefined inputs + expect(transformCodePaths(null as any)).toBe(""); + // biome-ignore lint/suspicious/noExplicitAny: testing null/undefined inputs + expect(transformCodePaths(undefined as any)).toBe(""); + }); + + test("should handle only a path on a line", () => { + const text = "`src/cli.ts`"; + const result = transformCodePaths(text); + expect(result).toBe("{gray-fg}`src/cli.ts`{/gray-fg}"); + }); + }); + + describe("transformCodePathsPlain", () => { + test("should preserve code paths in plain text", () => { + const text = "Check `src/cli.ts` and `package.json` files."; + const result = transformCodePathsPlain(text); + expect(result).toBe("Check `src/cli.ts` and `package.json` files."); + }); + + test("should ignore non-path backticks", () => { + const text = "Use `function` to call `src/cli.ts` method."; + const result = transformCodePathsPlain(text); + expect(result).toBe("Use `function` to call `src/cli.ts` method."); + }); + + test("should handle empty input", () => { + expect(transformCodePathsPlain("")).toBe(""); + // biome-ignore lint/suspicious/noExplicitAny: testing null/undefined inputs + expect(transformCodePathsPlain(null as any)).toBe(""); + // biome-ignore lint/suspicious/noExplicitAny: testing null/undefined inputs + expect(transformCodePathsPlain(undefined as any)).toBe(""); + }); + }); + + describe("comprehensive path detection", () => { + test("should capture 100% of code paths in test fixture", () => { + const testFixture = ` +Implementation details: +- Update \`src/cli.ts\` to add new command +- Modify \`src/ui/task-viewer-with-search.ts\` for display +- Check \`package.json\` for dependencies +- Test with \`/absolute/path/test.js\` +- Relative paths: \`./src/utils.ts\` and \`../config/settings.json\` + +Also review the \`README.md\` file and \`biome.json\` configuration. +Windows paths like \`C:\\Users\\name\\file.txt\` should work too. + `.trim(); + + const extractedPaths = extractCodePaths(testFixture); + + // Verify we captured all expected paths + const expectedPaths = [ + "src/cli.ts", + "src/ui/task-viewer-with-search.ts", + "package.json", + "/absolute/path/test.js", + "./src/utils.ts", + "../config/settings.json", + "README.md", + "biome.json", + "C:\\Users\\name\\file.txt", + ]; + + expect(extractedPaths).toEqual(expectedPaths); + expect(extractedPaths.length).toBe(9); + }); + }); +}); diff --git a/src/test/config-commands.test.ts b/src/test/config-commands.test.ts new file mode 100644 index 0000000..84aa3c4 --- /dev/null +++ b/src/test/config-commands.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import type { PromptRunner } from "../commands/advanced-config-wizard.ts"; +import { configureAdvancedSettings } from "../commands/configure-advanced-settings.ts"; +import { Core } from "../core/backlog.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("Config commands", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-config-commands"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests - required for CI + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test Config Project"); + }); + + function createPromptStub(sequence: Array<Record<string, unknown>>): PromptRunner { + const stub: PromptRunner = async () => { + return sequence.shift() ?? {}; + }; + return stub; + } + + it("configureAdvancedSettings keeps defaults when no changes requested", async () => { + const promptStub = createPromptStub([ + { installCompletions: false }, + { checkActiveBranches: true }, + { remoteOperations: true }, + { activeBranchDays: 30 }, + { bypassGitHooks: false }, + { autoCommit: false }, + { enableZeroPadding: false }, + { editor: "" }, + { configureWebUI: false }, + { installClaudeAgent: false }, + ]); + + const { mergedConfig, installClaudeAgent, installShellCompletions } = await configureAdvancedSettings(core, { + promptImpl: promptStub, + }); + + expect(installClaudeAgent).toBe(false); + expect(installShellCompletions).toBe(false); + expect(mergedConfig.checkActiveBranches).toBe(true); + expect(mergedConfig.remoteOperations).toBe(true); + expect(mergedConfig.activeBranchDays).toBe(30); + expect(mergedConfig.bypassGitHooks).toBe(false); + expect(mergedConfig.autoCommit).toBe(false); + expect(mergedConfig.zeroPaddedIds).toBeUndefined(); + expect(mergedConfig.defaultEditor).toBeUndefined(); + expect(mergedConfig.defaultPort).toBe(6420); + expect(mergedConfig.autoOpenBrowser).toBe(true); + + const reloadedConfig = await core.filesystem.loadConfig(); + expect(reloadedConfig?.defaultPort).toBe(6420); + expect(reloadedConfig?.autoOpenBrowser).toBe(true); + }); + + it("configureAdvancedSettings applies wizard selections", async () => { + const promptStub = createPromptStub([ + { installCompletions: true }, + { checkActiveBranches: true }, + { remoteOperations: false }, + { activeBranchDays: 14 }, + { bypassGitHooks: true }, + { autoCommit: true }, + { enableZeroPadding: true }, + { paddingWidth: 4 }, + { editor: "echo" }, + { configureWebUI: true }, + { defaultPort: 7007, autoOpenBrowser: false }, + { installClaudeAgent: true }, + ]); + + const { mergedConfig, installClaudeAgent, installShellCompletions } = await configureAdvancedSettings(core, { + promptImpl: promptStub, + }); + + expect(installClaudeAgent).toBe(true); + expect(installShellCompletions).toBe(true); + expect(mergedConfig.checkActiveBranches).toBe(true); + expect(mergedConfig.remoteOperations).toBe(false); + expect(mergedConfig.activeBranchDays).toBe(14); + expect(mergedConfig.bypassGitHooks).toBe(true); + expect(mergedConfig.autoCommit).toBe(true); + expect(mergedConfig.zeroPaddedIds).toBe(4); + expect(mergedConfig.defaultEditor).toBe("echo"); + expect(mergedConfig.defaultPort).toBe(7007); + expect(mergedConfig.autoOpenBrowser).toBe(false); + + const reloadedConfig = await core.filesystem.loadConfig(); + expect(reloadedConfig?.zeroPaddedIds).toBe(4); + expect(reloadedConfig?.defaultEditor).toBe("echo"); + expect(reloadedConfig?.defaultPort).toBe(7007); + expect(reloadedConfig?.autoOpenBrowser).toBe(false); + expect(reloadedConfig?.bypassGitHooks).toBe(true); + expect(reloadedConfig?.autoCommit).toBe(true); + }); + + it("exposes config list/get/set subcommands", async () => { + const listOutput = await $`bun ${CLI_PATH} config list`.cwd(TEST_DIR).text(); + expect(listOutput).toContain("Configuration:"); + + await $`bun ${CLI_PATH} config set defaultPort 7001`.cwd(TEST_DIR).quiet(); + + const portOutput = await $`bun ${CLI_PATH} config get defaultPort`.cwd(TEST_DIR).text(); + expect(portOutput.trim()).toBe("7001"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should save and load defaultEditor config", async () => { + // Load initial config + const config = await core.filesystem.loadConfig(); + expect(config).toBeTruthy(); + expect(config?.defaultEditor).toBeUndefined(); + + // Set defaultEditor + if (config) { + config.defaultEditor = "nano"; + await core.filesystem.saveConfig(config); + } + + // Reload config and verify it was saved + const reloadedConfig = await core.filesystem.loadConfig(); + expect(reloadedConfig).toBeTruthy(); + expect(reloadedConfig?.defaultEditor).toBe("nano"); + }); + + it("should handle config with and without defaultEditor", async () => { + // Initially undefined + let config = await core.filesystem.loadConfig(); + expect(config?.defaultEditor).toBeUndefined(); + + // Set to a value + if (config) { + config.defaultEditor = "vi"; + await core.filesystem.saveConfig(config); + } + + config = await core.filesystem.loadConfig(); + expect(config?.defaultEditor).toBe("vi"); + + // Clear the value + if (config) { + config.defaultEditor = undefined; + await core.filesystem.saveConfig(config); + } + + config = await core.filesystem.loadConfig(); + expect(config?.defaultEditor).toBeUndefined(); + }); + + it("should preserve other config values when setting defaultEditor", async () => { + let config = await core.filesystem.loadConfig(); + const originalProjectName = config?.projectName; + const originalStatuses = config ? [...config.statuses] : []; + + // Set defaultEditor + if (config) { + config.defaultEditor = "code"; + await core.filesystem.saveConfig(config); + } + + // Reload and verify other values are preserved + config = await core.filesystem.loadConfig(); + expect(config?.defaultEditor).toBe("code"); + expect(config?.projectName).toBe(originalProjectName ?? ""); + expect(config?.statuses).toEqual(originalStatuses); + }); +}); diff --git a/src/test/config-hang-repro.test.ts b/src/test/config-hang-repro.test.ts new file mode 100644 index 0000000..ed3afe0 --- /dev/null +++ b/src/test/config-hang-repro.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { FileSystem } from "../file-system/operations.ts"; +import type { BacklogConfig } from "../types/index.ts"; + +describe("Config Loading & Migration", () => { + const testRoot = "/tmp/test-config-migration"; + const backlogDir = join(testRoot, "backlog"); + const configPath = join(backlogDir, "config.yml"); + + beforeEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + await mkdir(backlogDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it("should load config from standard backlog directory", async () => { + const config = `project_name: "Test Project" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +milestones: [] +default_status: "To Do" +date_format: "yyyy-mm-dd" +max_column_width: 20 +auto_commit: false`; + + await writeFile(configPath, config); + + const fs = new FileSystem(testRoot); + + // This should complete without hanging + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Config loading timed out - infinite loop detected!")), 5000); + }); + + const loadedConfig = (await Promise.race([fs.loadConfig(), timeoutPromise])) as BacklogConfig | null; + + expect(loadedConfig).toBeTruthy(); + expect(loadedConfig?.projectName).toBe("Test Project"); + }); + + it("should migrate legacy .backlog directory to backlog", async () => { + // Create a legacy .backlog directory instead of backlog + const legacyBacklogDir = join(testRoot, ".backlog"); + const legacyConfigPath = join(legacyBacklogDir, "config.yml"); + + await rm(backlogDir, { recursive: true, force: true }); + await mkdir(legacyBacklogDir, { recursive: true }); + + const legacyConfig = `project_name: "Legacy Project" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +milestones: [] +default_status: "To Do" +date_format: "yyyy-mm-dd" +max_column_width: 20 +auto_commit: false`; + + await writeFile(legacyConfigPath, legacyConfig); + + const fs = new FileSystem(testRoot); + const config = await fs.loadConfig(); + + // Check that config was loaded + expect(config).toBeTruthy(); + expect(config?.projectName).toBe("Legacy Project"); + + // Check that the directory was renamed + const newBacklogExists = await Bun.file(join(testRoot, "backlog", "config.yml")).exists(); + const oldBacklogExists = await Bun.file(join(testRoot, ".backlog", "config.yml")).exists(); + + expect(newBacklogExists).toBe(true); + expect(oldBacklogExists).toBe(false); + }); +}); diff --git a/src/test/content-store.test.ts b/src/test/content-store.test.ts new file mode 100644 index 0000000..d5bcb8a --- /dev/null +++ b/src/test/content-store.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { ContentStore, type ContentStoreEvent } from "../core/content-store.ts"; +import { FileSystem } from "../file-system/operations.ts"; +import type { Decision, Document, Task } from "../types/index.ts"; +import { createUniqueTestDir, getPlatformTimeout, safeCleanup, sleep } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("ContentStore", () => { + let filesystem: FileSystem; + let store: ContentStore; + + const sampleTask: Task = { + id: "task-1", + title: "Sample Task", + status: "To Do", + assignee: [], + createdDate: "2025-09-19 10:00", + labels: [], + dependencies: [], + rawContent: "## Description\nSeed content", + }; + + const sampleDecision: Decision = { + id: "decision-1", + title: "Adopt shared cache", + date: "2025-09-19", + status: "proposed", + context: "Context", + decision: "Decision text", + consequences: "Consequences", + rawContent: "## Context\nContext\n\n## Decision\nDecision text\n\n## Consequences\nConsequences", + }; + + const sampleDocument: Document = { + id: "doc-1", + title: "Architecture Guide", + type: "guide", + createdDate: "2025-09-19", + rawContent: "# Architecture Guide", + }; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-content-store"); + filesystem = new FileSystem(TEST_DIR); + await filesystem.ensureBacklogStructure(); + store = new ContentStore(filesystem); + }); + + afterEach(async () => { + store?.dispose(); + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + it("loads tasks, documents, and decisions during initialization", async () => { + await filesystem.saveTask(sampleTask); + await filesystem.saveDecision(sampleDecision); + await filesystem.saveDocument(sampleDocument); + + const snapshot = await store.ensureInitialized(); + + expect(snapshot.tasks).toHaveLength(1); + expect(snapshot.documents).toHaveLength(1); + expect(snapshot.decisions).toHaveLength(1); + expect(snapshot.tasks.map((task) => task.id)).toContain("task-1"); + }); + + it("emits task updates when underlying files change", async () => { + await filesystem.saveTask(sampleTask); + await store.ensureInitialized(); + + const waitForUpdate = waitForEventWithTimeout(store, (event) => { + return event.type === "tasks" && event.tasks.some((task) => task.title === "Updated Task"); + }); + + await filesystem.saveTask({ ...sampleTask, title: "Updated Task" }); + await waitForUpdate; + + const tasks = store.getTasks(); + expect(tasks.map((task) => task.title)).toContain("Updated Task"); + }); + + it("updates documents when new files are added", async () => { + await store.ensureInitialized(); + + const waitForDocument = waitForEventWithTimeout(store, (event) => { + return event.type === "documents" && event.documents.some((doc) => doc.id === "doc-2"); + }); + + await filesystem.saveDocument( + { + ...sampleDocument, + id: "doc-2", + title: "Implementation Notes", + rawContent: "# Implementation Notes", + }, + "guides", + ); + + await waitForDocument; + + const documents = store.getDocuments(); + expect(documents.some((doc) => doc.id === "doc-2")).toBe(true); + }); + + it("preserves cross-branch tasks from the task loader during refresh", async () => { + await filesystem.saveTask(sampleTask); + + const remoteTask: Task = { + id: "task-remote", + title: "Remote Task", + status: "In Progress", + assignee: ["alice"], + createdDate: "2025-10-01 12:00", + labels: ["remote"], + dependencies: [], + rawContent: "## Description\nRemote content", + source: "remote", + }; + + let loaderCalls = 0; + store.dispose(); + store = new ContentStore(filesystem, async () => { + loaderCalls += 1; + const localTasks = await filesystem.listTasks(); + return [...localTasks, remoteTask]; + }); + + await store.ensureInitialized(); + expect(store.getTasks().map((task) => task.id)).toContain("task-remote"); + + await (store as unknown as { refreshTasksFromDisk: () => Promise<void> }).refreshTasksFromDisk(); + + const refreshedTasks = store.getTasks(); + expect(refreshedTasks.map((task) => task.id)).toContain("task-remote"); + expect(loaderCalls).toBeGreaterThanOrEqual(2); + }); + + it("removes decisions when files are deleted", async () => { + store.dispose(); + store = new ContentStore(filesystem, undefined, true); + await filesystem.saveDecision(sampleDecision); + await store.ensureInitialized(); + + const decisionsDir = filesystem.decisionsDir; + const decisionFiles: string[] = []; + for await (const file of new Bun.Glob("decision-*.md").scan({ cwd: decisionsDir })) { + decisionFiles.push(file); + } + const decisionFile = decisionFiles.find((file) => file.startsWith("decision-1")); + if (!decisionFile) { + throw new Error("Expected decision file was not created"); + } + + const waitForRemoval = waitForEventWithTimeout(store, (event) => { + return event.type === "decisions" && event.decisions.every((decision) => decision.id !== "decision-1"); + }); + + await unlink(join(decisionsDir, decisionFile)); + await waitForRemoval; + + const decisions = store.getDecisions(); + expect(decisions.find((decision) => decision.id === "decision-1")).toBeUndefined(); + }); +}); + +function waitForEventWithTimeout( + store: ContentStore, + predicate: (event: ContentStoreEvent) => boolean, + timeout = getPlatformTimeout(), +): Promise<ContentStoreEvent> { + const eventPromise = new Promise<ContentStoreEvent>((resolve) => { + const unsubscribe = store.subscribe((event) => { + if (!predicate(event)) { + return; + } + unsubscribe(); + resolve(event); + }); + }); + + return Promise.race([ + eventPromise, + sleep(timeout).then(() => { + throw new Error("Timed out waiting for content store event"); + }), + ]); +} diff --git a/src/test/core.test.ts b/src/test/core.test.ts new file mode 100644 index 0000000..eb27fec --- /dev/null +++ b/src/test/core.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { Document, Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Core", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-core"); + core = new Core(TEST_DIR); + await core.filesystem.ensureBacklogStructure(); + + // Initialize git repository for testing + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("initialization", () => { + it("should have filesystem and git operations available", () => { + expect(core.filesystem).toBeDefined(); + expect(core.gitOps).toBeDefined(); + }); + + it("should initialize project with default config", async () => { + await core.initializeProject("Test Project", true); + + const config = await core.filesystem.loadConfig(); + expect(config?.projectName).toBe("Test Project"); + expect(config?.statuses).toEqual(["To Do", "In Progress", "Done"]); + expect(config?.defaultStatus).toBe("To Do"); + }); + }); + + describe("task operations", () => { + const sampleTask: Task = { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: ["test"], + dependencies: [], + description: "This is a test task", + }; + + beforeEach(async () => { + await core.initializeProject("Test Project", true); + }); + + it("should create task without auto-commit", async () => { + await core.createTask(sampleTask, false); + + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask?.id).toBe("task-1"); + expect(loadedTask?.title).toBe("Test Task"); + }); + + it("should create task with auto-commit", async () => { + await core.createTask(sampleTask, true); + + // Check if task file was created + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask?.id).toBe("task-1"); + + // Check git status to see if there are uncommitted changes + const _hasChanges = await core.gitOps.hasUncommittedChanges(); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + // For now, just check that we have a commit (could be initialization or task) + expect(lastCommit).toBeDefined(); + expect(lastCommit.length).toBeGreaterThan(0); + }); + + it("should update task with auto-commit", async () => { + await core.createTask(sampleTask, true); + + // Check original task + const originalTask = await core.filesystem.loadTask("task-1"); + expect(originalTask?.title).toBe("Test Task"); + + await core.updateTaskFromInput("task-1", { title: "Updated Task" }, true); + + // Check if task was updated + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask?.title).toBe("Updated Task"); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + // For now, just check that we have a commit (could be initialization or task) + expect(lastCommit).toBeDefined(); + expect(lastCommit.length).toBeGreaterThan(0); + }); + + it("should archive task with auto-commit", async () => { + await core.createTask(sampleTask, true); + + const archived = await core.archiveTask("task-1", true); + expect(archived).toBe(true); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toContain("backlog: Archive task task-1"); + }); + + it("should demote task with auto-commit", async () => { + await core.createTask(sampleTask, true); + + const demoted = await core.demoteTask("task-1", true); + expect(demoted).toBe(true); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toContain("backlog: Demote task task-1"); + }); + + it("should resolve tasks using flexible ID formats", async () => { + const standardTask: Task = { ...sampleTask, id: "task-5", title: "Standard" }; + const paddedTask: Task = { ...sampleTask, id: "task-007", title: "Padded" }; + await core.createTask(standardTask, false); + await core.createTask(paddedTask, false); + + const uppercase = await core.getTask("TASK-5"); + expect(uppercase?.id).toBe("task-5"); + + const bare = await core.getTask("5"); + expect(bare?.id).toBe("task-5"); + + const zeroPadded = await core.getTask("0007"); + expect(zeroPadded?.id).toBe("task-007"); + + const mixedCase = await core.getTask("Task-007"); + expect(mixedCase?.id).toBe("task-007"); + }); + + it("should return false when archiving non-existent task", async () => { + const archived = await core.archiveTask("non-existent", true); + expect(archived).toBe(false); + }); + + it("should apply default status when task has empty status", async () => { + const taskWithoutStatus: Task = { + ...sampleTask, + status: "", + }; + + await core.createTask(taskWithoutStatus, false); + + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask?.status).toBe("To Do"); // Should use default from config + }); + + it("should not override existing status", async () => { + const taskWithStatus: Task = { + ...sampleTask, + status: "In Progress", + }; + + await core.createTask(taskWithStatus, false); + + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask?.status).toBe("In Progress"); + }); + + it("should preserve description text when saving without header markers", async () => { + const taskNoHeader: Task = { + ...sampleTask, + id: "task-2", + description: "Just text", + }; + + await core.createTask(taskNoHeader, false); + const loaded = await core.filesystem.loadTask("task-2"); + expect(loaded?.description).toBe("Just text"); + const body = await core.getTaskContent("task-2"); + const matches = (body?.match(/## Description/g) ?? []).length; + expect(matches).toBe(1); + }); + + it("should not duplicate description header in saved content", async () => { + const taskWithHeader: Task = { + ...sampleTask, + id: "task-3", + description: "Existing", + }; + + await core.createTask(taskWithHeader, false); + const body = await core.getTaskContent("task-3"); + const matches = (body?.match(/## Description/g) ?? []).length; + expect(matches).toBe(1); + }); + + it("should handle task creation without auto-commit when git fails", async () => { + // Create task in directory without git + const nonGitCore = new Core(join(TEST_DIR, "no-git")); + await nonGitCore.filesystem.ensureBacklogStructure(); + + // This should succeed even without git + await nonGitCore.createTask(sampleTask, false); + + const loadedTask = await nonGitCore.filesystem.loadTask("task-1"); + expect(loadedTask?.id).toBe("task-1"); + }); + + it("should normalize assignee for string and array inputs", async () => { + const stringTask = { + ...sampleTask, + id: "task-2", + title: "String Assignee", + assignee: "@alice", + } as unknown as Task; + await core.createTask(stringTask, false); + const loadedString = await core.filesystem.loadTask("task-2"); + expect(loadedString?.assignee).toEqual(["@alice"]); + + const arrayTask: Task = { + ...sampleTask, + id: "task-3", + title: "Array Assignee", + assignee: ["@bob"], + }; + await core.createTask(arrayTask, false); + const loadedArray = await core.filesystem.loadTask("task-3"); + expect(loadedArray?.assignee).toEqual(["@bob"]); + }); + + it("should normalize assignee when updating tasks", async () => { + await core.createTask(sampleTask, false); + + await core.updateTaskFromInput("task-1", { assignee: ["@carol"] }, false); + let loaded = await core.filesystem.loadTask("task-1"); + expect(loaded?.assignee).toEqual(["@carol"]); + + await core.updateTaskFromInput("task-1", { assignee: ["@dave"] }, false); + loaded = await core.filesystem.loadTask("task-1"); + expect(loaded?.assignee).toEqual(["@dave"]); + }); + + it("should create sub-tasks with proper hierarchical IDs", async () => { + await core.initializeProject("Subtask Project", true); + + // Create parent task + const { task: parent } = await core.createTaskFromInput({ + title: "Parent Task", + status: "To Do", + }); + expect(parent.id).toBe("task-1"); + + // Create first sub-task + const { task: child1 } = await core.createTaskFromInput({ + title: "First Child", + parentTaskId: parent.id, + status: "To Do", + }); + expect(child1.id).toBe("task-1.1"); + expect(child1.parentTaskId).toBe("task-1"); + + // Create second sub-task + const { task: child2 } = await core.createTaskFromInput({ + title: "Second Child", + parentTaskId: parent.id, + status: "To Do", + }); + expect(child2.id).toBe("task-1.2"); + expect(child2.parentTaskId).toBe("task-1"); + + // Create another parent task to ensure sequential numbering still works + const { task: parent2 } = await core.createTaskFromInput({ + title: "Second Parent", + status: "To Do", + }); + expect(parent2.id).toBe("task-2"); + }); + }); + + describe("document operations", () => { + const baseDocument: Document = { + id: "doc-1", + title: "Operations Guide", + type: "guide", + createdDate: "2025-06-07", + rawContent: "# Ops Guide", + }; + + beforeEach(async () => { + await core.initializeProject("Test Project", false); + }); + + it("updates a document title without leaving the previous file behind", async () => { + await core.createDocument(baseDocument, false); + + const [initialFile] = await Array.fromAsync(new Bun.Glob("doc-*.md").scan({ cwd: core.filesystem.docsDir })); + expect(initialFile).toBe("doc-1 - Operations-Guide.md"); + + const documents = await core.filesystem.listDocuments(); + const existingDoc = documents[0]; + if (!existingDoc) { + throw new Error("Expected document to exist after creation"); + } + expect(existingDoc.title).toBe("Operations Guide"); + + await core.updateDocument({ ...existingDoc, title: "Operations Guide Updated" }, "# Updated content", false); + + const docFiles = await Array.fromAsync(new Bun.Glob("doc-*.md").scan({ cwd: core.filesystem.docsDir })); + expect(docFiles).toHaveLength(1); + expect(docFiles[0]).toBe("doc-1 - Operations-Guide-Updated.md"); + + const updatedDocs = await core.filesystem.listDocuments(); + expect(updatedDocs[0]?.title).toBe("Operations Guide Updated"); + }); + + it("shows a git rename when the document title changes", async () => { + await core.createDocument(baseDocument, true); + + const renamedDoc: Document = { + ...baseDocument, + title: "Operations Guide Renamed", + }; + + await core.updateDocument(renamedDoc, "# Ops Guide", false); + + await $`git add -A`.cwd(TEST_DIR).quiet(); + const diffResult = await $`git diff --name-status -M HEAD`.cwd(TEST_DIR).quiet(); + const diff = diffResult.stdout.toString(); + const previousPath = "backlog/docs/doc-1 - Operations-Guide.md"; + const renamedPath = "backlog/docs/doc-1 - Operations-Guide-Renamed.md"; + const escapeForRegex = (value: string) => value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); + expect(diff).toMatch( + new RegExp(`^R\\d*\\t${escapeForRegex(previousPath)}\\t${escapeForRegex(renamedPath)}`, "m"), + ); + }); + }); + + describe("draft operations", () => { + const sampleDraft: Task = { + id: "task-draft", + title: "Draft Task", + status: "Draft", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Draft task", + }; + + beforeEach(async () => { + await core.initializeProject("Draft Project", true); + }); + + it("should create draft without auto-commit", async () => { + await core.createDraft(sampleDraft, false); + + const loaded = await core.filesystem.loadDraft("task-draft"); + expect(loaded?.id).toBe("task-draft"); + }); + + it("should create draft with auto-commit", async () => { + await core.createDraft(sampleDraft, true); + + const loaded = await core.filesystem.loadDraft("task-draft"); + expect(loaded?.id).toBe("task-draft"); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toBeDefined(); + expect(lastCommit.length).toBeGreaterThan(0); + }); + + it("should promote draft with auto-commit", async () => { + await core.createDraft(sampleDraft, true); + + const promoted = await core.promoteDraft("task-draft", true); + expect(promoted).toBe(true); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toContain("backlog: Promote draft task-draft"); + }); + + it("should archive draft with auto-commit", async () => { + await core.createDraft(sampleDraft, true); + + const archived = await core.archiveDraft("task-draft", true); + expect(archived).toBe(true); + + const lastCommit = await core.gitOps.getLastCommitMessage(); + expect(lastCommit).toContain("backlog: Archive draft task-draft"); + }); + + it("should normalize assignee for string and array inputs", async () => { + const draftString = { + ...sampleDraft, + id: "task-draft-1", + title: "Draft String", + assignee: "@erin", + } as unknown as Task; + await core.createDraft(draftString, false); + const loadedString = await core.filesystem.loadDraft("task-draft-1"); + expect(loadedString?.assignee).toEqual(["@erin"]); + + const draftArray: Task = { + ...sampleDraft, + id: "task-draft-2", + title: "Draft Array", + assignee: ["@frank"], + }; + await core.createDraft(draftArray, false); + const loadedArray = await core.filesystem.loadDraft("task-draft-2"); + expect(loadedArray?.assignee).toEqual(["@frank"]); + }); + }); + + describe("integration with config", () => { + it("should use custom default status from config", async () => { + // Initialize with custom config + await core.initializeProject("Custom Project"); + + // Update config with custom default status + const config = await core.filesystem.loadConfig(); + if (config) { + config.defaultStatus = "Custom Status"; + await core.filesystem.saveConfig(config); + } + + const taskWithoutStatus: Task = { + id: "task-custom", + title: "Custom Task", + status: "", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Task without status", + }; + + await core.createTask(taskWithoutStatus, false); + + const loadedTask = await core.filesystem.loadTask("task-custom"); + expect(loadedTask?.status).toBe("Custom Status"); + }); + + it("should fall back to To Do when config has no default status", async () => { + // Initialize project + await core.initializeProject("Fallback Project"); + + // Update config to remove default status + const config = await core.filesystem.loadConfig(); + if (config) { + config.defaultStatus = undefined; + await core.filesystem.saveConfig(config); + } + + const taskWithoutStatus: Task = { + id: "task-fallback", + title: "Fallback Task", + status: "", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Task without status", + }; + + await core.createTask(taskWithoutStatus, false); + + const loadedTask = await core.filesystem.loadTask("task-fallback"); + expect(loadedTask?.status).toBe("To Do"); + }); + }); + + describe("directory accessor integration", () => { + it("should use FileSystem directory accessors for git operations", async () => { + await core.initializeProject("Accessor Test"); + + const task: Task = { + id: "task-accessor", + title: "Accessor Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Testing directory accessors", + }; + + // Create task without auto-commit to avoid potential git timing issues + await core.createTask(task, false); + + // Verify the task file was created in the correct directory + const _tasksDir = core.filesystem.tasksDir; + + // List all files to see what was actually created + const allFiles = await core.filesystem.listTasks(); + + // Check that a task with the expected ID exists + const createdTask = allFiles.find((t) => t.id === "task-accessor"); + expect(createdTask).toBeDefined(); + expect(createdTask?.title).toBe("Accessor Test Task"); + }, 10000); + }); +}); diff --git a/src/test/dependency.test.ts b/src/test/dependency.test.ts new file mode 100644 index 0000000..9b0fa30 --- /dev/null +++ b/src/test/dependency.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; + +describe("Task Dependencies", () => { + let tempDir: string; + let core: Core; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "backlog-dependency-test-")); + + // Initialize git repository first using the same pattern as other tests + await $`git init -b main`.cwd(tempDir).quiet(); + await $`git config user.name "Test User"`.cwd(tempDir).quiet(); + await $`git config user.email test@example.com`.cwd(tempDir).quiet(); + + core = new Core(tempDir); + await core.initializeProject("test-project"); + }); + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + console.warn(`Failed to clean up temp directory: ${error}`); + } + }); + + test("should create task with dependencies", async () => { + // Create base tasks first + const task1: Task = { + id: "task-1", + title: "Base Task 1", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Base task", + }; + + const task2: Task = { + id: "task-2", + title: "Base Task 2", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Another base task", + }; + + await core.createTask(task1, false); + await core.createTask(task2, false); + + // Create task with dependencies + const dependentTask: Task = { + id: "task-3", + title: "Dependent Task", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: ["task-1", "task-2"], + description: "Task that depends on others", + }; + + await core.createTask(dependentTask, false); + + // Verify the task was created with dependencies + const savedTask = await core.filesystem.loadTask("task-3"); + expect(savedTask).not.toBeNull(); + expect(savedTask?.dependencies).toEqual(["task-1", "task-2"]); + }); + + test("should update task dependencies", async () => { + // Create base tasks + const task1: Task = { + id: "task-1", + title: "Base Task 1", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Base task", + }; + + const task2: Task = { + id: "task-2", + title: "Base Task 2", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Another base task", + }; + + const task3: Task = { + id: "task-3", + title: "Task without dependencies", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Task without dependencies initially", + }; + + await core.createTask(task1, false); + await core.createTask(task2, false); + await core.createTask(task3, false); + + // Update task to add dependencies + await core.updateTaskFromInput(task3.id, { dependencies: ["task-1", "task-2"] }, false); + + // Verify the dependencies were updated + const savedTask = await core.filesystem.loadTask("task-3"); + expect(savedTask).not.toBeNull(); + expect(savedTask?.dependencies).toEqual(["task-1", "task-2"]); + }); + + test("should handle tasks with dependencies in drafts", async () => { + // Create a draft task + const draftTask: Task = { + id: "task-1", + title: "Draft Task", + status: "Draft", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Draft task", + }; + + await core.createDraft(draftTask, false); + + // Create task that depends on draft + const task2: Task = { + id: "task-2", + title: "Task depending on draft", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: ["task-1"], // Depends on draft task + description: "Task depending on draft", + }; + + await core.createTask(task2, false); + + // Verify the task was created with dependency on draft + const savedTask = await core.filesystem.loadTask("task-2"); + expect(savedTask).not.toBeNull(); + expect(savedTask?.dependencies).toEqual(["task-1"]); + }); + + test("should serialize and deserialize dependencies correctly", async () => { + const task: Task = { + id: "task-1", + title: "Task with multiple dependencies", + status: "In Progress", + assignee: ["@developer"], + createdDate: "2024-01-01", + labels: ["feature", "backend"], + dependencies: ["task-2", "task-3", "task-4"], + description: "Task with various metadata and dependencies", + }; + + // Create dependency tasks first + for (let i = 2; i <= 4; i++) { + const depTask: Task = { + id: `task-${i}`, + title: `Dependency Task ${i}`, + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: `Dependency task ${i}`, + }; + await core.createTask(depTask, false); + } + + await core.createTask(task, false); + + // Load the task back and verify all fields + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask).not.toBeNull(); + expect(loadedTask?.id).toBe("task-1"); + expect(loadedTask?.title).toBe("Task with multiple dependencies"); + expect(loadedTask?.status).toBe("In Progress"); + expect(loadedTask?.assignee).toEqual(["@developer"]); + expect(loadedTask?.labels).toEqual(["feature", "backend"]); + expect(loadedTask?.dependencies).toEqual(["task-2", "task-3", "task-4"]); + }); + + test("should handle empty dependencies array", async () => { + const task: Task = { + id: "task-1", + title: "Task without dependencies", + status: "To Do", + assignee: [], + createdDate: "2024-01-01", + labels: [], + dependencies: [], + description: "Task without dependencies", + }; + + await core.createTask(task, false); + + const loadedTask = await core.filesystem.loadTask("task-1"); + expect(loadedTask).not.toBeNull(); + expect(loadedTask?.dependencies).toEqual([]); + }); +}); diff --git a/src/test/desc-alias.test.ts b/src/test/desc-alias.test.ts new file mode 100644 index 0000000..82d30d4 --- /dev/null +++ b/src/test/desc-alias.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("--desc alias functionality", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-desc-alias"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo first + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email "test@example.com"`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core + const core = new Core(TEST_DIR); + await core.initializeProject("Desc Alias Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should create task with --desc alias", async () => { + const _result = await $`bun ${cliPath} task create "Test --desc alias" --desc "Created with --desc"` + .cwd(TEST_DIR) + .quiet(); + + // Check that command succeeded (no exception thrown) + const output = await $`bun ${cliPath} task 1 --plain`.cwd(TEST_DIR).text(); + expect(output).toContain("Test --desc alias"); + expect(output).toContain("Created with --desc"); + }); + + it("should verify task created with --desc has correct description", async () => { + // Create task with --desc + await $`bun ${cliPath} task create "Test task" --desc "Description via --desc"`.cwd(TEST_DIR).quiet(); + + // Verify the task was created with correct description + const core = new Core(TEST_DIR); + const task = await core.filesystem.loadTask("task-1"); + + expect(task).not.toBeNull(); + expect(task?.description).toContain("Description via --desc"); + }); + + it("should edit task description with --desc alias", async () => { + // Create initial task + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Edit test task", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Original description", + }, + false, + ); + + // Edit with --desc + await $`bun ${cliPath} task edit 1 --desc "Updated via --desc"`.cwd(TEST_DIR).quiet(); + + // Command succeeded without throwing + + // Verify the description was updated + const updatedTask = await core.filesystem.loadTask("task-1"); + expect(updatedTask?.description).toContain("Updated via --desc"); + }); + + it("should create draft with --desc alias", async () => { + await $`bun ${cliPath} draft create "Draft with --desc" --desc "Draft description"`.cwd(TEST_DIR).quiet(); + + // Command succeeded without throwing + }); + + it("should verify draft created with --desc has correct description", async () => { + // Create draft with --desc + await $`bun ${cliPath} draft create "Test draft" --desc "Draft via --desc"`.cwd(TEST_DIR).quiet(); + + // Verify the draft was created with correct description + const core = new Core(TEST_DIR); + const draft = await core.filesystem.loadDraft("task-1"); + + expect(draft).not.toBeNull(); + expect(draft?.description).toContain("Draft via --desc"); + }); + + it("should show --desc in help text", async () => { + const result = await $`bun ${cliPath} task create --help`.cwd(TEST_DIR).text(); + + expect(result).toContain("-d, --description <text>"); + expect(result).toContain("--desc <text>"); + expect(result).toContain("alias for --description"); + }); +}); diff --git a/src/test/description-newlines.test.ts b/src/test/description-newlines.test.ts new file mode 100644 index 0000000..126feb1 --- /dev/null +++ b/src/test/description-newlines.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("CLI description newline handling", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-desc-newlines"); + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch {} + await mkdir(TEST_DIR, { recursive: true }); + + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email "test@example.com"`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Desc Newlines Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch {} + }); + + it("should preserve literal newlines when creating task", async () => { + const desc = "First line\nSecond line\n\nThird paragraph"; + await $`bun ${[cliPath, "task", "create", "Multi-line", "--desc", desc]}`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + const body = await core.getTaskContent("task-1"); + expect(body).toContain(desc); + }); + + it("should preserve literal newlines when editing task", async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Edit me", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Original", + }, + false, + ); + + const desc = "First line\nSecond line\n\nThird paragraph"; + await $`bun ${[cliPath, "task", "edit", "1", "--desc", desc]}`.cwd(TEST_DIR).quiet(); + + const updatedBody = await core.getTaskContent("task-1"); + expect(updatedBody).toContain(desc); + }); + + it("should not interpret \\n sequences as newlines", async () => { + const literal = "First line\\nSecond line"; + await $`bun ${[cliPath, "task", "create", "Literal", "--desc", literal]}`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + const body = await core.getTaskContent("task-1"); + expect(body).toContain("First line\\nSecond line"); + }); +}); diff --git a/src/test/docs-recursive.test.ts b/src/test/docs-recursive.test.ts new file mode 100644 index 0000000..c1fd7aa --- /dev/null +++ b/src/test/docs-recursive.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { Core } from "../index.ts"; + +let TEST_DIR: string; + +describe("Docs recursive listing and ID generation", () => { + beforeEach(async () => { + TEST_DIR = join(process.cwd(), `.tmp-test-docs-${Math.random().toString(36).slice(2)}`); + await rm(TEST_DIR, { recursive: true, force: true }); + await mkdir(TEST_DIR, { recursive: true }); + + // Init backlog project + const core = new Core(TEST_DIR); + await core.initializeProject("Docs Test"); + + // Disable remote operations to simulate offline mode + const cfg = await core.filesystem.loadConfig(); + if (cfg) { + cfg.remoteOperations = false; + await core.filesystem.saveConfig(cfg); + } + }); + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }); + }); + + it("lists and views documents from subdirectories and generates unique IDs", async () => { + const core = new Core(TEST_DIR); + + // Create docs in nested paths using Core API directly + await core.createDocument( + { id: "doc-1", title: "Top", type: "other", createdDate: "2020-01-01", rawContent: "Top level doc" }, + false, + "", + ); + await core.createDocument( + { id: "doc-2", title: "Nested A", type: "other", createdDate: "2020-01-02", rawContent: "Nested A content" }, + false, + "guides", + ); + await core.createDocument( + { id: "doc-3", title: "Nested B", type: "other", createdDate: "2020-01-03", rawContent: "Nested B content" }, + false, + "guides/sub", + ); + + // List should include all 3 documents + const docs = await core.filesystem.listDocuments(); + const docIds = docs.map((d) => d.id); + expect(docIds).toContain("doc-1"); + expect(docIds).toContain("doc-2"); + expect(docIds).toContain("doc-3"); + + // View by id should work (verify document can be retrieved) + const doc2 = await core.getDocument("doc-2"); + expect(doc2).not.toBeNull(); + expect(doc2?.title).toBe("Nested A"); + + // Create doc-4 directly to test that IDs 1-3 are recognized + // (This verifies that listing works correctly for ID generation purposes) + await core.createDocument( + { id: "doc-4", title: "Another", type: "other", createdDate: "2020-01-04", rawContent: "New doc content" }, + false, + "guides", + ); + + // Verify doc-4 exists + const allDocs = await core.filesystem.listDocuments(); + const hasDoc4 = allDocs.some((d) => d.id === "doc-4"); + expect(hasDoc4).toBe(true); + expect(allDocs.length).toBe(4); + }); +}); diff --git a/src/test/editor.test.ts b/src/test/editor.test.ts new file mode 100644 index 0000000..93927c7 --- /dev/null +++ b/src/test/editor.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { BacklogConfig } from "../types/index.ts"; +import { isEditorAvailable, openInEditor, resolveEditor } from "../utils/editor.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +describe("Editor utilities", () => { + let originalEditor: string | undefined; + + beforeEach(() => { + // Save original EDITOR env var + originalEditor = process.env.EDITOR; + }); + + afterEach(() => { + // Restore original EDITOR env var + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } else { + delete process.env.EDITOR; + } + }); + + describe("resolveEditor", () => { + it("should prioritize EDITOR environment variable over config defaultEditor", () => { + process.env.EDITOR = "vim"; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + defaultEditor: "code", + }; + + const editor = resolveEditor(config); + expect(editor).toBe("vim"); + }); + + it("should use config defaultEditor when EDITOR environment variable is not set", () => { + delete process.env.EDITOR; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + defaultEditor: "code", + }; + + const editor = resolveEditor(config); + expect(editor).toBe("code"); + }); + + it("should use EDITOR environment variable when config has no defaultEditor", () => { + process.env.EDITOR = "vim"; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + }; + + const editor = resolveEditor(config); + expect(editor).toBe("vim"); + }); + + it("should use platform default when neither config nor env var is set", () => { + delete process.env.EDITOR; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + }; + + const editor = resolveEditor(config); + // Should return a platform-specific default + expect(editor).toBeTruthy(); + expect(typeof editor).toBe("string"); + }); + + it("should return platform default when config is null", () => { + delete process.env.EDITOR; + const editor = resolveEditor(null); + expect(editor).toBeTruthy(); + expect(typeof editor).toBe("string"); + }); + }); + + describe("isEditorAvailable", () => { + it("should detect available editors", async () => { + // Test with a command that should exist on the current platform + const testEditor = process.platform === "win32" ? "notepad" : "ls"; + const available = await isEditorAvailable(testEditor); + // We can't guarantee any specific editor exists, so just verify the function works + expect(typeof available).toBe("boolean"); + }); + + it("should return false for non-existent editors", async () => { + const available = await isEditorAvailable("definitely-not-a-real-editor-command"); + expect(available).toBe(false); + }); + + it("should handle editor commands with arguments", async () => { + const editor = process.platform === "win32" ? "notepad.exe" : "echo test"; + const available = await isEditorAvailable(editor); + expect(available).toBe(true); + }); + }); + + describe("openInEditor", () => { + let TEST_DIR: string; + let testFile: string; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-editor"); + testFile = join(TEST_DIR, "test.txt"); + await mkdir(TEST_DIR, { recursive: true }); + await writeFile(testFile, "Test content"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + it("should open file with echo command for testing", async () => { + // Use echo as a safe test command that exists on all platforms + // Clear EDITOR env var so config.defaultEditor is used + delete process.env.EDITOR; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + defaultEditor: "echo", + }; + + const success = await openInEditor(testFile, config); + expect(success).toBe(true); + }); + + it("should handle editor command failure gracefully", async () => { + // Clear EDITOR env var so config.defaultEditor is used + delete process.env.EDITOR; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + defaultEditor: "definitely-not-a-real-editor", + }; + + const success = await openInEditor(testFile, config); + expect(success).toBe(false); + }); + + it("should wait for editor to complete before returning", async () => { + // Create a simple Node.js script that delays then exits + // This works cross-platform without needing shell/batch scripts + // Clear EDITOR env var so config.defaultEditor is used + delete process.env.EDITOR; + const scriptPath = join(TEST_DIR, "test-editor.js"); + const scriptContent = ` + setTimeout(() => { + process.exit(0); + }, 100); + `; + await Bun.write(scriptPath, scriptContent); + + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + defaultEditor: `node ${scriptPath}`, + }; + + const startTime = Date.now(); + const success = await openInEditor(testFile, config); + const endTime = Date.now(); + + expect(success).toBe(true); + // Should have waited at least 90ms (allowing some margin) + expect(endTime - startTime).toBeGreaterThanOrEqual(90); + }); + + it("should handle commands with arguments by splitting on spaces", async () => { + // Test that editors with flags work correctly (like "nvim -c 'set noshowmode'") + // Use echo with an argument as a simple test that exits immediately + // Clear EDITOR env var so config.defaultEditor is used + delete process.env.EDITOR; + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + defaultEditor: "echo test-argument", + }; + + const success = await openInEditor(testFile, config); + expect(success).toBe(true); + }); + }); +}); diff --git a/src/test/enhanced-init.test.ts b/src/test/enhanced-init.test.ts new file mode 100644 index 0000000..817a0a2 --- /dev/null +++ b/src/test/enhanced-init.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { Core } from "../core/backlog.ts"; +import type { BacklogConfig } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +describe("Enhanced init command", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = createUniqueTestDir("test-enhanced-init"); + }); + + afterEach(async () => { + try { + await safeCleanup(tmpDir); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + test("should detect existing project and preserve config during re-initialization", async () => { + const core = new Core(tmpDir); + + // First initialization + await core.initializeProject("Test Project"); + + // Verify initial config + const initialConfig = await core.filesystem.loadConfig(); + expect(initialConfig?.projectName).toBe("Test Project"); + expect(initialConfig?.autoCommit).toBe(false); + + // Modify some config values to test preservation + expect(initialConfig).toBeTruthy(); + if (!initialConfig) throw new Error("Config not loaded"); + const modifiedConfig: BacklogConfig = { + ...initialConfig, + projectName: initialConfig?.projectName ?? "Test Project", + autoCommit: true, + defaultEditor: "vim", + defaultPort: 8080, + }; + await core.filesystem.saveConfig(modifiedConfig); + + // Re-initialization should detect existing config + const existingConfig = await core.filesystem.loadConfig(); + expect(existingConfig).toBeTruthy(); + expect(existingConfig?.projectName).toBe("Test Project"); + expect(existingConfig?.autoCommit).toBe(true); + expect(existingConfig?.defaultEditor).toBe("vim"); + expect(existingConfig?.defaultPort).toBe(8080); + + // Verify backlog structure exists + const configExists = await Bun.file(join(tmpDir, "backlog", "config.yml")).exists(); + expect(configExists).toBe(true); + }); + + test("should create default config for new project initialization", async () => { + const core = new Core(tmpDir); + + // Check that no config exists initially + const initialConfig = await core.filesystem.loadConfig(); + expect(initialConfig).toBeNull(); + + // Initialize project + await core.initializeProject("New Project"); + + // Verify config was created with defaults + const config = await core.filesystem.loadConfig(); + expect(config).toBeTruthy(); + expect(config?.projectName).toBe("New Project"); + expect(config?.autoCommit).toBe(false); // Default value + expect(config?.statuses).toEqual(["To Do", "In Progress", "Done"]); + expect(config?.dateFormat).toBe("yyyy-mm-dd"); + }); + + test("should handle editor configuration in init flow", async () => { + const core = new Core(tmpDir); + + // Test that editor can be set and saved + const configWithEditor = { + projectName: "Editor Test Project", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + defaultStatus: "To Do", + dateFormat: "yyyy-mm-dd", + backlogDirectory: "backlog", + autoCommit: false, + remoteOperations: true, + defaultEditor: "code --wait", + }; + + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(configWithEditor); + + // Verify editor was saved + const loadedConfig = await core.filesystem.loadConfig(); + expect(loadedConfig?.defaultEditor).toBe("code --wait"); + }); + + test("should handle config with missing fields by filling defaults", async () => { + const core = new Core(tmpDir); + + // Create a minimal config (like from an older version) + const minimalConfig = { + projectName: "Legacy Project", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + defaultStatus: "To Do", + dateFormat: "yyyy-mm-dd", + }; + + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(minimalConfig); + + // Load config - should handle missing fields gracefully + const loadedConfig = await core.filesystem.loadConfig(); + expect(loadedConfig).toBeTruthy(); + expect(loadedConfig?.projectName).toBe("Legacy Project"); + expect(loadedConfig?.autoCommit).toBeUndefined(); // Missing fields should be undefined, not cause errors + }); + + test("should preserve existing statuses and labels during re-initialization", async () => { + const core = new Core(tmpDir); + + // Initialize with custom config + const customConfig = { + projectName: "Custom Project", + statuses: ["Backlog", "In Progress", "Review", "Done"], + labels: ["bug", "feature", "enhancement"], + milestones: ["v1.0", "v2.0"], + defaultStatus: "Backlog", + dateFormat: "dd/mm/yyyy", + maxColumnWidth: 30, + backlogDirectory: "backlog", + autoCommit: true, + }; + + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(customConfig); + + // Simulate re-initialization by loading existing config + const existingConfig = await core.filesystem.loadConfig(); + expect(existingConfig).toBeTruthy(); + expect(existingConfig?.statuses).toEqual(["Backlog", "In Progress", "Review", "Done"]); + expect(existingConfig?.labels).toEqual(["bug", "feature", "enhancement"]); + expect(existingConfig?.milestones).toEqual(["v1.0", "v2.0"]); + expect(existingConfig?.dateFormat).toBe("dd/mm/yyyy"); + expect(existingConfig?.maxColumnWidth).toBe(30); + }); + + test("should handle zero-padding configuration in init flow", async () => { + const core = new Core(tmpDir); + + // Test config with zero-padding enabled + const configWithPadding = { + projectName: "Padded Project", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + defaultStatus: "To Do", + dateFormat: "yyyy-mm-dd", + backlogDirectory: "backlog", + autoCommit: false, + remoteOperations: true, + zeroPaddedIds: 3, + }; + + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(configWithPadding); + + // Verify zero-padding was saved + const loadedConfig = await core.filesystem.loadConfig(); + expect(loadedConfig?.zeroPaddedIds).toBe(3); + + // Test that zero-padding config is available for ID generation + // (ID generation happens in CLI, not in Core.createTask) + expect(loadedConfig?.zeroPaddedIds).toBe(3); + }); + + test("should handle zero-padding disabled configuration", async () => { + const core = new Core(tmpDir); + + // Test config with zero-padding disabled + const configWithoutPadding = { + projectName: "Non-Padded Project", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + defaultStatus: "To Do", + dateFormat: "yyyy-mm-dd", + backlogDirectory: "backlog", + autoCommit: false, + remoteOperations: true, + zeroPaddedIds: 0, + }; + + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(configWithoutPadding); + + // Verify zero-padding was saved as disabled + const loadedConfig = await core.filesystem.loadConfig(); + expect(loadedConfig?.zeroPaddedIds).toBe(0); + + // Test that zero-padding is properly disabled + // (ID generation happens in CLI, not in Core.createTask) + expect(loadedConfig?.zeroPaddedIds).toBe(0); + }); + + test("should preserve existing zero-padding config during re-initialization", async () => { + const core = new Core(tmpDir); + + // Create initial config with padding + const initialConfig = { + projectName: "Test Project", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + defaultStatus: "To Do", + dateFormat: "yyyy-mm-dd", + backlogDirectory: "backlog", + autoCommit: false, + zeroPaddedIds: 4, + }; + + await core.filesystem.ensureBacklogStructure(); + await core.filesystem.saveConfig(initialConfig); + + // Simulate re-initialization by loading existing config + const existingConfig = await core.filesystem.loadConfig(); + expect(existingConfig).toBeTruthy(); + expect(existingConfig?.zeroPaddedIds).toBe(4); + + // Verify the padding config is preserved + // (ID generation happens in CLI, not in Core.createTask) + expect(existingConfig?.zeroPaddedIds).toBe(4); + }); +}); diff --git a/src/test/filesystem.test.ts b/src/test/filesystem.test.ts new file mode 100644 index 0000000..881a7d4 --- /dev/null +++ b/src/test/filesystem.test.ts @@ -0,0 +1,580 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { FileSystem } from "../file-system/operations.ts"; +import type { BacklogConfig, Decision, Document, Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("FileSystem", () => { + let filesystem: FileSystem; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-backlog"); + filesystem = new FileSystem(TEST_DIR); + await filesystem.ensureBacklogStructure(); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("ensureBacklogStructure", () => { + it("should create all required directories", async () => { + const expectedDirs = [ + join(TEST_DIR, "backlog"), + join(TEST_DIR, "backlog", "tasks"), + join(TEST_DIR, "backlog", "drafts"), + join(TEST_DIR, "backlog", "archive", "tasks"), + join(TEST_DIR, "backlog", "archive", "drafts"), + join(TEST_DIR, "backlog", "docs"), + join(TEST_DIR, "backlog", "decisions"), + ]; + + for (const dir of expectedDirs) { + const stats = await stat(dir); + expect(stats.isDirectory()).toBe(true); + } + }); + }); + + describe("task operations", () => { + const sampleTask: Task = { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: ["@developer"], + reporter: "@manager", + createdDate: "2025-06-03", + labels: ["test"], + milestone: "v1.0", + dependencies: [], + description: "This is a test task", + }; + + it("should save and load a task", async () => { + await filesystem.saveTask(sampleTask); + + const loadedTask = await filesystem.loadTask("task-1"); + expect(loadedTask?.id).toBe(sampleTask.id); + expect(loadedTask?.title).toBe(sampleTask.title); + expect(loadedTask?.status).toBe(sampleTask.status); + expect(loadedTask?.description).toBe(sampleTask.description); + }); + + it("should return null for non-existent task", async () => { + const task = await filesystem.loadTask("non-existent"); + expect(task).toBeNull(); + }); + + it("should list all tasks", async () => { + await filesystem.saveTask(sampleTask); + await filesystem.saveTask({ + ...sampleTask, + id: "task-2", + title: "Second Task", + }); + + const tasks = await filesystem.listTasks(); + expect(tasks).toHaveLength(2); + expect(tasks.map((t) => t.id)).toEqual(["task-1", "task-2"]); + }); + + it("should sort tasks numerically by ID", async () => { + // Create tasks with IDs that would sort incorrectly with string comparison + const taskIds = ["task-2", "task-10", "task-1", "task-20", "task-3"]; + for (const id of taskIds) { + await filesystem.saveTask({ + ...sampleTask, + id, + title: `Task ${id}`, + }); + } + + const tasks = await filesystem.listTasks(); + expect(tasks.map((t) => t.id)).toEqual(["task-1", "task-2", "task-3", "task-10", "task-20"]); + }); + + it("should sort tasks with decimal IDs correctly", async () => { + // Create tasks with decimal IDs + const taskIds = ["task-2.10", "task-2.2", "task-2", "task-1", "task-2.1"]; + for (const id of taskIds) { + await filesystem.saveTask({ + ...sampleTask, + id, + title: `Task ${id}`, + }); + } + + const tasks = await filesystem.listTasks(); + expect(tasks.map((t) => t.id)).toEqual(["task-1", "task-2", "task-2.1", "task-2.2", "task-2.10"]); + }); + + it("should filter tasks by status and assignee", async () => { + await filesystem.saveTask({ + ...sampleTask, + id: "task-1", + status: "To Do", + assignee: ["alice"], + title: "Task 1", + }); + await filesystem.saveTask({ + ...sampleTask, + id: "task-2", + status: "Done", + assignee: ["bob"], + title: "Task 2", + }); + await filesystem.saveTask({ + ...sampleTask, + id: "task-3", + status: "To Do", + assignee: ["bob"], + title: "Task 3", + }); + + const statusFiltered = await filesystem.listTasks({ status: "to do" }); + expect(statusFiltered.map((t) => t.id)).toEqual(["task-1", "task-3"]); + + const assigneeFiltered = await filesystem.listTasks({ assignee: "bob" }); + expect(assigneeFiltered.map((t) => t.id)).toEqual(["task-2", "task-3"]); + + const combinedFiltered = await filesystem.listTasks({ status: "to do", assignee: "bob" }); + expect(combinedFiltered.map((t) => t.id)).toEqual(["task-3"]); + }); + + it("should archive a task", async () => { + await filesystem.saveTask(sampleTask); + + const archived = await filesystem.archiveTask("task-1"); + expect(archived).toBe(true); + + const task = await filesystem.loadTask("task-1"); + expect(task).toBeNull(); + + // Check that file exists in archive + const archiveFiles = await readdir(join(TEST_DIR, "backlog", "archive", "tasks")); + expect(archiveFiles.some((f) => f.startsWith("task-1"))).toBe(true); + }); + + it("should demote a task to drafts", async () => { + await filesystem.saveTask(sampleTask); + + const demoted = await filesystem.demoteTask("task-1"); + expect(demoted).toBe(true); + + const draft = await filesystem.loadDraft("task-1"); + expect(draft?.id).toBe("task-1"); + }); + }); + + describe("draft operations", () => { + const sampleDraft: Task = { + id: "task-draft", + title: "Draft Task", + status: "Draft", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Draft description", + }; + + it("should save and load a draft", async () => { + await filesystem.saveDraft(sampleDraft); + + const loaded = await filesystem.loadDraft("task-draft"); + expect(loaded?.id).toBe(sampleDraft.id); + expect(loaded?.title).toBe(sampleDraft.title); + }); + + it("should list all drafts", async () => { + await filesystem.saveDraft(sampleDraft); + await filesystem.saveDraft({ ...sampleDraft, id: "task-draft2", title: "Second" }); + + const drafts = await filesystem.listDrafts(); + expect(drafts.map((d) => d.id)).toEqual(["task-draft", "task-draft2"]); + }); + + it("should promote a draft to tasks", async () => { + await filesystem.saveDraft(sampleDraft); + + const promoted = await filesystem.promoteDraft("task-draft"); + expect(promoted).toBe(true); + + const task = await filesystem.loadTask("task-draft"); + expect(task?.id).toBe("task-draft"); + }); + + it("should archive a draft", async () => { + await filesystem.saveDraft(sampleDraft); + + const archived = await filesystem.archiveDraft("task-draft"); + expect(archived).toBe(true); + + const draft = await filesystem.loadDraft("task-draft"); + expect(draft).toBeNull(); + + const files = await readdir(join(TEST_DIR, "backlog", "archive", "drafts")); + expect(files.some((f) => f.startsWith("task-draft"))).toBe(true); + }); + }); + + describe("config operations", () => { + const sampleConfig: BacklogConfig = { + projectName: "Test Project", + defaultAssignee: "@admin", + defaultStatus: "To Do", + defaultReporter: undefined, + statuses: ["To Do", "In Progress", "Done"], + labels: ["bug", "feature"], + milestones: ["v1.0", "v2.0"], + dateFormat: "yyyy-mm-dd", + }; + + it("should save and load config", async () => { + await filesystem.saveConfig(sampleConfig); + + const loadedConfig = await filesystem.loadConfig(); + expect(loadedConfig).toEqual(sampleConfig); + }); + + it("should return null for missing config", async () => { + // Create a fresh filesystem without any config + const freshFilesystem = new FileSystem(join(TEST_DIR, "fresh")); + await freshFilesystem.ensureBacklogStructure(); + + const config = await freshFilesystem.loadConfig(); + expect(config).toBeNull(); + }); + + it("should handle defaultReporter field", async () => { + const cfg: BacklogConfig = { + projectName: "Reporter", + defaultReporter: "@author", + statuses: ["To Do"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + }; + + await filesystem.saveConfig(cfg); + const loaded = await filesystem.loadConfig(); + expect(loaded?.defaultReporter).toBe("@author"); + }); + }); + + describe("user config operations", () => { + it("should save and load local and global user settings", async () => { + await filesystem.setUserSetting("reporter", "local", false); + await filesystem.setUserSetting("reporter", "global", true); + + const local = await filesystem.getUserSetting("reporter", false); + const global = await filesystem.getUserSetting("reporter", true); + + expect(local).toBe("local"); + expect(global).toBe("global"); + }); + }); + + describe("directory accessors", () => { + it("should provide correct directory paths", () => { + expect(filesystem.tasksDir).toBe(join(TEST_DIR, "backlog", "tasks")); + expect(filesystem.archiveTasksDir).toBe(join(TEST_DIR, "backlog", "archive", "tasks")); + expect(filesystem.decisionsDir).toBe(join(TEST_DIR, "backlog", "decisions")); + expect(filesystem.docsDir).toBe(join(TEST_DIR, "backlog", "docs")); + }); + }); + + describe("decision log operations", () => { + const sampleDecision: Decision = { + id: "decision-1", + title: "Use TypeScript", + date: "2025-06-07", + status: "accepted", + context: "Need type safety", + decision: "Use TypeScript", + consequences: "Better DX", + rawContent: "", + }; + + it("should save and load a decision log", async () => { + await filesystem.saveDecision(sampleDecision); + + const loadedDecision = await filesystem.loadDecision("decision-1"); + expect(loadedDecision?.id).toBe(sampleDecision.id); + expect(loadedDecision?.title).toBe(sampleDecision.title); + expect(loadedDecision?.status).toBe(sampleDecision.status); + expect(loadedDecision?.context).toBe(sampleDecision.context); + }); + + it("should return null for non-existent decision log", async () => { + const decision = await filesystem.loadDecision("non-existent"); + expect(decision).toBeNull(); + }); + + it("should save decision log with alternatives", async () => { + const decisionWithAlternatives: Decision = { + ...sampleDecision, + id: "decision-2", + alternatives: "Considered JavaScript", + }; + + await filesystem.saveDecision(decisionWithAlternatives); + const loaded = await filesystem.loadDecision("decision-2"); + + expect(loaded?.alternatives).toBe("Considered JavaScript"); + }); + + it("should list decision logs", async () => { + await filesystem.saveDecision(sampleDecision); + const list = await filesystem.listDecisions(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe(sampleDecision.id); + }); + }); + + describe("document operations", () => { + const sampleDocument: Document = { + id: "doc-1", + title: "API Guide", + type: "guide", + createdDate: "2025-06-07", + updatedDate: "2025-06-08", + rawContent: "This is the API guide content.", + tags: ["api", "guide"], + }; + + it("should save a document", async () => { + await filesystem.saveDocument(sampleDocument); + + // Check that file was created + const docsFiles = await readdir(filesystem.docsDir); + expect(docsFiles.some((f) => f.includes("API-Guide"))).toBe(true); + }); + + it("should save document without optional fields", async () => { + const minimalDoc: Document = { + id: "doc-2", + title: "Simple Doc", + type: "readme", + createdDate: "2025-06-07", + rawContent: "Simple content.", + }; + + await filesystem.saveDocument(minimalDoc); + + const docsFiles = await readdir(filesystem.docsDir); + expect(docsFiles.some((f) => f.includes("Simple-Doc"))).toBe(true); + }); + + it("removes the previous document file when the title changes", async () => { + await filesystem.saveDocument(sampleDocument); + + await filesystem.saveDocument({ + ...sampleDocument, + title: "API Guide Updated", + rawContent: "Updated content", + }); + + const docFiles = await Array.fromAsync(new Bun.Glob("doc-*.md").scan({ cwd: filesystem.docsDir })); + expect(docFiles).toHaveLength(1); + expect(docFiles[0]).toBe("doc-1 - API-Guide-Updated.md"); + }); + + it("should list documents", async () => { + await filesystem.saveDocument(sampleDocument); + const list = await filesystem.listDocuments(); + expect(list.some((d) => d.id === sampleDocument.id)).toBe(true); + }); + + it("should include relative path metadata when listing documents", async () => { + await filesystem.saveDocument( + { + ...sampleDocument, + id: "doc-3", + title: "Nested Guide", + }, + "guides", + ); + + const docs = await filesystem.listDocuments(); + const nested = docs.find((doc) => doc.id === "doc-3"); + expect(nested?.path).toBe(join("guides", "doc-3 - Nested-Guide.md")); + }); + + it("should load documents using flexible ID formats", async () => { + await filesystem.saveDocument({ + ...sampleDocument, + id: "doc-7", + title: "Operations Reference", + rawContent: "Ops content", + }); + + const uppercase = await filesystem.loadDocument("DOC-7"); + expect(uppercase.id).toBe("doc-7"); + + const zeroPadded = await filesystem.loadDocument("0007"); + expect(zeroPadded.id).toBe("doc-7"); + + await filesystem.saveDocument({ + ...sampleDocument, + id: "DOC-0009", + title: "Padded Uppercase", + rawContent: "Content", + }); + + const canonicalFiles = await Array.fromAsync(new Bun.Glob("doc-*.md").scan({ cwd: filesystem.docsDir })); + expect(canonicalFiles.some((file) => file.startsWith("doc-0009"))).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should handle task with task- prefix in id", async () => { + const taskWithPrefix: Task = { + id: "task-prefixed", + title: "Already Prefixed", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Task with task- prefix", + }; + + await filesystem.saveTask(taskWithPrefix); + const loaded = await filesystem.loadTask("task-prefixed"); + + expect(loaded?.id).toBe("task-prefixed"); + }); + + it("should handle task without task- prefix in id", async () => { + const taskWithoutPrefix: Task = { + id: "no-prefix", + title: "No Prefix", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Task without prefix", + }; + + await filesystem.saveTask(taskWithoutPrefix); + const loaded = await filesystem.loadTask("no-prefix"); + + expect(loaded?.id).toBe("no-prefix"); + }); + + it("should return empty array when listing tasks in empty directory", async () => { + const tasks = await filesystem.listTasks(); + expect(tasks).toEqual([]); + }); + + it("should return false when archiving non-existent task", async () => { + const result = await filesystem.archiveTask("non-existent"); + expect(result).toBe(false); + }); + + it("should handle config with all optional fields", async () => { + const fullConfig: BacklogConfig = { + projectName: "Full Project", + defaultAssignee: "@admin", + defaultStatus: "To Do", + defaultReporter: undefined, + statuses: ["To Do", "In Progress", "Done"], + labels: ["bug", "feature", "enhancement"], + milestones: ["v1.0", "v1.1", "v2.0"], + dateFormat: "yyyy-mm-dd", + }; + + await filesystem.saveConfig(fullConfig); + const loaded = await filesystem.loadConfig(); + + expect(loaded).toEqual(fullConfig); + }); + + it("should handle config with minimal fields", async () => { + const minimalConfig: BacklogConfig = { + projectName: "Minimal Project", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "yyyy-mm-dd", + }; + + await filesystem.saveConfig(minimalConfig); + const loaded = await filesystem.loadConfig(); + + expect(loaded?.projectName).toBe("Minimal Project"); + expect(loaded?.defaultAssignee).toBeUndefined(); + expect(loaded?.defaultStatus).toBeUndefined(); + }); + + it("should sanitize filenames correctly", async () => { + const taskWithSpecialChars: Task = { + id: "task-special", + title: "Task/with\\special:chars?", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Task with special characters in title", + }; + + await filesystem.saveTask(taskWithSpecialChars); + const loaded = await filesystem.loadTask("task-special"); + + expect(loaded?.title).toBe("Task/with\\special:chars?"); + }); + + it("should preserve case in filenames", async () => { + const taskWithMixedCase: Task = { + id: "task-mixed", + title: "Fix Task List Ordering", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Task with mixed case title", + }; + + await filesystem.saveTask(taskWithMixedCase); + + // Check that the file exists with preserved case + const files = await readdir(filesystem.tasksDir); + const taskFile = files.find((f) => f.startsWith("task-mixed -")); + expect(taskFile).toBe("task-mixed - Fix-Task-List-Ordering.md"); + + // Verify the task can be loaded + const loaded = await filesystem.loadTask("task-mixed"); + expect(loaded?.title).toBe("Fix Task List Ordering"); + }); + + it("should avoid double dashes in filenames", async () => { + const weirdTask: Task = { + id: "task-dashes", + title: "Task -- with -- multiple dashes", + status: "To Do", + assignee: [], + createdDate: "2025-06-07", + labels: [], + dependencies: [], + description: "Check double dashes", + }; + + await filesystem.saveTask(weirdTask); + const files = await readdir(filesystem.tasksDir); + const filename = files.find((f) => f.startsWith("task-dashes -")); + expect(filename).toBeDefined(); + expect(filename?.includes("--")).toBe(false); + }); + }); +}); diff --git a/src/test/find-task-in-branches.test.ts b/src/test/find-task-in-branches.test.ts new file mode 100644 index 0000000..4cc95f0 --- /dev/null +++ b/src/test/find-task-in-branches.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test"; +import { findTaskInLocalBranches, findTaskInRemoteBranches } from "../core/task-loader.ts"; +import type { GitOperations } from "../git/operations.ts"; + +type PartialGitOps = Partial<GitOperations>; + +describe("findTaskInRemoteBranches", () => { + it("should return null when git has no remotes", async () => { + const mockGit: PartialGitOps = { + hasAnyRemote: async () => false, + }; + const result = await findTaskInRemoteBranches(mockGit as GitOperations, "task-999"); + expect(result).toBeNull(); + }); + + it("should return null when no branches exist", async () => { + const mockGit: PartialGitOps = { + hasAnyRemote: async () => true, + listRecentRemoteBranches: async () => [], + }; + const result = await findTaskInRemoteBranches(mockGit as GitOperations, "task-999"); + expect(result).toBeNull(); + }); + + it("should return null when task is not in any branch", async () => { + const mockGit: PartialGitOps = { + hasAnyRemote: async () => true, + listRecentRemoteBranches: async () => ["main"], + listFilesInTree: async () => ["backlog/tasks/task-1 - some task.md"], + getBranchLastModifiedMap: async () => new Map([["backlog/tasks/task-1 - some task.md", new Date()]]), + }; + const result = await findTaskInRemoteBranches(mockGit as GitOperations, "task-999"); + expect(result).toBeNull(); + }); + + it("should find and load task from remote branch", async () => { + const mockTaskContent = `--- +id: task-123 +title: Test Task +status: To Do +assignee: [] +created_date: '2025-01-01 12:00' +labels: [] +dependencies: [] +--- + +## Description + +Test description +`; + const mockGit: PartialGitOps = { + hasAnyRemote: async () => true, + listRecentRemoteBranches: async () => ["feature"], + listFilesInTree: async () => ["backlog/tasks/task-123 - Test Task.md"], + getBranchLastModifiedMap: async () => + new Map([["backlog/tasks/task-123 - Test Task.md", new Date("2025-01-01")]]), + showFile: async () => mockTaskContent, + }; + + const result = await findTaskInRemoteBranches(mockGit as GitOperations, "task-123"); + expect(result).not.toBeNull(); + expect(result?.id).toBe("task-123"); + expect(result?.source).toBe("remote"); + expect(result?.branch).toBe("feature"); + }); +}); + +describe("findTaskInLocalBranches", () => { + it("should return null when on detached HEAD", async () => { + const mockGit: PartialGitOps = { + getCurrentBranch: async () => "", + }; + const result = await findTaskInLocalBranches(mockGit as GitOperations, "task-999"); + expect(result).toBeNull(); + }); + + it("should return null when only current branch exists", async () => { + const mockGit: PartialGitOps = { + getCurrentBranch: async () => "main", + listRecentBranches: async () => ["main"], + }; + const result = await findTaskInLocalBranches(mockGit as GitOperations, "task-999"); + expect(result).toBeNull(); + }); + + it("should find and load task from another local branch", async () => { + const mockTaskContent = `--- +id: task-456 +title: Local Branch Task +status: In Progress +assignee: [] +created_date: '2025-01-01 12:00' +labels: [] +dependencies: [] +--- + +## Description + +From local branch +`; + const mockGit: PartialGitOps = { + getCurrentBranch: async () => "main", + listRecentBranches: async () => ["main", "feature-branch"], + listFilesInTree: async () => ["backlog/tasks/task-456 - Local Branch Task.md"], + getBranchLastModifiedMap: async () => + new Map([["backlog/tasks/task-456 - Local Branch Task.md", new Date("2025-01-01")]]), + showFile: async () => mockTaskContent, + }; + + const result = await findTaskInLocalBranches(mockGit as GitOperations, "task-456"); + expect(result).not.toBeNull(); + expect(result?.id).toBe("task-456"); + expect(result?.source).toBe("local-branch"); + expect(result?.branch).toBe("feature-branch"); + }); +}); diff --git a/src/test/git.test.ts b/src/test/git.test.ts new file mode 100644 index 0000000..f676efd --- /dev/null +++ b/src/test/git.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { GitOperations, isGitRepository } from "../git/operations.ts"; + +describe("Git Operations", () => { + describe("isGitRepository", () => { + it("should return true for current directory (which is a git repo)", async () => { + const result = await isGitRepository(process.cwd()); + expect(result).toBe(true); + }); + + it("should return false for /tmp directory", async () => { + const result = await isGitRepository("/tmp"); + expect(result).toBe(false); + }); + }); + + describe("GitOperations instantiation", () => { + it("should create GitOperations instance", () => { + const git = new GitOperations(process.cwd()); + expect(git).toBeDefined(); + }); + }); + + // Note: Skipping integration tests that require git repository setup + // These tests can be enabled for local development but may timeout in CI +}); diff --git a/src/test/heading.test.ts b/src/test/heading.test.ts new file mode 100644 index 0000000..6d961cc --- /dev/null +++ b/src/test/heading.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { formatHeading, getHeadingStyle, type HeadingLevel } from "../ui/heading.ts"; + +describe("Heading component", () => { + describe("getHeadingStyle", () => { + test("should return correct style for level 1", () => { + const style = getHeadingStyle(1); + expect(style.color).toBe("bright-white"); + expect(style.bold).toBe(true); + }); + + test("should return correct style for level 2", () => { + const style = getHeadingStyle(2); + expect(style.color).toBe("cyan"); + expect(style.bold).toBe(false); + }); + + test("should return correct style for level 3", () => { + const style = getHeadingStyle(3); + expect(style.color).toBe("white"); + expect(style.bold).toBe(false); + }); + }); + + describe("formatHeading", () => { + test("should format level 1 heading with bold and bright-white", () => { + const formatted = formatHeading("Main Title", 1); + expect(formatted).toBe("{bold}{brightwhite-fg}Main Title{/brightwhite-fg}{/bold}"); + }); + + test("should format level 2 heading with cyan", () => { + const formatted = formatHeading("Section Title", 2); + expect(formatted).toBe("{cyan-fg}Section Title{/cyan-fg}"); + }); + + test("should format level 3 heading with white", () => { + const formatted = formatHeading("Subsection Title", 3); + expect(formatted).toBe("{white-fg}Subsection Title{/white-fg}"); + }); + + test("should handle empty text", () => { + const formatted = formatHeading("", 1); + expect(formatted).toBe("{bold}{brightwhite-fg}{/brightwhite-fg}{/bold}"); + }); + + test("should handle special characters", () => { + const formatted = formatHeading("Title with @#$%", 2); + expect(formatted).toBe("{cyan-fg}Title with @#$%{/cyan-fg}"); + }); + }); + + describe("heading levels", () => { + test("should accept valid heading levels", () => { + const levels: HeadingLevel[] = [1, 2, 3]; + + for (const level of levels) { + const style = getHeadingStyle(level); + expect(style).toBeDefined(); + expect(typeof style.color).toBe("string"); + expect(typeof style.bold).toBe("boolean"); + } + }); + + test("should have distinct styles for each level", () => { + const style1 = getHeadingStyle(1); + const style2 = getHeadingStyle(2); + const style3 = getHeadingStyle(3); + + // Level 1 should be the only bold one + expect(style1.bold).toBe(true); + expect(style2.bold).toBe(false); + expect(style3.bold).toBe(false); + + // Each level should have different colors + expect(style1.color).not.toBe(style2.color); + expect(style2.color).not.toBe(style3.color); + expect(style1.color).not.toBe(style3.color); + }); + }); + + describe("blessed tag formatting", () => { + test("should produce valid blessed tags", () => { + const level1 = formatHeading("Test", 1); + const level2 = formatHeading("Test", 2); + const level3 = formatHeading("Test", 3); + + // Should contain valid blessed tag syntax + expect(level1).toMatch(/^\{.*\}.*\{\/.*\}$/); + expect(level2).toMatch(/^\{.*\}.*\{\/.*\}$/); + expect(level3).toMatch(/^\{.*\}.*\{\/.*\}$/); + + // Level 1 should have both bold and color tags + expect(level1).toContain("{bold}"); + expect(level1).toContain("{/bold}"); + expect(level1).toContain("-fg}"); + + // Level 2 and 3 should only have color tags + expect(level2).not.toContain("{bold}"); + expect(level3).not.toContain("{bold}"); + }); + }); +}); diff --git a/src/test/implementation-notes-append.test.ts b/src/test/implementation-notes-append.test.ts new file mode 100644 index 0000000..1c14cdb --- /dev/null +++ b/src/test/implementation-notes-append.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { extractStructuredSection } from "../markdown/structured-sections.ts"; +import type { Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); +let TEST_DIR: string; + +describe("Implementation Notes - append", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-notes-append"); + await mkdir(TEST_DIR, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Append Notes Test Project"); + }); + + afterEach(async () => { + await safeCleanup(TEST_DIR).catch(() => {}); + }); + + it("appends to existing Implementation Notes with single blank line", async () => { + const core = new Core(TEST_DIR); + const task: Task = { + id: "task-1", + title: "Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Test description", + implementationNotes: "First block", + }; + await core.createTask(task, false); + + const result = await $`bun ${[CLI_PATH, "task", "edit", "1", "--append-notes", "Second block"]}` + .cwd(TEST_DIR) + .quiet(); + expect(result.exitCode).toBe(0); + + const updatedBody = await core.getTaskContent("task-1"); + expect(extractStructuredSection(updatedBody ?? "", "implementationNotes")).toBe("First block\n\nSecond block"); + }); + + it("creates Implementation Notes at correct position when missing (after plan, else AC, else Description)", async () => { + const core = new Core(TEST_DIR); + const t: Task = { + id: "task-1", + title: "Planned", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Desc here", + acceptanceCriteriaItems: [{ index: 1, text: "A", checked: false }], + implementationPlan: "1. Do A\n2. Do B", + }; + await core.createTask(t, false); + + const res = await $`bun ${[CLI_PATH, "task", "edit", "1", "--append-notes", "Followed plan"]}` + .cwd(TEST_DIR) + .quiet(); + expect(res.exitCode).toBe(0); + + const body = (await core.getTaskContent("task-1")) ?? ""; + const planIdx = body.indexOf("## Implementation Plan"); + const notesContent = extractStructuredSection(body, "implementationNotes") || ""; + expect(planIdx).toBeGreaterThan(0); + expect(notesContent).toContain("Followed plan"); + }); + + it("supports multiple --append-notes flags in order", async () => { + const core = new Core(TEST_DIR); + const task: Task = { + id: "task-1", + title: "Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Some description", + }; + await core.createTask(task, false); + + const res = await $`bun ${[CLI_PATH, "task", "edit", "1", "--append-notes", "First", "--append-notes", "Second"]}` + .cwd(TEST_DIR) + .quiet(); + expect(res.exitCode).toBe(0); + + const updatedBody = await core.getTaskContent("task-1"); + expect(extractStructuredSection(updatedBody ?? "", "implementationNotes")).toBe("First\n\nSecond"); + }); + + it("edit --append-notes works and allows combining with --notes", async () => { + const resOk = await $`bun ${[CLI_PATH, "task", "create", "T", "--plan", "1. A\n2. B"]}`.cwd(TEST_DIR).quiet(); + expect(resOk.exitCode).toBe(0); + + const res1 = await $`bun ${[CLI_PATH, "task", "edit", "1", "--append-notes", "Alpha", "--append-notes", "Beta"]}` + .cwd(TEST_DIR) + .quiet(); + expect(res1.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + let taskBody = await core.getTaskContent("task-1"); + expect(extractStructuredSection(taskBody ?? "", "implementationNotes")).toBe("Alpha\n\nBeta"); + + // Combining --notes (replace) with --append-notes (append) should work + const combined = await $`bun ${[CLI_PATH, "task", "edit", "1", "--notes", "Y", "--append-notes", "X"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(combined.exitCode).toBe(0); + + taskBody = await core.getTaskContent("task-1"); + expect(extractStructuredSection(taskBody ?? "", "implementationNotes")).toBe("Y\n\nX"); + }); +}); diff --git a/src/test/implementation-notes.test.ts b/src/test/implementation-notes.test.ts new file mode 100644 index 0000000..e82761d --- /dev/null +++ b/src/test/implementation-notes.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { extractStructuredSection } from "../markdown/structured-sections.ts"; +import type { Task } from "../types/index.ts"; +import { editTaskPlatformAware } from "./test-helpers.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("Implementation Notes CLI", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-notes"); + await mkdir(TEST_DIR, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Implementation Notes Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + describe("task create with implementation notes", () => { + it("should handle all task creation scenarios with implementation notes", async () => { + // Test 1: create task with implementation notes using --notes + const result1 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task 1", "--notes", "Initial implementation completed"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(result1.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + let task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("<!-- SECTION:NOTES:BEGIN -->"); + expect(extractStructuredSection(task?.rawContent || "", "implementationNotes")).toContain( + "Initial implementation completed", + ); + + // Test 2: create task with multi-line implementation notes + const result2 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task 2", "--notes", "Step 1: Analysis completed\nStep 2: Implementation in progress"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(result2.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + const notes2 = extractStructuredSection(task?.rawContent || "", "implementationNotes") || ""; + expect(notes2).toContain("Step 1: Analysis completed"); + expect(notes2).toContain("Step 2: Implementation in progress"); + + // Test 3: create task with both plan and notes (notes should come after plan) + const result3 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task 3", "--plan", "1. Design\n2. Build\n3. Test", "--notes", "Following the plan step by step"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(result3.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-3"); + expect(task).not.toBeNull(); + expect(extractStructuredSection(task?.rawContent || "", "implementationPlan")).toContain("1. Design"); + expect(extractStructuredSection(task?.rawContent || "", "implementationNotes")).toContain( + "Following the plan step by step", + ); + + // Check that Implementation Notes comes after Implementation Plan + const desc = task?.rawContent || ""; + const planIndex = desc.indexOf("## Implementation Plan"); + const notesIndex = desc.indexOf("## Implementation Notes"); + expect(notesIndex).toBeGreaterThan(planIndex); + + // Test 4: create task with multiple options including notes + const result4 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task 4", "-d", "Complex task description", "--ac", "Must work correctly,Must be tested", "--notes", "Using TDD approach"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(result4.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-4"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("Complex task description"); + expect(extractStructuredSection(task?.rawContent || "", "implementationNotes")).toContain("Using TDD approach"); + + // Test 5: create task without notes should not add the section + const result5 = await $`bun ${[CLI_PATH, "task", "create", "Test Task 5"]}`.cwd(TEST_DIR).quiet().nothrow(); + expect(result5.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-5"); + expect(task).not.toBeNull(); + // Should not add Implementation Notes section for empty notes + expect(task?.rawContent).not.toContain("## Implementation Notes"); + }); + }); + + describe("task edit with implementation notes", () => { + it("should handle all implementation notes scenarios", async () => { + const core = new Core(TEST_DIR); + + // Test 1: add implementation notes to existing task + const task1: Task = { + id: "task-1", + title: "Test Task 1", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Test description", + }; + await core.createTask(task1, false); + + let result = await editTaskPlatformAware( + { + taskId: "1", + notes: "Fixed the bug by updating the validation logic", + }, + TEST_DIR, + ); + expect(result.exitCode).toBe(0); + + let updatedTask = await core.filesystem.loadTask("task-1"); + expect(updatedTask).not.toBeNull(); + expect(updatedTask?.rawContent).toContain("## Implementation Notes"); + expect(updatedTask?.rawContent).toContain("Fixed the bug by updating the validation logic"); + + // Test 2: overwrite existing implementation notes + const task2: Task = { + id: "task-2", + title: "Test Task 2", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Test description", + implementationNotes: "Initial implementation completed", + }; + await core.createTask(task2, false); + + result = await editTaskPlatformAware( + { + taskId: "2", + notes: "Added error handling", + }, + TEST_DIR, + ); + expect(result.exitCode).toBe(0); + + updatedTask = await core.filesystem.loadTask("task-2"); + expect(updatedTask).not.toBeNull(); + const notesSection = updatedTask?.rawContent?.match(/## Implementation Notes\s*\n([\s\S]*?)(?=\n## |$)/i); + expect(notesSection?.[1]).not.toContain("Initial implementation completed"); + expect(notesSection?.[1]).toContain("Added error handling"); + + // Test 3: work together with status update when marking as Done + const task3: Task = { + id: "task-3", + title: "Feature Implementation", + status: "In Progress", + assignee: ["@dev"], + createdDate: "2025-07-03", + labels: ["feature"], + dependencies: [], + description: "Implement new feature", + acceptanceCriteriaItems: [ + { index: 1, text: "Feature works", checked: false }, + { index: 2, text: "Tests pass", checked: false }, + ], + }; + await core.createTask(task3, false); + + result = await editTaskPlatformAware( + { + taskId: "3", + status: "Done", + notes: "Implemented using the factory pattern\nAdded unit tests\nUpdated documentation", + }, + TEST_DIR, + ); + expect(result.exitCode).toBe(0); + + updatedTask = await core.filesystem.loadTask("task-3"); + expect(updatedTask).not.toBeNull(); + expect(updatedTask?.status).toBe("Done"); + expect(updatedTask?.rawContent).toContain("## Implementation Notes"); + expect(updatedTask?.rawContent).toContain("Implemented using the factory pattern"); + expect(updatedTask?.rawContent).toContain("Added unit tests"); + expect(updatedTask?.rawContent).toContain("Updated documentation"); + + // Test 4: handle multi-line notes with proper formatting + const task4: Task = { + id: "task-4", + title: "Complex Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Complex task description", + }; + await core.createTask(task4, false); + + const multiLineNotes = `Completed the following: +- Refactored the main module +- Added error boundaries +- Improved performance by 30% + +Technical decisions: +- Used memoization for expensive calculations +- Implemented lazy loading`; + + result = await editTaskPlatformAware( + { + taskId: "4", + notes: multiLineNotes, + }, + TEST_DIR, + ); + expect(result.exitCode).toBe(0); + + updatedTask = await core.filesystem.loadTask("task-4"); + expect(updatedTask).not.toBeNull(); + expect(updatedTask?.rawContent).toContain("Refactored the main module"); + expect(updatedTask?.rawContent).toContain("Technical decisions:"); + expect(updatedTask?.rawContent).toContain("Implemented lazy loading"); + + // Test 5: position implementation notes after implementation plan if present + const task5: Task = { + id: "task-5", + title: "Planned Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + rawContent: + "Task with plan\n\n## Acceptance Criteria\n\n- [ ] Works\n\n## Implementation Plan\n\n1. Design\n2. Build\n3. Test", + }; + await core.createTask(task5, false); + + result = await editTaskPlatformAware( + { + taskId: "5", + notes: "Followed the plan successfully", + }, + TEST_DIR, + ); + expect(result.exitCode).toBe(0); + + updatedTask = await core.filesystem.loadTask("task-5"); + expect(updatedTask).not.toBeNull(); + const desc = updatedTask?.rawContent || ""; + + // Check that Implementation Notes comes after Implementation Plan + const planIndex = desc.indexOf("## Implementation Plan"); + const notesIndex = desc.indexOf("## Implementation Notes"); + expect(planIndex).toBeGreaterThan(0); + expect(notesIndex).toBeGreaterThan(planIndex); + + // Test 6: handle empty notes gracefully + const task6: Task = { + id: "task-6", + title: "Test Task 6", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + description: "Test description", + }; + await core.createTask(task6, false); + + result = await editTaskPlatformAware( + { + taskId: "6", + notes: "", + }, + TEST_DIR, + ); + expect(result.exitCode).toBe(0); + + updatedTask = await core.filesystem.loadTask("task-6"); + expect(updatedTask).not.toBeNull(); + // Should not add Implementation Notes section for empty notes + expect(updatedTask?.rawContent).not.toContain("## Implementation Notes"); + }); + + it("preserves nested H2 headings when migrating legacy implementation notes", async () => { + const core = new Core(TEST_DIR); + const task: Task = { + id: "task-7", + title: "Legacy Notes", + status: "To Do", + assignee: [], + createdDate: "2025-07-03", + labels: [], + dependencies: [], + rawContent: + "Initial description\n\n## Implementation Notes\n\nSummary of work\n\n## Follow-up\n\nCapture additional findings", + }; + await core.createTask(task, false); + + const appendResult = await $`bun ${CLI_PATH} task edit 7 --append-notes "Added verification details"` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(appendResult.exitCode).toBe(0); + + const updated = await core.filesystem.loadTask("task-7"); + expect(updated).not.toBeNull(); + const body = updated?.rawContent || ""; + expect(body).toContain("<!-- SECTION:NOTES:BEGIN -->"); + const notesContent = extractStructuredSection(body, "implementationNotes") || ""; + expect(notesContent).toContain("## Follow-up"); + expect(notesContent).toContain("Summary of work"); + expect(notesContent).toContain("Added verification details"); + }); + }); +}); diff --git a/src/test/implementation-plan.test.ts b/src/test/implementation-plan.test.ts new file mode 100644 index 0000000..cd7e29d --- /dev/null +++ b/src/test/implementation-plan.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createTaskPlatformAware, editTaskPlatformAware } from "./test-helpers.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +describe("Implementation Plan CLI", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-plan"); + await mkdir(TEST_DIR, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + const core = new Core(TEST_DIR); + await core.initializeProject("Implementation Plan Test Project"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + describe("task create with implementation plan", () => { + it("should handle all task creation scenarios with implementation plans", async () => { + // Test 1: create task with implementation plan using --plan + const result1 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task 1", "--plan", "Step 1: Analyze\nStep 2: Implement"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(result1.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + let task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Implementation Plan"); + expect(task?.rawContent).toContain("Step 1: Analyze"); + expect(task?.rawContent).toContain("Step 2: Implement"); + + // Test 2: create task with both description and implementation plan + const result2 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task 2", "-d", "Task description", "--plan", "1. First step\n2. Second step"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + expect(result2.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Description"); + expect(task?.rawContent).toContain("Task description"); + expect(task?.rawContent).toContain("## Implementation Plan"); + expect(task?.rawContent).toContain("1. First step"); + expect(task?.rawContent).toContain("2. Second step"); + + // Test 3: create task with acceptance criteria and implementation plan + const result = await createTaskPlatformAware( + { + title: "Test Task 3", + ac: "Must work correctly, Must be tested", + plan: "Phase 1: Setup\nPhase 2: Testing", + }, + TEST_DIR, + ); + + if (result.exitCode !== 0) { + console.error("CLI Error:", result.stderr || result.stdout); + console.error("Exit code:", result.exitCode); + } + expect(result.exitCode).toBe(0); + + task = await core.filesystem.loadTask(result.taskId || "task-3"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Acceptance Criteria"); + expect(task?.rawContent).toContain("- [ ] #1 Must work correctly, Must be tested"); + expect(task?.rawContent).toContain("## Implementation Plan"); + expect(task?.rawContent).toContain("Phase 1: Setup"); + expect(task?.rawContent).toContain("Phase 2: Testing"); + }); + }); + + describe("task edit with implementation plan", () => { + beforeEach(async () => { + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Existing Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-19", + labels: [], + dependencies: [], + rawContent: "## Description\n\nExisting task description", + }, + false, + ); + }); + + it("should handle all task editing scenarios with implementation plans", async () => { + // Test 1: add implementation plan to existing task + const result1 = await editTaskPlatformAware({ taskId: "1", plan: "New plan:\n- Step A\n- Step B" }, TEST_DIR); + expect(result1.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + let task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Description"); + expect(task?.rawContent).toContain("Existing task description"); + expect(task?.rawContent).toContain("## Implementation Plan"); + expect(task?.rawContent).toContain("New plan:"); + expect(task?.rawContent).toContain("- Step A"); + expect(task?.rawContent).toContain("- Step B"); + + // Test 2: replace existing implementation plan + // First add an old plan via structured field (serializer will compose) + await core.updateTaskFromInput( + "task-1", + { implementationPlan: "Old plan:\n1. Old step 1\n2. Old step 2" }, + false, + ); + + // Now update with new plan + const result2 = await editTaskPlatformAware( + { taskId: "1", plan: "Updated plan:\n1. New step 1\n2. New step 2" }, + TEST_DIR, + ); + expect(result2.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.rawContent).toContain("## Implementation Plan"); + expect(task?.rawContent).toContain("Updated plan:"); + expect(task?.rawContent).toContain("1. New step 1"); + expect(task?.rawContent).toContain("2. New step 2"); + expect(task?.rawContent).not.toContain("Old plan:"); + expect(task?.rawContent).not.toContain("Old step 1"); + + // Test 3: update both title and implementation plan + const result = + await $`bun ${[CLI_PATH, "task", "edit", "1", "--title", "Updated Title", "--plan", "Implementation:\n- Do this\n- Then that"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + + if (result.exitCode !== 0) { + console.error("CLI Error:", result.stderr.toString() || result.stdout.toString()); + console.error("Exit code:", result.exitCode); + } + expect(result.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + expect(task?.title).toBe("Updated Title"); + expect(task?.rawContent).toContain("## Implementation Plan"); + expect(task?.rawContent).toContain("Implementation:"); + expect(task?.rawContent).toContain("- Do this"); + expect(task?.rawContent).toContain("- Then that"); + }); + }); + + describe("implementation plan positioning", () => { + it("should handle implementation plan positioning and edge cases", async () => { + // Test 1: place implementation plan after acceptance criteria when both exist + const result1 = + await $`bun ${[CLI_PATH, "task", "create", "Test Task", "-d", "Description text", "--ac", "Criterion 1", "--plan", "Plan text"]}` + .cwd(TEST_DIR) + .quiet() + .nothrow(); + + if (result1.exitCode !== 0) { + console.error("CLI Error:", result1.stderr.toString() || result1.stdout.toString()); + console.error("Exit code:", result1.exitCode); + } + expect(result1.exitCode).toBe(0); + + const core = new Core(TEST_DIR); + let task = await core.filesystem.loadTask("task-1"); + expect(task).not.toBeNull(); + + const description = task?.rawContent || ""; + const descIndex = description.indexOf("## Description"); + const acIndex = description.indexOf("## Acceptance Criteria"); + const planIndex = description.indexOf("## Implementation Plan"); + + // Verify order: Description -> Acceptance Criteria -> Implementation Plan + expect(descIndex).toBeLessThan(acIndex); + expect(acIndex).toBeLessThan(planIndex); + + // Test 2: create task without plan (should not add the section) + const result2 = await $`bun ${[CLI_PATH, "task", "create", "Test Task 2"]}`.cwd(TEST_DIR).quiet().nothrow(); + + if (result2.exitCode !== 0) { + console.error("CLI Error:", result2.stderr.toString() || result2.stdout.toString()); + console.error("Exit code:", result2.exitCode); + } + expect(result2.exitCode).toBe(0); + + task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + // Should NOT add the section when no plan is provided + expect(task?.rawContent).not.toContain("## Implementation Plan"); + }); + }); +}); diff --git a/src/test/line-wrapping.test.ts b/src/test/line-wrapping.test.ts new file mode 100644 index 0000000..2e455af --- /dev/null +++ b/src/test/line-wrapping.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; +import { box, list } from "neo-neo-bblessed"; +import { WRAP_LIMIT } from "../constants/index.ts"; +import { createScreen } from "../ui/tui.ts"; + +describe("Line Wrapping", () => { + test("WRAP_LIMIT constant is set to 72", () => { + expect(WRAP_LIMIT).toBe(72); + }); + + test("blessed box with wrap:true enables text wrapping", async () => { + const screen = createScreen({ smartCSR: false }); + + // Create a long text that should wrap + const longText = + "This is a very long line of text that should definitely wrap when displayed in a blessed box because it exceeds the 72 character limit that we have set"; + + const b = box({ + parent: screen, + content: longText, + width: WRAP_LIMIT, + height: 10, + wrap: true, + }); + + // Verify wrap is enabled + expect(b.options.wrap).toBe(true); + expect(b.width).toBe(WRAP_LIMIT); + + screen.destroy(); + }); + + test("blessed box without wrap:false does not break mid-word", async () => { + const screen = createScreen({ smartCSR: false }); + + // Create text with long words + const textWithLongWords = + "Supercalifragilisticexpialidocious is a very extraordinarily long word that should not be broken in the middle when wrapping"; + + const b2 = box({ + parent: screen, + content: textWithLongWords, + width: 50, + height: 10, + wrap: true, + }); + + screen.render(); + + const lines = b2.getLines?.() ?? []; + + // Check that words are not broken mid-word + // This is a simplified check - blessed should handle word boundaries + for (let i = 0; i < lines.length - 1; i++) { + const currentLine = String(lines[i] ?? "") + /* biome-ignore lint/suspicious/noControlCharactersInRegex: testing ANSI escape sequences */ + .replace(/\x1b\[[0-9;]*m/g, "") + .trim(); + const nextLine = String(lines[i + 1] ?? "") + /* biome-ignore lint/suspicious/noControlCharactersInRegex: testing ANSI escape sequences */ + .replace(/\x1b\[[0-9;]*m/g, "") + .trim(); + + if (currentLine && nextLine) { + // If a line doesn't end with a space or punctuation, and the next line + // doesn't start with a space, it might be a mid-word break + const lastChar = currentLine[currentLine.length - 1]; + const firstChar = nextLine[0]; + + // Basic check: if both characters are letters, it might be mid-word + if (/[a-zA-Z]/.test(String(lastChar)) && /[a-zA-Z]/.test(String(firstChar))) { + // This is acceptable for blessed as it handles word wrapping internally + // We're mainly checking that wrap:true is set + expect(b2.options.wrap).toBe(true); + } + } + } + + screen.destroy(); + }); + + test("task viewer boxes have wrap enabled", async () => { + const screen = createScreen({ smartCSR: false }); + + // Simulate task viewer boxes + const testBoxes = [ + { + name: "header", + box: box({ + parent: screen, + content: "Task-123 - This is a very long task title that should wrap properly", + wrap: true, + }), + }, + { + name: "tagBox", + box: box({ + parent: screen, + content: "[label1] [label2] [label3] [label4] [label5] [label6] [label7] [label8]", + wrap: true, + }), + }, + { + name: "metadata", + box: box({ + parent: screen, + content: "Status: In Progress\nAssignee: @user1, @user2, @user3\nCreated: 2024-01-01", + wrap: true, + }), + }, + { + name: "description", + box: box({ + parent: screen, + content: + "This is a very long description that contains multiple sentences and should wrap properly without breaking words in the middle.", + wrap: true, + }), + }, + ]; + + // Verify all boxes have wrap enabled + for (const testBox of testBoxes) { + expect(testBox.box.options.wrap).toBe(true); + } + + screen.destroy(); + }); + + test("board view content respects width constraints", async () => { + const screen = createScreen({ smartCSR: false }); + + // Simulate board column + const column = box({ + parent: screen, + width: "33%", + height: "100%", + border: "line", + }); + + // Task list items should fit within column + const taskList = list({ + parent: column, + width: "100%-2", + items: [ + "TASK-1 - Short task", + "TASK-2 - This is a much longer task title that might need special handling", + "TASK-3 - Another task with @assignee", + ], + }); + + screen.render(); + + // The list should be constrained by its parent width + expect(taskList.width).toBeLessThan(screen.width); + + screen.destroy(); + }); + + test("popup content boxes have wrap enabled", async () => { + const screen = createScreen({ smartCSR: false }); + + // Simulate popup boxes + const statusLine = box({ + parent: screen, + content: "● In Progress β€’ @user1, @user2 β€’ 2024-01-01", + wrap: true, + }); + + const metadataLine = box({ + parent: screen, + content: "[label1] [label2] [label3]", + wrap: true, + }); + + const contentArea = box({ + parent: screen, + content: "Task content goes here with descriptions and acceptance criteria", + wrap: true, + }); + + // Verify wrap is enabled + expect(statusLine.options.wrap).toBe(true); + expect(metadataLine.options.wrap).toBe(true); + expect(contentArea.options.wrap).toBe(true); + + screen.destroy(); + }); + + test("UI components use percentage-based widths", () => { + // This test verifies that our UI components are configured to use + // percentage-based widths, which allows blessed to handle wrapping + // based on the actual terminal size + const widthConfigs = [ + { component: "task-viewer header", width: "100%" }, + { component: "task-viewer tagBox", width: "100%" }, + { component: "task-viewer description", width: "60%" }, + { component: "task-viewer bottomBox", width: "100%" }, + { component: "board column", width: "dynamic%" }, + { component: "popup contentArea", width: "100%" }, + ]; + + // Verify we're using percentage-based widths + for (const config of widthConfigs) { + expect(config.width).toMatch(/%$/); + } + }); +}); diff --git a/src/test/local-branch-tasks.test.ts b/src/test/local-branch-tasks.test.ts new file mode 100644 index 0000000..26d585a --- /dev/null +++ b/src/test/local-branch-tasks.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { buildLocalBranchTaskIndex, loadLocalBranchTasks } from "../core/task-loader.ts"; +import type { GitOperations } from "../git/operations.ts"; +import type { Task } from "../types/index.ts"; + +// Mock GitOperations for testing +class MockGitOperations implements Partial<GitOperations> { + private currentBranch = "main"; + + async getCurrentBranch(): Promise<string> { + return this.currentBranch; + } + + async listRecentBranches(_daysAgo: number): Promise<string[]> { + return ["main", "feature-a", "feature-b", "origin/main"]; + } + + async getBranchLastModifiedMap(_ref: string, _dir: string): Promise<Map<string, Date>> { + const map = new Map<string, Date>(); + map.set("backlog/tasks/task-1 - Main Task.md", new Date("2025-06-13")); + map.set("backlog/tasks/task-2 - Feature Task.md", new Date("2025-06-13")); + map.set("backlog/tasks/task-3 - New Task.md", new Date("2025-06-13")); + return map; + } + + async listFilesInTree(ref: string, _path: string): Promise<string[]> { + // Main branch has task-1 and task-2 + if (ref === "main") { + return ["backlog/tasks/task-1 - Main Task.md", "backlog/tasks/task-2 - Feature Task.md"]; + } + // feature-a has task-1 and task-3 (task-3 is new) + if (ref === "feature-a") { + return ["backlog/tasks/task-1 - Main Task.md", "backlog/tasks/task-3 - New Task.md"]; + } + // feature-b has task-2 + if (ref === "feature-b") { + return ["backlog/tasks/task-2 - Feature Task.md"]; + } + return []; + } + + async showFile(_ref: string, file: string): Promise<string> { + if (file.includes("task-1")) { + return `--- +id: task-1 +title: Main Task +status: To Do +assignee: [] +created_date: 2025-06-13 +labels: [] +dependencies: [] +---\n\n## Description\n\nMain task`; + } + if (file.includes("task-2")) { + return `--- +id: task-2 +title: Feature Task +status: In Progress +assignee: [] +created_date: 2025-06-13 +labels: [] +dependencies: [] +---\n\n## Description\n\nFeature task`; + } + if (file.includes("task-3")) { + return `--- +id: task-3 +title: New Task +status: To Do +assignee: [] +created_date: 2025-06-13 +labels: [] +dependencies: [] +---\n\n## Description\n\nNew task from feature-a branch`; + } + return ""; + } +} + +describe("Local branch task discovery", () => { + let consoleDebugSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + consoleDebugSpy = spyOn(console, "debug"); + }); + + afterEach(() => { + consoleDebugSpy?.mockRestore(); + }); + + describe("buildLocalBranchTaskIndex", () => { + it("should build index from local branches excluding current branch", async () => { + const mockGit = new MockGitOperations() as unknown as GitOperations; + const branches = ["main", "feature-a", "feature-b", "origin/main"]; + + const index = await buildLocalBranchTaskIndex(mockGit, branches, "main", "backlog"); + + // Should find task-3 from feature-a (not in main) + expect(index.has("task-3")).toBe(true); + const task3Entries = index.get("task-3"); + expect(task3Entries?.length).toBe(1); + expect(task3Entries?.[0]?.branch).toBe("feature-a"); + + // Should find task-1 and task-2 from other branches + expect(index.has("task-1")).toBe(true); + expect(index.has("task-2")).toBe(true); + }); + + it("should exclude origin/ branches", async () => { + const mockGit = new MockGitOperations() as unknown as GitOperations; + const branches = ["main", "feature-a", "origin/feature-a"]; + + const index = await buildLocalBranchTaskIndex(mockGit, branches, "main", "backlog"); + + // Should only have entries from feature-a (local), not origin/feature-a + const task1Entries = index.get("task-1"); + expect(task1Entries?.every((e) => e.branch === "feature-a")).toBe(true); + }); + + it("should exclude current branch", async () => { + const mockGit = new MockGitOperations() as unknown as GitOperations; + const branches = ["main", "feature-a"]; + + const index = await buildLocalBranchTaskIndex(mockGit, branches, "main", "backlog"); + + // task-1 should only be from feature-a, not main + const task1Entries = index.get("task-1"); + expect(task1Entries?.every((e) => e.branch !== "main")).toBe(true); + }); + }); + + describe("loadLocalBranchTasks", () => { + it("should discover tasks from other local branches", async () => { + const mockGit = new MockGitOperations() as unknown as GitOperations; + + const progressMessages: string[] = []; + const localBranchTasks = await loadLocalBranchTasks(mockGit, null, (msg: string) => { + progressMessages.push(msg); + }); + + // Should find task-3 which only exists in feature-a + const task3 = localBranchTasks.find((t) => t.id === "task-3"); + expect(task3).toBeDefined(); + expect(task3?.title).toBe("New Task"); + expect(task3?.source).toBe("local-branch"); + expect(task3?.branch).toBe("feature-a"); + + // Progress should mention other local branches + expect(progressMessages.some((msg) => msg.includes("other local branches"))).toBe(true); + }); + + it("should skip tasks that exist in filesystem when provided", async () => { + const mockGit = new MockGitOperations() as unknown as GitOperations; + + // Simulate that task-1 already exists in filesystem + const localTasks: Task[] = [ + { + id: "task-1", + title: "Main Task (local)", + status: "To Do", + assignee: [], + createdDate: "2025-06-13", + labels: [], + dependencies: [], + source: "local", + }, + ]; + + const localBranchTasks = await loadLocalBranchTasks(mockGit, null, undefined, localTasks); + + // task-3 should be found (not in local tasks) + expect(localBranchTasks.some((t) => t.id === "task-3")).toBe(true); + + // task-1 should not be hydrated since it exists locally + // (unless the remote version is newer, which in this mock it's not) + // The behavior depends on whether the remote version is newer + }); + + it("should return empty array when on detached HEAD", async () => { + const mockGit = { + getCurrentBranch: async () => "", + } as unknown as GitOperations; + + const tasks = await loadLocalBranchTasks(mockGit, null); + expect(tasks).toEqual([]); + }); + + it("should return empty when only current branch exists", async () => { + const mockGit = { + getCurrentBranch: async () => "main", + listRecentBranches: async () => ["main"], + } as unknown as GitOperations; + + const tasks = await loadLocalBranchTasks(mockGit, null); + expect(tasks).toEqual([]); + }); + }); +}); diff --git a/src/test/markdown-test-helpers.ts b/src/test/markdown-test-helpers.ts new file mode 100644 index 0000000..57b9ee1 --- /dev/null +++ b/src/test/markdown-test-helpers.ts @@ -0,0 +1,511 @@ +/** + * Helper functions for parsing markdown responses in MCP tests + */ + +/** + * Parse sequence create markdown response into structured data + */ +export function parseSequenceCreateMarkdown(markdown: string) { + const lines = markdown.split("\n"); + + // Extract metadata from Summary table + const metadata: Record<string, string | number | boolean | null> = {}; + let inSummaryTable = false; + + for (const line of lines) { + if (line.trim() === "## Summary") { + inSummaryTable = true; + continue; + } + if (inSummaryTable && line.startsWith("| ") && !line.includes("Metric")) { + const match = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|/); + if (match) { + const [, key, value] = match; + if (!key || !value) continue; + // Convert values to appropriate types + if (value === "true" || value === "false") { + metadata[key] = value === "true"; + } else if (!Number.isNaN(Number(value))) { + metadata[key] = Number(value); + } else if (value === "null") { + metadata[key] = null; + } else { + metadata[key] = value; + } + } + } + if (inSummaryTable && line.startsWith("## ") && line !== "## Summary") { + inSummaryTable = false; + } + } + + // Extract sequences + const sequences: Array<{ index: number; tasks: Array<{ id: string; title: string; status: string }> }> = []; + interface SequenceType { + index: number; + tasks: Array<{ id: string; title: string; status: string }>; + } + let currentSequence: SequenceType | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + // Match sequence headers like "### Sequence 1" + const sequenceMatch = line.match(/^### Sequence (\d+)$/); + if (sequenceMatch) { + if (currentSequence) { + sequences.push(currentSequence); + } + const indexStr = sequenceMatch[1]; + if (!indexStr) continue; + currentSequence = { + index: Number.parseInt(indexStr, 10), + tasks: [], + }; + continue; + } + + // Match task lines like "- **task-1** - Foundation Task (To Do)" + if (currentSequence && line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)$/)) { + const taskMatch = line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)$/); + if (taskMatch) { + const [, id, title, status] = taskMatch; + if (id && title && status) { + currentSequence.tasks.push({ id, title, status }); + } + } + } + } + + if (currentSequence) { + sequences.push(currentSequence); + } + + // Extract unsequenced tasks + const unsequenced: Array<{ id: string; title: string; status: string }> = []; + let inUnsequenced = false; + + for (const line of lines) { + if (line.trim() === "## Unsequenced Tasks") { + inUnsequenced = true; + continue; + } + if (inUnsequenced && line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)$/)) { + const taskMatch = line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)$/); + if (taskMatch) { + const [, id, title, status] = taskMatch; + if (id && title && status) { + unsequenced.push({ id, title, status }); + } + } + } + if (inUnsequenced && line && line.startsWith("## ") && line !== "## Unsequenced Tasks") { + inUnsequenced = false; + } + } + + return { + sequences, + unsequenced, + metadata: { + totalTasks: metadata["Total Tasks"] || 0, + filteredTasks: metadata["Filtered Tasks"] || 0, + sequenceCount: metadata.Sequences || 0, + unsequencedCount: metadata["Unsequenced Tasks"] || 0, + includeCompleted: metadata["Include Completed"] || false, + filterStatus: metadata["Filter Status"] || null, + }, + }; +} + +/** + * Parse sequence plan markdown response into structured data + */ +export function parseSequencePlanMarkdown(markdown: string) { + const lines = markdown.split("\n"); + + // Extract summary metadata + const summary: Record<string, string | number> = {}; + let inSummaryTable = false; + + for (const line of lines) { + if (line.trim() === "## Summary") { + inSummaryTable = true; + continue; + } + if (inSummaryTable && line.startsWith("| ") && !line.includes("Metric")) { + const match = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|/); + if (match) { + const [, key, value] = match; + if (!key || !value) continue; + summary[key] = !Number.isNaN(Number(value)) ? Number(value) : value; + } + } + if (inSummaryTable && line.startsWith("## ") && line !== "## Summary") { + inSummaryTable = false; + } + } + + // Extract phases + const phases: Array<{ + phase: number; + name: string; + tasks: Array<{ id: string; title: string; status: string; assignee?: string[]; dependencies?: string[] }>; + dependsOn?: number[]; + }> = []; + + interface PhaseType { + phase: number; + name: string; + tasks: Array<{ id: string; title: string; status: string; assignee?: string[]; dependencies?: string[] }>; + dependsOn?: number[]; + } + let currentPhase: PhaseType | null = null; + let inPhaseTasks = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + // Match phase headers like "### Phase 1: Sequence 1" + const phaseMatch = line.match(/^### Phase (\d+): (.+)$/); + if (phaseMatch) { + if (currentPhase) { + phases.push(currentPhase); + } + const phaseNum = phaseMatch[1]; + const phaseName = phaseMatch[2]; + if (!phaseNum || !phaseName) continue; + currentPhase = { + phase: Number.parseInt(phaseNum, 10), + name: phaseName, + tasks: [], + dependsOn: [], + }; + inPhaseTasks = false; + continue; + } + + // Match dependency lines like "**Depends on:** Phase 1" + if (currentPhase && line.match(/^\*\*Depends on:\*\* (.+)$/)) { + const dependsMatch = line.match(/^\*\*Depends on:\*\* Phase (.+)$/); + if (dependsMatch?.[1]) { + const deps = dependsMatch[1].split(", Phase ").map((n) => Number.parseInt(n, 10)); + currentPhase.dependsOn = deps; + } + } + + // Mark when we enter the tasks section + if (currentPhase && line && line.trim() === "**Tasks:**") { + inPhaseTasks = true; + continue; + } + + // Match task lines like "- **task-1** - Foundation Task (To Do)" + if (currentPhase && inPhaseTasks && line && line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)(.*)$/)) { + const taskMatch = line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)(.*)$/); + if (taskMatch) { + const [, id, title, status, extra] = taskMatch; + if (!id || !title || !status) continue; + const task: { id: string; title: string; status: string; assignee?: string[]; dependencies?: string[] } = { + id, + title, + status, + }; + + // Parse assignee if present + if (extra) { + const assigneeMatch = extra.match(/\((.+?)\)/); + if (assigneeMatch?.[1]) { + task.assignee = assigneeMatch[1].split(", "); + } + } + + currentPhase.tasks.push(task); + } + } + + // Check for dependency lines + if (currentPhase && inPhaseTasks && line && line.match(/^\s+- Dependencies: (.+)$/)) { + const depMatch = line.match(/^\s+- Dependencies: (.+)$/); + if (depMatch?.[1] && currentPhase.tasks.length > 0) { + const lastTask = currentPhase.tasks[currentPhase.tasks.length - 1]; + if (lastTask) { + lastTask.dependencies = depMatch[1].split(", "); + } + } + } + } + + if (currentPhase) { + phases.push(currentPhase); + } + + // Extract unsequenced tasks + const unsequenced: Array<{ id: string; title: string; status: string; reason: string }> = []; + let inUnsequenced = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + if (line.trim() === "## Unsequenced Tasks") { + inUnsequenced = true; + continue; + } + if (inUnsequenced && line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)$/)) { + const taskMatch = line.match(/^- \*\*(.+?)\*\* - (.+?) \((.+?)\)$/); + if (taskMatch) { + const [, id, title, status] = taskMatch; + if (!id || !title || !status) continue; + const nextLine = lines[i + 1]; + let reason = ""; + if (nextLine?.match(/^\s+- (.+)$/)) { + const reasonMatch = nextLine.match(/^\s+- (.+)$/); + if (reasonMatch?.[1]) { + reason = reasonMatch[1]; + } + } + unsequenced.push({ id, title, status, reason }); + } + } + if (inUnsequenced && line && line.startsWith("## ") && line !== "## Unsequenced Tasks") { + inUnsequenced = false; + } + } + + return { + phases, + unsequenced, + summary: { + totalPhases: summary["Total Phases"] || 0, + totalTasksInPlan: summary["Tasks in Plan"] || 0, + unsequencedTasks: summary["Unsequenced Tasks"] || 0, + canStartImmediately: summary["Can Start Immediately"] || 0, + }, + }; +} + +/** + * Parse project overview markdown response into structured data + */ +export function parseProjectOverviewMarkdown(markdown: string) { + const lines = markdown.split("\n"); + + // Extract statistics from Project Statistics table + const statistics: Record<string, string | number> = {}; + let inProjectStats = false; + + // Extract status counts from Status Breakdown table + const statusCounts: Record<string, number> = {}; + let inStatusBreakdown = false; + + // Extract priority counts from Priority Breakdown table + const priorityCounts: Record<string, number> = {}; + let inPriorityBreakdown = false; + + // Extract recent activity and project health data + const recentActivity = { created: [], updated: [] }; + const projectHealth = { averageTaskAge: 0, staleTasks: [], blockedTasks: [] }; + + for (const line of lines) { + // Project Statistics section + if (line.trim() === "## Project Statistics") { + inProjectStats = true; + inStatusBreakdown = false; + inPriorityBreakdown = false; + continue; + } + + // Status Breakdown section + if (line.trim() === "## Status Breakdown") { + inProjectStats = false; + inStatusBreakdown = true; + inPriorityBreakdown = false; + continue; + } + + // Priority Breakdown section + if (line.trim() === "## Priority Breakdown") { + inProjectStats = false; + inStatusBreakdown = false; + inPriorityBreakdown = true; + continue; + } + + // Reset flags on other sections + if ( + line.startsWith("## ") && + !["## Project Statistics", "## Status Breakdown", "## Priority Breakdown"].includes(line.trim()) + ) { + inProjectStats = false; + inStatusBreakdown = false; + inPriorityBreakdown = false; + } + + // Parse table rows + if ( + line.startsWith("| ") && + !line.includes("Metric") && + !line.includes("Status") && + !line.includes("Priority") && + !line.includes("-----") + ) { + const match = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|/); + if (match) { + const [, key, value] = match; + if (!key || !value) continue; + + if (inProjectStats) { + // Convert values to appropriate types for project statistics + if (key === "Completion Rate") { + statistics[key] = Number.parseInt(value.replace("%", ""), 10); + } else if (!Number.isNaN(Number(value))) { + statistics[key] = Number(value); + } else { + statistics[key] = value; + } + } else if (inStatusBreakdown) { + statusCounts[key] = Number.parseInt(value, 10) || 0; + } else if (inPriorityBreakdown) { + priorityCounts[key] = Number.parseInt(value, 10) || 0; + } + } + } + } + + return { + success: true, + statistics: { + statusCounts, + priorityCounts, + totalTasks: statistics["Total Tasks"] || 0, + completedTasks: statistics["Completed Tasks"] || 0, + completionPercentage: statistics["Completion Rate"] || 0, + draftCount: statistics["Draft Tasks"] || 0, + recentActivity, + projectHealth, + }, + }; +} + +/** + * Parse config markdown response into structured data + */ +export function parseConfigMarkdown(markdown: string): unknown { + const lines = markdown.split("\n"); + + // Check if this is a single config value + let configKey: string | null = null; + let inJsonBlock = false; + const jsonContent: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim(); + if (!line) continue; + + // Match config key like "**projectName:**" + const keyMatch = line.match(/^\*\*(.+?):\*\*$/); + if (keyMatch?.[1]) { + configKey = keyMatch[1]; + continue; + } + + // Check for JSON code block + if (line === "```json") { + inJsonBlock = true; + continue; + } + + if (line === "```") { + inJsonBlock = false; + // Parse the JSON content + const jsonStr = jsonContent.join("\n"); + try { + return JSON.parse(jsonStr); + } catch { + return jsonStr; + } + } + + if (inJsonBlock) { + const rawLine = lines[i]; + if (rawLine !== undefined) { + jsonContent.push(rawLine); // Don't trim - preserve formatting + } + continue; + } + + // Match single value like "`Test Project`" + const valueMatch = line.match(/^`(.+)`$/); + if (valueMatch && configKey) { + const value = valueMatch[1]; + // Handle special values + if (value === "null") return null; + if (value === "true") return true; + if (value === "false") return false; + if (!Number.isNaN(Number(value)) && value !== "") return Number(value); + return value; + } + } + + // If no specific pattern found, try to parse as full config object + // Look for the config table format: | Setting | Value | + const config: Record<string, unknown> = {}; + + for (const line of lines) { + // Match table rows: | projectName | `Test Project` | + const tableMatch = line.match(/^\|\s*([^|]+?)\s*\|\s*`([^`]+?)`\s*\|$/); + if (tableMatch) { + const [, key, value] = tableMatch; + if (!key || !value) continue; + const cleanKey = key.trim(); + let parsedValue: unknown = value; + + // Parse array format: [To Do, In Progress, Done] + if (value.startsWith("[") && value.endsWith("]")) { + const arrayContent = value.slice(1, -1).trim(); + if (arrayContent) { + parsedValue = arrayContent.split(",").map((v) => v.trim()); + } else { + parsedValue = []; + } + } else if (value === "null") { + parsedValue = null; + } else if (value === "true") { + parsedValue = true; + } else if (value === "false") { + parsedValue = false; + } else if (!Number.isNaN(Number(value)) && value !== "") { + parsedValue = Number(value); + } + + config[cleanKey] = parsedValue; + } + + // Also handle the key-value format for single configs + const keyMatch = line.match(/^\*\*(.+?):\*\*$/); + if (keyMatch && keyMatch[1] !== undefined) { + const key = keyMatch[1]; + // Look for the value in the next non-empty line + const nextLineIndex = lines.findIndex((l, i) => i > lines.indexOf(line) && l.trim().length > 0); + if (nextLineIndex !== -1) { + const valueLine = lines[nextLineIndex]; + if (valueLine) { + const valueMatch = valueLine.match(/^`(.+)`$/); + if (valueMatch && valueMatch[1] !== undefined) { + const value = valueMatch[1]; + if (value === "null") config[key] = null; + else if (value === "true") config[key] = true; + else if (value === "false") config[key] = false; + else if (!Number.isNaN(Number(value)) && value !== "") config[key] = Number(value); + else config[key] = value; + } + } + } + } + } + + return Object.keys(config).length > 0 ? config : markdown; +} diff --git a/src/test/markdown.test.ts b/src/test/markdown.test.ts new file mode 100644 index 0000000..c4e25b8 --- /dev/null +++ b/src/test/markdown.test.ts @@ -0,0 +1,573 @@ +import { describe, expect, it } from "bun:test"; +import { parseDecision, parseDocument, parseMarkdown, parseTask } from "../markdown/parser.ts"; +import { + serializeDecision, + serializeDocument, + serializeTask, + updateTaskAcceptanceCriteria, +} from "../markdown/serializer.ts"; +import type { Decision, Document, Task } from "../types/index.ts"; + +describe("Markdown Parser", () => { + describe("parseMarkdown", () => { + it("should parse frontmatter and content", () => { + const content = `--- +title: "Test Task" +status: "To Do" +labels: ["bug", "urgent"] +--- + +This is the task description. + +## Acceptance Criteria + +- [ ] First criterion +- [ ] Second criterion`; + + const result = parseMarkdown(content); + + expect(result.frontmatter.title).toBe("Test Task"); + expect(result.frontmatter.status).toBe("To Do"); + expect(result.frontmatter.labels).toEqual(["bug", "urgent"]); + expect(result.content).toContain("This is the task description"); + }); + + it("should handle content without frontmatter", () => { + const content = "Just some markdown content"; + const result = parseMarkdown(content); + + expect(result.frontmatter).toEqual({}); + expect(result.content).toBe("Just some markdown content"); + }); + + it("should handle empty content", () => { + const content = ""; + const result = parseMarkdown(content); + + expect(result.frontmatter).toEqual({}); + expect(result.content).toBe(""); + }); + }); + + describe("parseTask", () => { + it("should parse a complete task", () => { + const content = `--- +id: task-1 +title: "Fix login bug" +status: "In Progress" +assignee: "@developer" +reporter: "@manager" +created_date: "2025-06-03" +labels: ["bug", "frontend"] +milestone: "v1.0" +dependencies: ["task-0"] +parent_task_id: "task-parent" +subtasks: ["task-1.1", "task-1.2"] +--- + +## Description + +Fix the login bug that prevents users from signing in. + +## Acceptance Criteria + +- [ ] Login form validates correctly +- [ ] Error messages are displayed properly`; + + const task = parseTask(content); + + expect(task.id).toBe("task-1"); + expect(task.title).toBe("Fix login bug"); + expect(task.status).toBe("In Progress"); + expect(task.assignee).toEqual(["@developer"]); + expect(task.reporter).toBe("@manager"); + expect(task.createdDate).toBe("2025-06-03"); + expect(task.labels).toEqual(["bug", "frontend"]); + expect(task.milestone).toBe("v1.0"); + expect(task.dependencies).toEqual(["task-0"]); + expect(task.parentTaskId).toBe("task-parent"); + expect(task.subtasks).toEqual(["task-1.1", "task-1.2"]); + expect(task.acceptanceCriteriaItems?.map((item) => item.text)).toEqual([ + "Login form validates correctly", + "Error messages are displayed properly", + ]); + }); + + it("should parse a task with minimal fields", () => { + const content = `--- +id: task-2 +title: "Simple task" +--- + +Just a basic task.`; + + const task = parseTask(content); + + expect(task.id).toBe("task-2"); + expect(task.title).toBe("Simple task"); + expect(task.status).toBe(""); + expect(task.assignee).toEqual([]); + expect(task.reporter).toBeUndefined(); + expect(task.labels).toEqual([]); + expect(task.dependencies).toEqual([]); + expect(task.acceptanceCriteriaItems).toEqual([]); + expect(task.parentTaskId).toBeUndefined(); + expect(task.subtasks).toBeUndefined(); + }); + + it("should handle task with empty status", () => { + const content = `--- +id: task-3 +title: "No status task" +created_date: "2025-06-07" +--- + +Task without status.`; + + const task = parseTask(content); + + expect(task.status).toBe(""); + expect(task.createdDate).toBe("2025-06-07"); + }); + + it("should parse unquoted created_date", () => { + const content = `--- +id: task-5 +title: "Unquoted" +created_date: 2025-06-08 +---`; + + const task = parseTask(content); + + expect(task.createdDate).toBe("2025-06-08"); + }); + + it("should parse created_date in short format", () => { + const content = `--- +id: task-6 +title: "Short" +created_date: 08-06-25 +---`; + + const task = parseTask(content); + + expect(task.createdDate).toBe("2025-06-08"); + }); + + it("should extract acceptance criteria with checked items", () => { + const content = `--- +id: task-4 +title: "Test with mixed criteria" +--- + +## Acceptance Criteria + +- [ ] Todo item +- [x] Done item +- [ ] Another todo`; + + const task = parseTask(content); + + expect(task.acceptanceCriteriaItems?.map((item) => item.text)).toEqual([ + "Todo item", + "Done item", + "Another todo", + ]); + }); + + it("should parse unquoted assignee names starting with @", () => { + const content = `--- +id: task-5 +title: "Assignee Test" +assignee: @MrLesk +--- + +Test task.`; + + const task = parseTask(content); + + expect(task.assignee).toEqual(["@MrLesk"]); + }); + + it("should parse unquoted reporter names starting with @", () => { + const content = `--- +id: task-6 +title: "Reporter Test" +assignee: [] +reporter: @MrLesk +created_date: 2025-06-08 +--- + +Test task with reporter.`; + + const task = parseTask(content); + + expect(task.reporter).toBe("@MrLesk"); + }); + }); + + describe("parseDecision", () => { + it("should parse a decision log", () => { + const content = `--- +id: decision-1 +title: "Use TypeScript for backend" +date: "2025-06-03" +status: "accepted" +--- + +## Context + +We need to choose a language for the backend. + +## Decision + +We will use TypeScript for better type safety. + +## Consequences + +Better development experience but steeper learning curve.`; + + const decision = parseDecision(content); + + expect(decision.id).toBe("decision-1"); + expect(decision.title).toBe("Use TypeScript for backend"); + expect(decision.status).toBe("accepted"); + expect(decision.context).toBe("We need to choose a language for the backend."); + expect(decision.decision).toBe("We will use TypeScript for better type safety."); + expect(decision.consequences).toBe("Better development experience but steeper learning curve."); + }); + + it("should parse decision log with alternatives", () => { + const content = `--- +id: decision-2 +title: "Choose database" +date: "2025-06-03" +status: "proposed" +--- + +## Context + +Need a database solution. + +## Decision + +Use PostgreSQL. + +## Consequences + +Good performance and reliability. + +## Alternatives + +Considered MongoDB and MySQL.`; + + const decision = parseDecision(content); + + expect(decision.alternatives).toBe("Considered MongoDB and MySQL."); + }); + + it("should handle missing sections", () => { + const content = `--- +id: decision-3 +title: "Minimal decision" +date: "2025-06-03" +status: "proposed" +--- + +## Context + +Some context.`; + + const decision = parseDecision(content); + + expect(decision.context).toBe("Some context."); + expect(decision.decision).toBe(""); + expect(decision.consequences).toBe(""); + expect(decision.alternatives).toBeUndefined(); + }); + }); + + describe("parseDocument", () => { + it("should parse a document", () => { + const content = `--- +id: doc-1 +title: "API Guide" +type: "guide" +created_date: 2025-06-07 +tags: [api] +--- + +Document body.`; + + const doc = parseDocument(content); + + expect(doc.id).toBe("doc-1"); + expect(doc.title).toBe("API Guide"); + expect(doc.type).toBe("guide"); + expect(doc.createdDate).toBe("2025-06-07"); + expect(doc.tags).toEqual(["api"]); + expect(doc.rawContent).toBe("Document body."); + }); + }); +}); + +describe("Markdown Serializer", () => { + describe("serializeTask", () => { + it("should serialize a task correctly", () => { + const task: Task = { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: ["@developer"], + reporter: "@manager", + createdDate: "2025-06-03", + labels: ["bug", "frontend"], + milestone: "v1.0", + dependencies: ["task-0"], + description: "This is a test task description.", + }; + + const result = serializeTask(task); + + expect(result).toContain("id: task-1"); + expect(result).toContain("title: Test Task"); + expect(result).toContain("status: To Do"); + expect(result).toContain("created_date: '2025-06-03'"); + expect(result).toContain("labels:"); + expect(result).toContain("- bug"); + expect(result).toContain("- frontend"); + expect(result).toContain("## Description"); + expect(result).toContain("This is a test task description."); + }); + + it("should serialize task with subtasks", () => { + const task: Task = { + id: "task-parent", + title: "Parent Task", + status: "In Progress", + assignee: [], + createdDate: "2025-06-03", + labels: [], + dependencies: [], + description: "A parent task with subtasks.", + subtasks: ["task-parent.1", "task-parent.2"], + }; + + const result = serializeTask(task); + + expect(result).toContain("subtasks:"); + expect(result).toContain("- task-parent.1"); + expect(result).toContain("- task-parent.2"); + }); + + it("should serialize task with parent", () => { + const task: Task = { + id: "task-1.1", + title: "Subtask", + status: "To Do", + assignee: [], + createdDate: "2025-06-03", + labels: [], + dependencies: [], + description: "A subtask.", + parentTaskId: "task-1", + }; + + const result = serializeTask(task); + + expect(result).toContain("parent_task_id: task-1"); + }); + + it("should serialize minimal task", () => { + const task: Task = { + id: "task-minimal", + title: "Minimal Task", + status: "Draft", + assignee: [], + createdDate: "2025-06-03", + labels: [], + dependencies: [], + description: "Minimal task.", + }; + + const result = serializeTask(task); + + expect(result).toContain("id: task-minimal"); + expect(result).toContain("title: Minimal Task"); + expect(result).toContain("assignee: []"); + expect(result).not.toContain("reporter:"); + expect(result).not.toContain("updated_date:"); + }); + + it("removes acceptance criteria section when list becomes empty", () => { + const task: Task = { + id: "task-clean", + title: "Cleanup Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-10", + labels: [], + dependencies: [], + description: "Some details", + acceptanceCriteriaItems: [], + }; + + const result = serializeTask(task); + + expect(result).not.toContain("## Acceptance Criteria"); + expect(result).not.toContain("<!-- AC:BEGIN -->"); + expect(result).toContain("## Description"); + expect(result).toContain("Some details"); + }); + + it("serializes acceptance criteria when structured items exist", () => { + const task: Task = { + id: "task-freeform", + title: "Legacy Criteria Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-11", + labels: [], + dependencies: [], + description: "Some details", + acceptanceCriteriaItems: [{ index: 1, text: "Criterion A", checked: false }], + }; + + const result = serializeTask(task); + + expect(result).toContain("## Acceptance Criteria"); + expect(result).toContain("- [ ] #1 Criterion A"); + }); + }); + + describe("serializeDecision", () => { + it("should serialize a decision log correctly", () => { + const decision: Decision = { + id: "decision-1", + title: "Use TypeScript", + date: "2025-06-03", + status: "accepted", + context: "We need type safety", + decision: "Use TypeScript", + consequences: "Better DX", + rawContent: "", + }; + + const result = serializeDecision(decision); + + expect(result).toContain("id: decision-1"); + expect(result).toContain("## Context"); + expect(result).toContain("We need type safety"); + expect(result).toContain("## Decision"); + expect(result).toContain("Use TypeScript"); + }); + + it("should serialize decision log with alternatives", () => { + const decision: Decision = { + id: "decision-2", + title: "Database Choice", + date: "2025-06-03", + status: "accepted", + context: "Need database", + decision: "PostgreSQL", + consequences: "Good performance", + alternatives: "Considered MongoDB", + rawContent: "", + }; + + const result = serializeDecision(decision); + + expect(result).toContain("## Alternatives"); + expect(result).toContain("Considered MongoDB"); + }); + }); + + describe("serializeDocument", () => { + it("should serialize a document correctly", () => { + const document: Document = { + id: "doc-1", + title: "API Documentation", + type: "specification", + createdDate: "2025-06-07", + updatedDate: "2025-06-08", + rawContent: "This document describes the API endpoints.", + tags: ["api", "docs"], + }; + + const result = serializeDocument(document); + + expect(result).toContain("id: doc-1"); + expect(result).toContain("title: API Documentation"); + expect(result).toContain("type: specification"); + expect(result).toContain("created_date: '2025-06-07'"); + expect(result).toContain("updated_date: '2025-06-08'"); + expect(result).toContain("tags:"); + expect(result).toContain("- api"); + expect(result).toContain("- docs"); + expect(result).toContain("This document describes the API endpoints."); + }); + + it("should serialize document without optional fields", () => { + const document: Document = { + id: "doc-2", + title: "Simple Doc", + type: "guide", + createdDate: "2025-06-07", + rawContent: "Simple content.", + }; + + const result = serializeDocument(document); + + expect(result).toContain("id: doc-2"); + expect(result).not.toContain("updated_date:"); + expect(result).not.toContain("tags:"); + }); + }); + + describe("updateTaskAcceptanceCriteria", () => { + it("should add acceptance criteria to content without existing section", () => { + const content = "# Task Description\n\nThis is a simple task."; + const criteria = ["Login works correctly", "Error handling is proper"]; + + const result = updateTaskAcceptanceCriteria(content, criteria); + + expect(result).toContain("## Acceptance Criteria"); + expect(result).toContain("- [ ] Login works correctly"); + expect(result).toContain("- [ ] Error handling is proper"); + }); + + it("should replace existing acceptance criteria section", () => { + const content = `# Task Description + +This is a task with existing criteria. + +## Acceptance Criteria + +- [ ] Old criterion 1 +- [ ] Old criterion 2 + +## Notes + +Some additional notes.`; + + const criteria = ["New criterion 1", "New criterion 2"]; + + const result = updateTaskAcceptanceCriteria(content, criteria); + + expect(result).toContain("- [ ] New criterion 1"); + expect(result).toContain("- [ ] New criterion 2"); + expect(result).not.toContain("Old criterion 1"); + expect(result).toContain("## Notes"); + }); + + it("should handle empty criteria array", () => { + const content = "# Task Description\n\nSimple task."; + const criteria: string[] = []; + + const result = updateTaskAcceptanceCriteria(content, criteria); + + expect(result).toContain("## Acceptance Criteria"); + expect(result).not.toContain("- [ ]"); + }); + }); +}); diff --git a/src/test/mcp-documents.test.ts b/src/test/mcp-documents.test.ts new file mode 100644 index 0000000..b1b2207 --- /dev/null +++ b/src/test/mcp-documents.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { $ } from "bun"; +import { McpServer } from "../mcp/server.ts"; +import { registerDocumentTools } from "../mcp/tools/documents/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +// Helper to extract text from MCP content (handles union types) +const getText = (content: unknown[] | undefined, index = 0): string => { + const item = content?.[index] as { text?: string } | undefined; + return item?.text ?? ""; +}; + +let TEST_DIR: string; +let mcpServer: McpServer; + +async function loadConfig(server: McpServer) { + const config = await server.filesystem.loadConfig(); + if (!config) { + throw new Error("Failed to load backlog configuration for tests"); + } + return config; +} + +describe("MCP document tools", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("mcp-documents"); + mcpServer = new McpServer(TEST_DIR, "Test instructions"); + await mcpServer.filesystem.ensureBacklogStructure(); + + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + await mcpServer.initializeProject("Docs Project"); + const config = await loadConfig(mcpServer); + registerDocumentTools(mcpServer, config); + }); + + afterEach(async () => { + try { + await mcpServer.stop(); + } catch { + // ignore shutdown issues in tests + } + await safeCleanup(TEST_DIR); + }); + + it("creates and lists documents", async () => { + const createResult = await mcpServer.testInterface.callTool({ + params: { + name: "document_create", + arguments: { + title: "Engineering Guidelines", + content: "# Overview\n\nFollow the documented practices.", + }, + }, + }); + + const createText = getText(createResult.content); + expect(createText).toContain("Document created successfully."); + expect(createText).toContain("Document doc-1 - Engineering Guidelines"); + expect(createText).toContain("# Overview"); + + const listResult = await mcpServer.testInterface.callTool({ + params: { name: "document_list", arguments: {} }, + }); + + const listText = getText(listResult.content); + expect(listText).toContain("Documents:"); + expect(listText).toContain("doc-1 - Engineering Guidelines"); + expect(listText).toContain("tags: (none)"); + }); + + it("filters documents using substring search", async () => { + await mcpServer.testInterface.callTool({ + params: { + name: "document_create", + arguments: { + title: "Engineering Guidelines", + content: "Content", + }, + }, + }); + + await mcpServer.testInterface.callTool({ + params: { + name: "document_create", + arguments: { + title: "Product Strategy", + content: "Strategy content", + }, + }, + }); + + const filteredResult = await mcpServer.testInterface.callTool({ + params: { name: "document_list", arguments: { search: "strat" } }, + }); + + const filteredText = getText(filteredResult.content); + expect(filteredText).toContain("Documents:"); + expect(filteredText).toContain("Product Strategy"); + expect(filteredText).not.toContain("Engineering Guidelines"); + }); + + it("views documents regardless of ID casing or padding", async () => { + await mcpServer.testInterface.callTool({ + params: { + name: "document_create", + arguments: { + title: "Runbook", + content: "Step 1: Do the thing.", + }, + }, + }); + + const withPrefix = await mcpServer.testInterface.callTool({ + params: { name: "document_view", arguments: { id: "doc-1" } }, + }); + const withoutPrefix = await mcpServer.testInterface.callTool({ + params: { name: "document_view", arguments: { id: "1" } }, + }); + const uppercase = await mcpServer.testInterface.callTool({ + params: { name: "document_view", arguments: { id: "DOC-0001" } }, + }); + const zeroPadded = await mcpServer.testInterface.callTool({ + params: { name: "document_view", arguments: { id: "0001" } }, + }); + + const prefixText = getText(withPrefix.content); + const noPrefixText = getText(withoutPrefix.content); + const uppercaseText = getText(uppercase.content); + const zeroPaddedText = getText(zeroPadded.content); + expect(prefixText).toContain("Document doc-1 - Runbook"); + expect(prefixText).toContain("Step 1: Do the thing."); + expect(noPrefixText).toContain("Document doc-1 - Runbook"); + expect(uppercaseText).toContain("Document doc-1 - Runbook"); + expect(zeroPaddedText).toContain("Document doc-1 - Runbook"); + }); + + it("updates documents including title changes", async () => { + await mcpServer.testInterface.callTool({ + params: { + name: "document_create", + arguments: { + title: "Incident Response", + content: "Initial content", + }, + }, + }); + + const updateResult = await mcpServer.testInterface.callTool({ + params: { + name: "document_update", + arguments: { + id: "DOC-0001", + title: "Incident Response Handbook", + content: "Updated procedures", + }, + }, + }); + + const updateText = getText(updateResult.content); + expect(updateText).toContain("Document updated successfully."); + expect(updateText).toContain("Document doc-1 - Incident Response Handbook"); + expect(updateText).toContain("Updated procedures"); + + const viewResult = await mcpServer.testInterface.callTool({ + params: { name: "document_view", arguments: { id: "doc-1" } }, + }); + const viewText = getText(viewResult.content); + expect(viewText).toContain("Incident Response Handbook"); + expect(viewText).toContain("Updated procedures"); + }); + + it("searches documents and includes formatted scores", async () => { + await mcpServer.testInterface.callTool({ + params: { + name: "document_create", + arguments: { + title: "Architecture Overview", + content: "Contains service topology details.", + }, + }, + }); + + const searchResult = await mcpServer.testInterface.callTool({ + params: { + name: "document_search", + arguments: { + query: "architecture", + }, + }, + }); + + const searchText = getText(searchResult.content); + expect(searchText).toContain("Documents:"); + expect(searchText).toMatch(/Architecture Overview/); + expect(searchText).toMatch(/\[score [0-1]\.\d{3}]/); + }); +}); diff --git a/src/test/mcp-fallback.test.ts b/src/test/mcp-fallback.test.ts new file mode 100644 index 0000000..cc3da62 --- /dev/null +++ b/src/test/mcp-fallback.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MCP_INIT_REQUIRED_GUIDE } from "../guidelines/mcp/index.ts"; +import { createMcpServer } from "../mcp/server.ts"; + +// Helper to extract text from MCP contents (handles union types) +const getContentsText = (contents: unknown[] | undefined, index = 0): string => { + const item = contents?.[index] as { text?: string } | undefined; + return item?.text ?? ""; +}; + +describe("MCP Server Fallback Mode", () => { + let tempDir: string; + + beforeEach(() => { + // Create a temporary directory without backlog initialization + tempDir = mkdtempSync(join(tmpdir(), "mcp-fallback-test-")); + }); + + afterEach(() => { + // Clean up temp directory + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("should start successfully in non-backlog directory", async () => { + // Should not throw an error + const server = await createMcpServer(tempDir, { debug: false }); + + expect(server).toBeDefined(); + expect(server.getServer()).toBeDefined(); + }); + + test("should provide backlog://init-required resource in fallback mode", async () => { + const server = await createMcpServer(tempDir, { debug: false }); + + const resources = await server.testInterface.listResources(); + + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0]?.uri).toBe("backlog://init-required"); + expect(resources.resources[0]?.name).toBe("Backlog.md Not Initialized"); + }); + + test("should be able to read backlog://init-required resource", async () => { + const server = await createMcpServer(tempDir, { debug: false }); + + const result = await server.testInterface.readResource({ + params: { uri: "backlog://init-required" }, + }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0]?.uri).toBe("backlog://init-required"); + expect(getContentsText(result.contents)).toBe(MCP_INIT_REQUIRED_GUIDE); + }); + + test("should not provide task tools in fallback mode", async () => { + const server = await createMcpServer(tempDir, { debug: false }); + + const tools = await server.testInterface.listTools(); + + // In fallback mode, no task tools should be registered + expect(tools.tools).toHaveLength(0); + }); +}); diff --git a/src/test/mcp-server.test.ts b/src/test/mcp-server.test.ts new file mode 100644 index 0000000..3fc87c0 --- /dev/null +++ b/src/test/mcp-server.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { $ } from "bun"; +import { + MCP_TASK_COMPLETION_GUIDE, + MCP_TASK_CREATION_GUIDE, + MCP_TASK_EXECUTION_GUIDE, + MCP_WORKFLOW_OVERVIEW, + MCP_WORKFLOW_OVERVIEW_TOOLS, +} from "../guidelines/mcp/index.ts"; +import { registerWorkflowResources } from "../mcp/resources/workflow/index.ts"; +import { createMcpServer, McpServer } from "../mcp/server.ts"; +import { registerTaskTools } from "../mcp/tools/tasks/index.ts"; +import { registerWorkflowTools } from "../mcp/tools/workflow/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +// Helpers to extract text from MCP responses (handles union types) +const getText = (content: unknown[] | undefined, index = 0): string => { + const item = content?.[index] as { text?: string } | undefined; + return item?.text ?? ""; +}; +const getContentsText = (contents: unknown[] | undefined, index = 0): string => { + const item = contents?.[index] as { text?: string } | undefined; + return item?.text ?? ""; +}; + +let TEST_DIR: string; + +async function bootstrapServer(): Promise<McpServer> { + TEST_DIR = createUniqueTestDir("mcp-server"); + // Use normal mode instructions for bootstrapped test server + const server = new McpServer(TEST_DIR, "Test instructions"); + + await server.filesystem.ensureBacklogStructure(); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + await server.initializeProject("Test Project"); + + // Register workflow resources and tools manually (normally done in createMcpServer) + registerWorkflowResources(server); + registerWorkflowTools(server); + + return server; +} + +describe("McpServer bootstrap", () => { + afterEach(async () => { + await safeCleanup(TEST_DIR); + }); + + it("exposes core capabilities before registration", async () => { + const server = await bootstrapServer(); + + const tools = await server.testInterface.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual([ + "get_workflow_overview", + "get_task_creation_guide", + "get_task_execution_guide", + "get_task_completion_guide", + ]); + + const resources = await server.testInterface.listResources(); + expect(resources.resources.map((r) => r.uri)).toEqual([ + "backlog://workflow/overview", + "backlog://workflow/task-creation", + "backlog://workflow/task-execution", + "backlog://workflow/task-completion", + ]); + + const prompts = await server.testInterface.listPrompts(); + expect(prompts.prompts).toEqual([]); + + const resourceTemplates = await server.testInterface.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toEqual([]); + + await server.stop(); + }); + + it("workflow overview resource returns correct content", async () => { + const server = await bootstrapServer(); + + const result = await server.testInterface.readResource({ + params: { uri: "backlog://workflow/overview" }, + }); + + expect(result.contents).toHaveLength(1); + expect(getContentsText(result.contents)).toBe(MCP_WORKFLOW_OVERVIEW); + expect(result.contents[0]?.mimeType).toBe("text/markdown"); + + await server.stop(); + }); + + it("task creation guide resource returns correct content", async () => { + const server = await bootstrapServer(); + + const result = await server.testInterface.readResource({ + params: { uri: "backlog://workflow/task-creation" }, + }); + + expect(result.contents).toHaveLength(1); + expect(getContentsText(result.contents)).toBe(MCP_TASK_CREATION_GUIDE); + + await server.stop(); + }); + + it("task execution guide resource returns correct content", async () => { + const server = await bootstrapServer(); + + const result = await server.testInterface.readResource({ + params: { uri: "backlog://workflow/task-execution" }, + }); + + expect(result.contents).toHaveLength(1); + expect(getContentsText(result.contents)).toBe(MCP_TASK_EXECUTION_GUIDE); + + await server.stop(); + }); + + it("task completion guide resource returns correct content", async () => { + const server = await bootstrapServer(); + + const result = await server.testInterface.readResource({ + params: { uri: "backlog://workflow/task-completion" }, + }); + + expect(result.contents).toHaveLength(1); + expect(getContentsText(result.contents)).toBe(MCP_TASK_COMPLETION_GUIDE); + + await server.stop(); + }); + + it("workflow tools mirror resource content", async () => { + const server = await bootstrapServer(); + + const overview = await server.testInterface.callTool({ + params: { name: "get_workflow_overview", arguments: {} }, + }); + expect(getText(overview.content)).toBe(MCP_WORKFLOW_OVERVIEW_TOOLS); + + const creation = await server.testInterface.callTool({ + params: { name: "get_task_creation_guide", arguments: {} }, + }); + expect(getText(creation.content)).toBe(MCP_TASK_CREATION_GUIDE); + + await server.stop(); + }); + + it("registers task tools via helpers", async () => { + const server = await bootstrapServer(); + const config = await server.filesystem.loadConfig(); + if (!config) { + throw new Error("Failed to load config"); + } + + registerTaskTools(server, config); + + const tools = await server.testInterface.listTools(); + const toolNames = tools.tools.map((tool) => tool.name).sort(); + expect(toolNames).toEqual([ + "get_task_completion_guide", + "get_task_creation_guide", + "get_task_execution_guide", + "get_workflow_overview", + "task_archive", + "task_create", + "task_edit", + "task_list", + "task_search", + "task_view", + ]); + + const resources = await server.testInterface.listResources(); + expect(resources.resources.map((r) => r.uri)).toEqual([ + "backlog://workflow/overview", + "backlog://workflow/task-creation", + "backlog://workflow/task-execution", + "backlog://workflow/task-completion", + ]); + expect(MCP_WORKFLOW_OVERVIEW).toContain("## Backlog.md Overview (MCP)"); + + const resourceTemplates = await server.testInterface.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toEqual([]); + + await server.stop(); + }); + + it("createMcpServer wires stdio-ready instance", async () => { + TEST_DIR = createUniqueTestDir("mcp-server-factory"); + + const bootstrap = new McpServer(TEST_DIR, "Bootstrap instructions"); + await bootstrap.filesystem.ensureBacklogStructure(); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await bootstrap.initializeProject("Factory Project"); + await bootstrap.stop(); + + const server = await createMcpServer(TEST_DIR); + + const tools = await server.testInterface.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual([ + "get_workflow_overview", + "get_task_creation_guide", + "get_task_execution_guide", + "get_task_completion_guide", + "task_create", + "task_list", + "task_search", + "task_edit", + "task_view", + "task_archive", + "document_list", + "document_view", + "document_create", + "document_update", + "document_search", + ]); + + const resources = await server.testInterface.listResources(); + expect(resources.resources.map((r) => r.uri)).toEqual([ + "backlog://workflow/overview", + "backlog://workflow/task-creation", + "backlog://workflow/task-execution", + "backlog://workflow/task-completion", + ]); + expect(MCP_WORKFLOW_OVERVIEW).toContain("## Backlog.md Overview (MCP)"); + + const resourceTemplates = await server.testInterface.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toEqual([]); + + await server.connect(); + await server.start(); + await server.stop(); + await safeCleanup(TEST_DIR); + }); +}); diff --git a/src/test/mcp-tasks-local-filter.test.ts b/src/test/mcp-tasks-local-filter.test.ts new file mode 100644 index 0000000..96277a0 --- /dev/null +++ b/src/test/mcp-tasks-local-filter.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import type { McpServer } from "../mcp/server.ts"; +import { TaskHandlers } from "../mcp/tools/tasks/handlers.ts"; +import type { Task, TaskSearchResult } from "../types/index.ts"; + +const localTask: Task = { + id: "task-1", + title: "Local task", + status: "To Do", + assignee: [], + createdDate: "2025-12-03", + labels: [], + dependencies: [], + source: "local", +}; + +const remoteTask: Task = { + id: "task-2", + title: "Remote task", + status: "To Do", + assignee: [], + createdDate: "2025-12-03", + labels: [], + dependencies: [], + source: "remote", +}; + +describe("MCP task tools local filtering", () => { + const mockConfig = { statuses: ["To Do", "In Progress", "Done"] }; + + it("filters cross-branch tasks out of task_list", async () => { + const handlers = new TaskHandlers({ + queryTasks: async () => [localTask, remoteTask], + filesystem: { + loadConfig: async () => mockConfig, + }, + } as unknown as McpServer); + + const result = await handlers.listTasks({}); + const text = (result.content ?? []) + .map((c) => (typeof c === "object" && c && "text" in c ? c.text : "")) + .join("\n"); + + expect(text).toContain("task-1 - Local task"); + expect(text).not.toContain("task-2 - Remote task"); + }); + + it("filters cross-branch tasks out of task_search", async () => { + const searchResults: TaskSearchResult[] = [ + { type: "task", task: localTask, score: 0.1 }, + { type: "task", task: remoteTask, score: 0.2 }, + ]; + + const handlers = new TaskHandlers({ + getSearchService: async () => ({ + search: () => searchResults, + }), + filesystem: { + loadConfig: async () => mockConfig, + }, + } as unknown as McpServer); + + const result = await handlers.searchTasks({ query: "task" }); + const text = (result.content ?? []) + .map((c) => (typeof c === "object" && c && "text" in c ? c.text : "")) + .join("\n"); + + expect(text).toContain("task-1 - Local task"); + expect(text).not.toContain("task-2 - Remote task"); + }); +}); diff --git a/src/test/mcp-tasks.test.ts b/src/test/mcp-tasks.test.ts new file mode 100644 index 0000000..33066f9 --- /dev/null +++ b/src/test/mcp-tasks.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { $ } from "bun"; +import { DEFAULT_STATUSES } from "../constants/index.ts"; +import { McpServer } from "../mcp/server.ts"; +import { registerTaskTools } from "../mcp/tools/tasks/index.ts"; +import type { JsonSchema } from "../mcp/validation/validators.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +// Helper to extract text from MCP content (handles union types) +const getText = (content: unknown[] | undefined, index = 0): string => { + const item = content?.[index] as { text?: string } | undefined; + return item?.text ?? ""; +}; + +let TEST_DIR: string; +let mcpServer: McpServer; + +async function loadConfig(server: McpServer) { + const config = await server.filesystem.loadConfig(); + if (!config) { + throw new Error("Failed to load backlog configuration for tests"); + } + return config; +} + +describe("MCP task tools (MVP)", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("mcp-tasks"); + mcpServer = new McpServer(TEST_DIR, "Test instructions"); + await mcpServer.filesystem.ensureBacklogStructure(); + + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + + await mcpServer.initializeProject("Test Project"); + + const config = await loadConfig(mcpServer); + registerTaskTools(mcpServer, config); + }); + + afterEach(async () => { + try { + await mcpServer.stop(); + } catch { + // ignore + } + await safeCleanup(TEST_DIR); + }); + + it("creates and lists tasks", async () => { + const createResult = await mcpServer.testInterface.callTool({ + params: { + name: "task_create", + arguments: { + title: "Agent onboarding checklist", + description: "Steps to onboard a new AI agent", + labels: ["agents", "workflow"], + priority: "high", + acceptanceCriteria: ["Credentials provisioned", "Documentation shared"], + }, + }, + }); + + expect(getText(createResult.content)).toContain("Task task-1 - Agent onboarding checklist"); + + const listResult = await mcpServer.testInterface.callTool({ + params: { name: "task_list", arguments: { search: "onboarding" } }, + }); + + const listText = (listResult.content ?? []).map((entry) => ("text" in entry ? entry.text : "")).join("\n\n"); + expect(listText).toContain("To Do:"); + expect(listText).toContain("[HIGH] task-1 - Agent onboarding checklist"); + expect(listText).not.toContain("Implementation Plan:"); + expect(listText).not.toContain("Acceptance Criteria:"); + + const searchResult = await mcpServer.testInterface.callTool({ + params: { name: "task_search", arguments: { query: "agent" } }, + }); + + const searchText = getText(searchResult.content); + expect(searchText).toContain("Tasks:"); + expect(searchText).toContain("task-1 - Agent onboarding checklist"); + expect(searchText).toContain("(To Do)"); + expect(searchText).not.toContain("Implementation Plan:"); + }); + + it("exposes status enums and defaults from configuration", async () => { + const config = await loadConfig(mcpServer); + const expectedStatuses = + config.statuses && config.statuses.length > 0 ? [...config.statuses] : Array.from(DEFAULT_STATUSES); + const tools = await mcpServer.testInterface.listTools(); + const toolByName = new Map(tools.tools.map((tool) => [tool.name, tool])); + + const createSchema = toolByName.get("task_create")?.inputSchema as JsonSchema | undefined; + const editSchema = toolByName.get("task_edit")?.inputSchema as JsonSchema | undefined; + + const createStatusSchema = createSchema?.properties?.status; + const editStatusSchema = editSchema?.properties?.status; + + expect(createStatusSchema?.enum).toEqual(expectedStatuses); + expect(createStatusSchema?.default).toBe(expectedStatuses[0] ?? DEFAULT_STATUSES[0]); + expect(createStatusSchema?.enumCaseInsensitive).toBe(true); + expect(createStatusSchema?.enumNormalizeWhitespace).toBe(true); + + expect(editStatusSchema?.enum).toEqual(expectedStatuses); + expect(editStatusSchema?.default).toBe(expectedStatuses[0] ?? DEFAULT_STATUSES[0]); + expect(editStatusSchema?.enumCaseInsensitive).toBe(true); + expect(editStatusSchema?.enumNormalizeWhitespace).toBe(true); + }); + + it("allows case-insensitive and whitespace-normalized status values", async () => { + const createResult = await mcpServer.testInterface.callTool({ + params: { + name: "task_create", + arguments: { + title: "Status normalization", + status: "done", + }, + }, + }); + + const createText = getText(createResult.content); + expect(createText).toContain("Task task-1 - Status normalization"); + + const createdTask = await mcpServer.getTask("task-1"); + expect(createdTask?.status).toBe("Done"); + + const editResult = await mcpServer.testInterface.callTool({ + params: { + name: "task_edit", + arguments: { + id: "task-1", + status: "inprogress", + }, + }, + }); + + const editText = getText(editResult.content); + expect(editText).toContain("Task task-1 - Status normalization"); + + const updatedTask = await mcpServer.getTask("task-1"); + expect(updatedTask?.status).toBe("In Progress"); + }); + + it("edits tasks including plan, notes, dependencies, and acceptance criteria", async () => { + // Seed primary task + const seedTask = await mcpServer.testInterface.callTool({ + params: { + name: "task_create", + arguments: { + title: "Refine MCP documentation", + status: "To Do", + }, + }, + }); + + expect(getText(seedTask.content)).toContain("Task task-1 - Refine MCP documentation"); + + // Create dependency task + const dependencyTask = await mcpServer.testInterface.callTool({ + params: { + name: "task_create", + arguments: { + title: "Placeholder dependency", + }, + }, + }); + + expect(getText(dependencyTask.content)).toContain("Task task-2 - Placeholder dependency"); + + const editResult = await mcpServer.testInterface.callTool({ + params: { + name: "task_edit", + arguments: { + id: "task-1", + status: "In Progress", + labels: ["docs"], + assignee: ["technical-writer"], + dependencies: ["task-2"], + planSet: "1. Audit existing content\n2. Remove non-MVP sections", + notesAppend: ["Ensure CLI examples mirror MCP usage"], + acceptanceCriteriaSet: ["Plan documented"], + acceptanceCriteriaAdd: ["Agents can follow instructions end-to-end"], + }, + }, + }); + + const editText = getText(editResult.content); + expect(editText).toContain("Status: β—’ In Progress"); + expect(editText).toContain("Labels: docs"); + expect(editText).toContain("Dependencies: task-2"); + expect(editText).toContain("Implementation Plan:"); + expect(editText).toContain("Implementation Notes:"); + expect(editText).toContain("#1 Plan documented"); + expect(editText).toContain("#2 Agents can follow instructions end-to-end"); + + // Uncheck criteria via task_edit + const criteriaUpdate = await mcpServer.testInterface.callTool({ + params: { + name: "task_edit", + arguments: { + id: "task-1", + acceptanceCriteriaCheck: [1], + acceptanceCriteriaUncheck: [2], + }, + }, + }); + + const criteriaText = getText(criteriaUpdate.content); + expect(criteriaText).toContain("- [x] #1 Plan documented"); + expect(criteriaText).toContain("- [ ] #2 Agents can follow instructions end-to-end"); + }); +}); diff --git a/src/test/mermaid.test.ts b/src/test/mermaid.test.ts new file mode 100644 index 0000000..ccda48b --- /dev/null +++ b/src/test/mermaid.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { JSDOM } from "jsdom"; +import { renderMermaidIn } from "../web/utils/mermaid"; + +let dom: JSDOM; + +function createContainerWithMermaid(code = "graph TD\nA --> B") { + const container = dom.window.document.createElement("div"); + const pre = dom.window.document.createElement("pre"); + const codeEl = dom.window.document.createElement("code"); + codeEl.className = "language-mermaid"; + codeEl.textContent = code; + pre.appendChild(codeEl); + container.appendChild(pre); + dom.window.document.body.appendChild(container); + return { container, codeEl }; +} + +describe("renderMermaidIn", () => { + beforeEach(async () => { + const { JSDOM } = await import("jsdom"); + dom = new JSDOM("<!doctype html><html><body></body></html>"); + // attach globals + // biome-ignore lint/suspicious/noExplicitAny: Testing environment setup + globalThis.window = dom.window as any; + // biome-ignore lint/suspicious/noExplicitAny: Testing environment setup + globalThis.document = dom.window.document as any; + // remove any mock if present + // biome-ignore lint/suspicious/noExplicitAny: Mock cleanup + delete (globalThis as any).__MERMAID_MOCK__; + }); + + afterEach(() => { + // cleanup + // biome-ignore lint/suspicious/noExplicitAny: Mock cleanup + delete (globalThis as any).__MERMAID_MOCK__; + // biome-ignore lint/suspicious/noExplicitAny: Testing environment cleanup + delete (globalThis as any).window; + // biome-ignore lint/suspicious/noExplicitAny: Testing environment cleanup + delete (globalThis as any).document; + dom.window.close(); + }); + + it("uses run API when available", async () => { + // biome-ignore lint/suspicious/noExplicitAny: Mock needed for testing + (globalThis as any).__MERMAID_MOCK__ = { + default: { + // biome-ignore lint/suspicious/noExplicitAny: Mock signature flexibility + run: async ({ nodes }: any) => { + const el = nodes?.[0] || dom.window.document.querySelector(".mermaid"); + if (el) { + el.innerHTML = "<svg><text>mock-run</text></svg>"; + } + }, + initialize: () => {}, + }, + }; + + const { container } = createContainerWithMermaid(); + await renderMermaidIn(container as HTMLElement); + + const mermaidDiv = container.querySelector(".mermaid"); + expect(mermaidDiv).toBeTruthy(); + expect(mermaidDiv?.innerHTML).toContain("mock-run"); + }); + + it("falls back to render API when run is not available", async () => { + // biome-ignore lint/suspicious/noExplicitAny: Mock needed for testing + (globalThis as any).__MERMAID_MOCK__ = { + default: { + render: async (_id: string, _txt: string) => ({ + svg: "<svg>rendered</svg>", + }), + initialize: () => {}, + }, + }; + + const { container } = createContainerWithMermaid(); + await renderMermaidIn(container as HTMLElement); + + const mermaidDiv = container.querySelector(".mermaid"); + expect(mermaidDiv).toBeTruthy(); + expect(mermaidDiv?.innerHTML).toContain("rendered"); + }); + + it("does not throw when mermaid is missing", async () => { + const { container } = createContainerWithMermaid(); + await expect(renderMermaidIn(container as HTMLElement)).resolves.toBeUndefined(); + const mermaidDiv = container.querySelector(".mermaid"); + expect(mermaidDiv).toBeTruthy(); + }); +}); diff --git a/src/test/no-remote-preflight.test.ts b/src/test/no-remote-preflight.test.ts new file mode 100644 index 0000000..bb545ca --- /dev/null +++ b/src/test/no-remote-preflight.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, join as joinPath } from "node:path"; +import { $ } from "bun"; +import { loadRemoteTasks } from "../core/task-loader.ts"; +import { GitOperations } from "../git/operations.ts"; +import type { BacklogConfig } from "../types/index.ts"; + +describe("Missing git remote preflight", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "backlog-noremote-")); + await $`git init`.cwd(tempDir).quiet(); + await $`git config user.email test@example.com`.cwd(tempDir).quiet(); + await $`git config user.name "Test User"`.cwd(tempDir).quiet(); + await writeFile(join(tempDir, "README.md"), "# Test"); + await $`git add README.md`.cwd(tempDir).quiet(); + await $`git commit -m "init"`.cwd(tempDir).quiet(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("GitOperations.fetch() silently skips when no remotes exist", async () => { + const gitOps = new GitOperations(tempDir, { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: true, + } as BacklogConfig); + + // Capture console.warn to ensure no warning is printed during fetch + const originalWarn = console.warn; + const warns: string[] = []; + console.warn = (msg: string) => { + warns.push(msg); + }; + + await expect(async () => { + await gitOps.fetch(); + }).not.toThrow(); + + // Should not warn during fetch when no remotes + expect(warns.length).toBe(0); + + console.warn = originalWarn; + }); + + it("loadRemoteTasks() handles no-remote repos without throwing", async () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: true, + }; + + const gitOps = new GitOperations(tempDir, config); + const progress: string[] = []; + const remoteTasks = await loadRemoteTasks(gitOps as unknown as typeof gitOps, config, (m) => progress.push(m)); + expect(Array.isArray(remoteTasks)).toBe(true); + expect(remoteTasks.length).toBe(0); + }); + + it("CLI init with includeRemote=true in no-remote repo shows a final warning", async () => { + const CLI_PATH = joinPath(process.cwd(), "src", "cli.ts"); + const result = + await $`bun ${[CLI_PATH, "init", "NoRemoteProj", "--defaults", "--check-branches", "true", "--include-remote", "true", "--auto-open-browser", "false"]}` + .cwd(tempDir) + .nothrow() + .quiet(); + expect(result.exitCode).toBe(0); + const out = result.stdout.toString() + result.stderr.toString(); + expect(out.toLowerCase()).toContain("remoteoperations is enabled"); + expect(out.toLowerCase()).toContain("no git remotes are configured"); + }); +}); diff --git a/src/test/offline-integration.test.ts b/src/test/offline-integration.test.ts new file mode 100644 index 0000000..fbe3fda --- /dev/null +++ b/src/test/offline-integration.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { BacklogConfig } from "../types/index.ts"; + +describe("Offline Integration Tests", () => { + let tempDir: string; + let core: Core; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "backlog-offline-integration-")); + + // Initialize a git repo without remote + await $`git init`.cwd(tempDir).quiet(); + await $`git config user.email test@example.com`.cwd(tempDir).quiet(); + await $`git config user.name "Test User"`.cwd(tempDir).quiet(); + + // Create initial commit + await writeFile(join(tempDir, "README.md"), "# Test Project"); + await $`git add README.md`.cwd(tempDir).quiet(); + await $`git commit -m "Initial commit"`.cwd(tempDir).quiet(); + + // Create basic backlog structure + const backlogDir = join(tempDir, "backlog"); + await mkdir(backlogDir, { recursive: true }); + await mkdir(join(backlogDir, "tasks"), { recursive: true }); + await mkdir(join(backlogDir, "drafts"), { recursive: true }); + + // Create config with remote operations disabled + const config: BacklogConfig = { + projectName: "Offline Test Project", + statuses: ["To Do", "In Progress", "Done"], + labels: ["bug", "feature"], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: false, + }; + + await writeFile( + join(backlogDir, "config.yml"), + `project_name: "${config.projectName}" +statuses: ["To Do", "In Progress", "Done"] +labels: ["bug", "feature"] +milestones: [] +date_format: YYYY-MM-DD +backlog_directory: "backlog" +remote_operations: false +`, + ); + + core = new Core(tempDir); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("should work in offline mode without remote", async () => { + // Ensure config migration works with remoteOperations + await core.ensureConfigMigrated(); + const config = await core.filesystem.loadConfig(); + expect(config?.remoteOperations).toBe(false); + + // Create a task - this should work without any remote operations + const task = { + id: "task-1", + title: "Test task in offline mode", + description: "This task should be created without remote operations", + status: "To Do", + assignee: [], + createdDate: new Date().toISOString().split("T")[0] ?? "", + updatedDate: new Date().toISOString().split("T")[0] ?? "", + labels: ["feature"], + dependencies: [], + priority: "medium" as const, + }; + + const filepath = await core.createTask(task); + expect(filepath).toContain("task-1"); + + // List tasks should work without remote operations + const tasks = await core.listTasksWithMetadata(); + expect(tasks).toHaveLength(1); + expect(tasks[0]?.id).toBe("task-1"); + expect(tasks[0]?.title).toBe("Test task in offline mode"); + }); + + it("should handle task ID generation in offline mode", async () => { + // Create multiple tasks to test ID generation + const task1 = { + id: "task-1", + title: "First task", + description: "First task description", + status: "To Do", + assignee: [], + createdDate: new Date().toISOString().split("T")[0] ?? "", + updatedDate: new Date().toISOString().split("T")[0] ?? "", + labels: [], + dependencies: [], + priority: "medium" as const, + }; + + const task2 = { + id: "task-2", + title: "Second task", + description: "Second task description", + status: "In Progress", + assignee: [], + createdDate: new Date().toISOString().split("T")[0] ?? "", + updatedDate: new Date().toISOString().split("T")[0] ?? "", + labels: [], + dependencies: [], + priority: "high" as const, + }; + + await core.createTask(task1); + await core.createTask(task2); + + const tasks = await core.listTasksWithMetadata(); + expect(tasks).toHaveLength(2); + + const taskIds = tasks.map((t) => t.id); + expect(taskIds).toContain("task-1"); + expect(taskIds).toContain("task-2"); + }); + + it("should handle repository without remote origin gracefully", async () => { + // Try to verify that git operations don't fail when there's no remote + // This simulates a local-only git repository + + // Get git operations instance + const gitOps = await core.getGitOps(); + + // These operations should not fail even without remote + try { + await gitOps.fetch(); + // Should complete without error due to remoteOperations: false + } catch (error) { + // If it does error, it should be handled gracefully + expect(error).toBeUndefined(); + } + + // Verify that we can still work with local git operations + const lastCommit = await gitOps.getLastCommitMessage(); + // Should be empty or the initial commit + expect(typeof lastCommit).toBe("string"); + }); + + it("should work with config command to set remoteOperations", async () => { + // Load initial config + const initialConfig = await core.filesystem.loadConfig(); + expect(initialConfig?.remoteOperations).toBe(false); + + // Simulate config set command + if (!initialConfig) throw new Error("Config not loaded"); + const updatedConfig: BacklogConfig = { ...initialConfig, remoteOperations: true }; + await core.filesystem.saveConfig(updatedConfig); + + // Verify config was updated + const newConfig = await core.filesystem.loadConfig(); + expect(newConfig?.remoteOperations).toBe(true); + + // Test changing it back + if (!newConfig) throw new Error("Config not loaded"); + const finalConfig: BacklogConfig = { ...newConfig, remoteOperations: false }; + await core.filesystem.saveConfig(finalConfig); + + const verifyConfig = await core.filesystem.loadConfig(); + expect(verifyConfig?.remoteOperations).toBe(false); + }); + + it("should migrate existing configs to include remoteOperations", async () => { + // Create a config without remoteOperations field + const backlogDir = join(tempDir, "backlog"); + await writeFile( + join(backlogDir, "config.yml"), + `project_name: "Legacy Project" +statuses: ["To Do", "Done"] +labels: [] +milestones: [] +date_format: YYYY-MM-DD +backlog_directory: "backlog" +`, + ); + + // Create new Core instance to trigger migration + const legacyCore = new Core(tempDir); + await legacyCore.ensureConfigMigrated(); + + // Verify that remoteOperations was added with default value + const migratedConfig = await legacyCore.filesystem.loadConfig(); + expect(migratedConfig?.remoteOperations).toBe(true); // Default should be true + expect(migratedConfig?.projectName).toBe("Legacy Project"); + }); + + it("should handle loadRemoteTasks in offline mode", async () => { + const config = await core.filesystem.loadConfig(); + expect(config?.remoteOperations).toBe(false); + + // Import loadRemoteTasks + const { loadRemoteTasks } = await import("../core/task-loader.ts"); + + const progressMessages: string[] = []; + const remoteTasks = await loadRemoteTasks(core.gitOps, config, (msg: string) => progressMessages.push(msg)); + + // Should return empty array and skip remote operations + expect(remoteTasks).toEqual([]); + expect(progressMessages).toContain("Remote operations disabled - skipping remote tasks"); + }); +}); diff --git a/src/test/offline-mode.test.ts b/src/test/offline-mode.test.ts new file mode 100644 index 0000000..bbbf4f5 --- /dev/null +++ b/src/test/offline-mode.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadRemoteTasks } from "../core/task-loader.ts"; +import type { FileSystem } from "../file-system/operations.ts"; +import { GitOperations } from "../git/operations.ts"; +import type { BacklogConfig } from "../types/index.ts"; + +describe("Offline Mode Configuration", () => { + let tempDir: string; + let gitOps: GitOperations; + let _mockFileSystem: FileSystem; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "backlog-offline-test-")); + gitOps = new GitOperations(tempDir); + _mockFileSystem = { + loadConfig: async () => ({ backlogDirectory: "backlog" }), + } as unknown as FileSystem; + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + describe("GitOperations.fetch()", () => { + it("should skip fetch when remoteOperations is false", async () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: false, + }; + + gitOps.setConfig(config); + + // Mock process.env.DEBUG to capture debug message + const originalDebug = process.env.DEBUG; + process.env.DEBUG = "1"; + + // Capture console.warn calls + const originalWarn = console.warn; + const warnMessages: string[] = []; + console.warn = (message: string) => { + warnMessages.push(message); + }; + + // This should not throw and should skip the actual fetch + await gitOps.fetch(); + + // Verify debug message was logged + expect(warnMessages).toContain("Remote operations are disabled in config. Skipping fetch."); + + // Restore + process.env.DEBUG = originalDebug; + console.warn = originalWarn; + }); + + it("should proceed with fetch when remoteOperations is true", async () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: true, + }; + + gitOps.setConfig(config); + + // This should attempt to run git fetch and likely fail since we're not in a git repo + // but it should not be skipped due to config + try { + await gitOps.fetch(); + } catch (error) { + // Expected to fail since we're not in a proper git repo with remote + expect(error).toBeDefined(); + } + }); + + it("should handle network errors gracefully", async () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: true, + }; + + gitOps.setConfig(config); + + // Capture console.warn calls + const originalWarn = console.warn; + const warnMessages: string[] = []; + console.warn = (message: string) => { + warnMessages.push(message); + }; + + // Mock execGit to simulate network error + type GitOperationsWithExecGit = { execGit: (args: string[]) => Promise<{ stdout: string; stderr: string }> }; + const originalExecGit = (gitOps as unknown as GitOperationsWithExecGit).execGit; + (gitOps as unknown as GitOperationsWithExecGit).execGit = async (args: string[]) => { + if (args[0] === "fetch") { + throw new Error("could not resolve host github.com"); + } + return originalExecGit.call(gitOps, args); + }; + + // Should not throw, should handle gracefully + await expect(async () => { + await gitOps.fetch(); + }).not.toThrow(); + + // Restore + console.warn = originalWarn; + (gitOps as unknown as GitOperationsWithExecGit).execGit = originalExecGit; + }); + }); + + describe("Network Error Detection", () => { + it("should detect various network error patterns", () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: true, + }; + + gitOps.setConfig(config); + + const networkErrors = [ + "could not resolve host github.com", + "Connection refused", + "Network is unreachable", + "Operation timed out", + "No route to host", + "Connection timed out", + "Temporary failure in name resolution", + ]; + + for (const errorMessage of networkErrors) { + const isNetworkError = (gitOps as unknown as { isNetworkError: (error: unknown) => boolean }).isNetworkError( + new Error(errorMessage), + ); + expect(isNetworkError).toBe(true); + } + + // Non-network errors should not be detected as network errors + const nonNetworkErrors = ["Permission denied", "Repository not found", "Authentication failed"]; + + for (const errorMessage of nonNetworkErrors) { + const isNetworkError = (gitOps as unknown as { isNetworkError: (error: unknown) => boolean }).isNetworkError( + new Error(errorMessage), + ); + expect(isNetworkError).toBe(false); + } + }); + }); + + describe("loadRemoteTasks with offline config", () => { + it("should skip remote operations when remoteOperations is false", async () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: false, + }; + + const progressMessages: string[] = []; + const onProgress = (msg: string) => progressMessages.push(msg); + + const mockGitOperations = { + fetch: async () => { + throw new Error("This should not be called"); + }, + listRemoteBranches: async () => [], + listRecentRemoteBranches: async (_daysAgo: number) => [], + } as unknown as GitOperations; + + const remoteTasks = await loadRemoteTasks(mockGitOperations, config, onProgress); + + expect(remoteTasks).toEqual([]); + expect(progressMessages).toContain("Remote operations disabled - skipping remote tasks"); + }); + + it("should proceed with remote operations when remoteOperations is true", async () => { + const config: BacklogConfig = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: true, + }; + + const progressMessages: string[] = []; + const onProgress = (msg: string) => progressMessages.push(msg); + + let fetchCalled = false; + const mockGitOperations = { + fetch: async () => { + fetchCalled = true; + }, + listRemoteBranches: async () => [], + listRecentRemoteBranches: async (_daysAgo: number) => [], + } as unknown as GitOperations; + + const remoteTasks = await loadRemoteTasks(mockGitOperations, config, onProgress); + + expect(fetchCalled).toBe(true); + expect(remoteTasks).toEqual([]); + expect(progressMessages).toContain("Fetching remote branches..."); + }); + + it("should proceed with remote operations when config is null (default behavior)", async () => { + const progressMessages: string[] = []; + const onProgress = (msg: string) => progressMessages.push(msg); + + let fetchCalled = false; + const mockGitOperations = { + fetch: async () => { + fetchCalled = true; + }, + listRemoteBranches: async () => [], + listRecentRemoteBranches: async (_daysAgo: number) => [], + } as unknown as GitOperations; + + const remoteTasks = await loadRemoteTasks(mockGitOperations, null, onProgress); + + expect(fetchCalled).toBe(true); + expect(remoteTasks).toEqual([]); + expect(progressMessages).toContain("Fetching remote branches..."); + }); + }); + + describe("Config Management", () => { + it("should handle missing remoteOperations field as default true", () => { + const configWithoutRemoteOps: Partial<BacklogConfig> = { + projectName: "Test", + statuses: ["To Do", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + // remoteOperations field is missing + }; + + gitOps.setConfig(configWithoutRemoteOps as BacklogConfig); + + // Should default to allowing remote operations when field is missing + // This tests backward compatibility + }); + + it("should handle null config gracefully", () => { + gitOps.setConfig(null); + + // Should not throw and should default to allowing remote operations + }); + }); +}); diff --git a/src/test/packaging-bin.test.ts b/src/test/packaging-bin.test.ts new file mode 100644 index 0000000..9e2ab25 --- /dev/null +++ b/src/test/packaging-bin.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +describe("package bin wrapper", () => { + it("points to scripts/cli.cjs to own .bin/backlog", async () => { + const pkgPath = join(process.cwd(), "package.json"); + const pkg = JSON.parse(await readFile(pkgPath, "utf8")); + expect(pkg?.bin?.backlog).toBe("scripts/cli.cjs"); + }); +}); diff --git a/src/test/parallel-loading.test.ts b/src/test/parallel-loading.test.ts new file mode 100644 index 0000000..edb2785 --- /dev/null +++ b/src/test/parallel-loading.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { loadRemoteTasks, resolveTaskConflict } from "../core/task-loader.ts"; +import type { GitOperations } from "../git/operations.ts"; +import type { Task } from "../types/index.ts"; + +// Mock GitOperations for testing +class MockGitOperations implements Partial<GitOperations> { + async fetch(): Promise<void> { + // Mock fetch + } + + async listRemoteBranches(): Promise<string[]> { + return ["main", "feature", "feature2"]; + } + + async listRecentRemoteBranches(_daysAgo: number): Promise<string[]> { + return ["main", "feature", "feature2"]; + } + + async getBranchLastModifiedMap(_ref: string, _dir: string): Promise<Map<string, Date>> { + const map = new Map<string, Date>(); + // Add all files with the same date for simplicity + map.set("backlog/tasks/task-1 - Main Task.md", new Date("2025-06-13")); + map.set("backlog/tasks/task-2 - Feature Task.md", new Date("2025-06-13")); + map.set("backlog/tasks/task-3 - Feature2 Task.md", new Date("2025-06-13")); + return map; + } + + async listFilesInTree(ref: string, _path: string): Promise<string[]> { + if (ref === "origin/main") { + return ["backlog/tasks/task-1 - Main Task.md"]; + } + if (ref === "origin/feature") { + return ["backlog/tasks/task-2 - Feature Task.md"]; + } + if (ref === "origin/feature2") { + return ["backlog/tasks/task-3 - Feature2 Task.md"]; + } + return []; + } + + async showFile(_ref: string, file: string): Promise<string> { + if (file.includes("task-1")) { + return `--- +id: task-1 +title: Main Task +status: To Do +assignee: [] +created_date: 2025-06-13 +labels: [] +dependencies: [] +---\n\n## Description\n\nMain task`; + } + if (file.includes("task-2")) { + return `--- +id: task-2 +title: Feature Task +status: In Progress +assignee: [] +created_date: 2025-06-13 +labels: [] +dependencies: [] +---\n\n## Description\n\nFeature task`; + } + if (file.includes("task-3")) { + return `--- +id: task-3 +title: Feature2 Task +status: Done +assignee: [] +created_date: 2025-06-13 +labels: [] +dependencies: [] +---\n\n## Description\n\nFeature2 task`; + } + return ""; + } + + async getFileLastModifiedTime(_ref: string, _file: string): Promise<Date | null> { + return new Date("2025-06-13"); + } +} + +describe("Parallel remote task loading", () => { + let consoleErrorSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + consoleErrorSpy = spyOn(console, "error"); + }); + + afterEach(() => { + consoleErrorSpy?.mockRestore(); + }); + + it("should load tasks from multiple branches in parallel", async () => { + const mockGitOperations = new MockGitOperations() as unknown as GitOperations; + + // Track progress messages + const progressMessages: string[] = []; + const remoteTasks = await loadRemoteTasks(mockGitOperations, null, (msg: string) => { + progressMessages.push(msg); + }); + + // Verify results - we should have tasks from all remote branches + expect(remoteTasks.length).toBe(3); + const taskIds = remoteTasks.map((t) => t.id); + expect(taskIds).toContain("task-1"); + expect(taskIds).toContain("task-2"); + expect(taskIds).toContain("task-3"); + + // Verify each task has correct metadata + const task1 = remoteTasks.find((t) => t.id === "task-1"); + expect(task1?.source).toBe("remote"); + expect(task1?.branch).toBe("main"); + expect(task1?.status).toBe("To Do"); + + // Verify progress reporting + expect(progressMessages.some((msg) => msg.includes("Fetching remote branches"))).toBe(true); + expect(progressMessages.some((msg) => msg.includes("Found 3 unique tasks across remote branches"))).toBe(true); + expect(progressMessages.some((msg) => msg.includes("Loaded 3 remote tasks"))).toBe(true); + }); + + it("should handle errors gracefully", async () => { + // Create a mock that throws an error + const errorGitOperations = { + fetch: async () => { + throw new Error("Network error"); + }, + listRecentRemoteBranches: async (_daysAgo: number) => { + throw new Error("Network error"); + }, + } as unknown as GitOperations; + + // Should return empty array on error + const remoteTasks = await loadRemoteTasks(errorGitOperations, null); + expect(remoteTasks).toEqual([]); + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to fetch remote tasks:", expect.any(Error)); + }); + + it("should resolve task conflicts correctly", async () => { + const statuses = ["To Do", "In Progress", "Done"]; + + const localTask: Task = { + id: "task-1", + title: "Local Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-13", + labels: [], + dependencies: [], + description: "Local version", + source: "local", + lastModified: new Date("2025-06-13T10:00:00Z"), + }; + + const remoteTask: Task = { + id: "task-1", + title: "Remote Task", + status: "Done", + assignee: [], + createdDate: "2025-06-13", + labels: [], + dependencies: [], + description: "Remote version", + source: "remote", + branch: "feature", + lastModified: new Date("2025-06-13T12:00:00Z"), + }; + + // Test most_progressed strategy - should pick Done over To Do + const resolved1 = resolveTaskConflict(localTask, remoteTask, statuses, "most_progressed"); + expect(resolved1.status).toBe("Done"); + expect(resolved1.title).toBe("Remote Task"); + + // Test most_recent strategy - should pick the more recent one + const resolved2 = resolveTaskConflict(localTask, remoteTask, statuses, "most_recent"); + expect(resolved2.lastModified).toEqual(new Date("2025-06-13T12:00:00Z")); + expect(resolved2.title).toBe("Remote Task"); + }); +}); diff --git a/src/test/parent-id-normalization.test.ts b/src/test/parent-id-normalization.test.ts new file mode 100644 index 0000000..af39ede --- /dev/null +++ b/src/test/parent-id-normalization.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import type { Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +async function initGitRepo(dir: string) { + await $`git init -b main`.cwd(dir).quiet(); + await $`git config user.name "Test User"`.cwd(dir).quiet(); + await $`git config user.email test@example.com`.cwd(dir).quiet(); +} + +describe("CLI parent task id normalization", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-parent-normalization"); + await mkdir(TEST_DIR, { recursive: true }); + await initGitRepo(TEST_DIR); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + it("should normalize parent task id when creating subtasks", async () => { + const core = new Core(TEST_DIR); + await core.initializeProject("Normalization Test", true); + + const parent: Task = { + id: "task-4", + title: "Parent", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + }; + await core.createTask(parent, true); + + await $`bun run ${CLI_PATH} task create Child --parent 4`.cwd(TEST_DIR).quiet(); + + const child = await core.filesystem.loadTask("task-4.1"); + expect(child?.parentTaskId).toBe("task-4"); + }); +}); diff --git a/src/test/priority.test.ts b/src/test/priority.test.ts new file mode 100644 index 0000000..1a80357 --- /dev/null +++ b/src/test/priority.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "bun:test"; +import { parseTask } from "../markdown/parser.ts"; +import { serializeTask } from "../markdown/serializer.ts"; +import type { Task } from "../types/index.ts"; + +describe("Priority functionality", () => { + describe("parseTask", () => { + it("should parse task with priority field", () => { + const content = `--- +id: task-1 +title: "High priority task" +status: "To Do" +priority: high +assignee: [] +created_date: "2025-06-20" +labels: [] +dependencies: [] +--- + +## Description + +This is a high priority task.`; + + const task = parseTask(content); + + expect(task.id).toBe("task-1"); + expect(task.title).toBe("High priority task"); + expect(task.priority).toBe("high"); + }); + + it("should handle all priority levels", () => { + const priorities = ["high", "medium", "low"] as const; + + for (const priority of priorities) { + const content = `--- +id: task-${priority} +title: "${priority} priority task" +status: "To Do" +priority: ${priority} +assignee: [] +created_date: "2025-06-20" +labels: [] +dependencies: [] +--- + +## Description + +This is a ${priority} priority task.`; + + const task = parseTask(content); + expect(task.priority).toBe(priority); + } + }); + + it("should handle invalid priority values gracefully", () => { + const content = `--- +id: task-1 +title: "Invalid priority task" +status: "To Do" +priority: invalid +assignee: [] +created_date: "2025-06-20" +labels: [] +dependencies: [] +--- + +## Description + +This task has an invalid priority.`; + + const task = parseTask(content); + + expect(task.priority).toBeUndefined(); + }); + + it("should handle task without priority field", () => { + const content = `--- +id: task-1 +title: "No priority task" +status: "To Do" +assignee: [] +created_date: "2025-06-20" +labels: [] +dependencies: [] +--- + +## Description + +This task has no priority.`; + + const task = parseTask(content); + + expect(task.priority).toBeUndefined(); + }); + + it("should handle case-insensitive priority values", () => { + const content = `--- +id: task-1 +title: "Mixed case priority" +status: "To Do" +priority: HIGH +assignee: [] +created_date: "2025-06-20" +labels: [] +dependencies: [] +--- + +## Description + +This task has mixed case priority.`; + + const task = parseTask(content); + + expect(task.priority).toBe("high"); + }); + }); + + describe("serializeTask", () => { + it("should serialize task with priority", () => { + const task: Task = { + id: "task-1", + title: "High priority task", + status: "To Do", + assignee: [], + createdDate: "2025-06-20", + labels: [], + dependencies: [], + rawContent: "## Description\n\nThis is a high priority task.", + priority: "high", + }; + + const serialized = serializeTask(task); + + expect(serialized).toContain("priority: high"); + }); + + it("should not include priority field when undefined", () => { + const task: Task = { + id: "task-1", + title: "No priority task", + status: "To Do", + assignee: [], + createdDate: "2025-06-20", + labels: [], + dependencies: [], + rawContent: "## Description\n\nThis task has no priority.", + }; + + const serialized = serializeTask(task); + + expect(serialized).not.toContain("priority:"); + }); + + it("should round-trip priority values correctly", () => { + const priorities: Array<"high" | "medium" | "low"> = ["high", "medium", "low"]; + + for (const priority of priorities) { + const originalTask: Task = { + id: "task-1", + title: `${priority} priority task`, + status: "To Do", + assignee: [], + createdDate: "2025-06-20", + labels: [], + dependencies: [], + rawContent: `## Description\n\nThis is a ${priority} priority task.`, + priority, + }; + + const serialized = serializeTask(originalTask); + const parsed = parseTask(serialized); + + expect(parsed.priority).toBe(priority); + } + }); + }); +}); diff --git a/src/test/remote-id-conflict.test.ts b/src/test/remote-id-conflict.test.ts new file mode 100644 index 0000000..b866182 --- /dev/null +++ b/src/test/remote-id-conflict.test.ts @@ -0,0 +1,69 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +let REMOTE_DIR: string; +let LOCAL_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +async function initRepo(dir: string) { + await $`git init -b main`.cwd(dir).quiet(); + await $`git config user.name Test`.cwd(dir).quiet(); + await $`git config user.email test@example.com`.cwd(dir).quiet(); +} + +describe("next id across remote branches", () => { + beforeAll(async () => { + TEST_DIR = createUniqueTestDir("test-remote-id"); + REMOTE_DIR = join(TEST_DIR, "remote.git"); + LOCAL_DIR = join(TEST_DIR, "local"); + await mkdir(REMOTE_DIR, { recursive: true }); + await $`git init --bare -b main`.cwd(REMOTE_DIR).quiet(); + await mkdir(LOCAL_DIR, { recursive: true }); + await initRepo(LOCAL_DIR); + await $`git remote add origin ${REMOTE_DIR}`.cwd(LOCAL_DIR).quiet(); + + const core = new Core(LOCAL_DIR); + await core.initializeProject("Remote Test", true); + await core.ensureConfigMigrated(); + await $`git branch -M main`.cwd(LOCAL_DIR).quiet(); + await $`git push -u origin main`.cwd(LOCAL_DIR).quiet(); + + await $`git checkout -b feature`.cwd(LOCAL_DIR).quiet(); + await core.createTask( + { + id: "task-1", + title: "Remote Task", + status: "To Do", + assignee: [], + createdDate: "2025-06-08", + labels: [], + dependencies: [], + rawContent: "", + }, + true, + ); + await $`git push -u origin feature`.cwd(LOCAL_DIR).quiet(); + await $`git checkout main`.cwd(LOCAL_DIR).quiet(); + }); + + afterAll(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors + } + }); + + it("uses id after highest remote task", async () => { + const result = await $`bun run ${CLI_PATH} task create "Local Task"`.cwd(LOCAL_DIR).quiet(); + expect(result.stdout.toString()).toContain("Created task task-2"); + const core = new Core(LOCAL_DIR); + const task = await core.filesystem.loadTask("task-2"); + expect(task).not.toBeNull(); + }); +}); diff --git a/src/test/reorder-utils.test.ts b/src/test/reorder-utils.test.ts new file mode 100644 index 0000000..16a30ec --- /dev/null +++ b/src/test/reorder-utils.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { calculateNewOrdinal, DEFAULT_ORDINAL_STEP, resolveOrdinalConflicts } from "../core/reorder.ts"; +import type { Task } from "../types/index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +const item = (id: string, ordinal?: number) => ({ id, ordinal }); + +let TEST_DIR: string; +let core: Core; + +const FIXED_DATE = "2025-01-01 00:00"; + +const buildTask = (id: string, status: string, ordinal?: number): Task => ({ + id, + title: `Task ${id}`, + status, + assignee: [], + createdDate: FIXED_DATE, + labels: [], + dependencies: [], + ...(ordinal !== undefined ? { ordinal } : {}), +}); + +beforeEach(async () => { + TEST_DIR = createUniqueTestDir("reorder-utils"); + await mkdir(TEST_DIR, { recursive: true }); + await $`git init -b main`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + core = new Core(TEST_DIR); + await core.initializeProject("Reorder Utilities Test Project"); +}); + +afterEach(async () => { + await safeCleanup(TEST_DIR); +}); + +describe("calculateNewOrdinal", () => { + it("returns default step when no neighbors exist", () => { + const result = calculateNewOrdinal({}); + expect(result.ordinal).toBe(DEFAULT_ORDINAL_STEP); + expect(result.requiresRebalance).toBe(false); + }); + + it("averages ordinals when both neighbors exist", () => { + const result = calculateNewOrdinal({ + previous: item("a", 1000), + next: item("b", 3000), + }); + expect(result.ordinal).toBe(2000); + expect(result.requiresRebalance).toBe(false); + }); + + it("flags rebalance when there is no gap between neighbors", () => { + const result = calculateNewOrdinal({ + previous: item("a", 2000), + next: item("b", 2000), + }); + expect(result.requiresRebalance).toBe(true); + }); + + it("appends step when dropping after the last task", () => { + const result = calculateNewOrdinal({ + previous: item("a", 4000), + }); + expect(result.ordinal).toBe(4000 + DEFAULT_ORDINAL_STEP); + expect(result.requiresRebalance).toBe(false); + }); +}); + +describe("resolveOrdinalConflicts", () => { + it("returns empty array when ordinals are already increasing", () => { + const updates = resolveOrdinalConflicts([item("a", 1000), item("b", 2000), item("c", 3000)]); + expect(updates).toHaveLength(0); + }); + + it("reassigns duplicate or descending ordinals", () => { + const updates = resolveOrdinalConflicts([item("a", 1000), item("b", 1000), item("c", 2000)]); + expect(updates).toHaveLength(2); + expect(updates[0]).toEqual({ id: "b", ordinal: 2000 }); + expect(updates[1]).toEqual({ id: "c", ordinal: 3000 }); + }); + + it("fills in missing ordinals with default spacing", () => { + const updates = resolveOrdinalConflicts([item("a"), item("b"), item("c", 1500)]); + expect(updates).toHaveLength(3); + expect(updates[0]).toEqual({ id: "a", ordinal: DEFAULT_ORDINAL_STEP }); + expect(updates[1]).toEqual({ id: "b", ordinal: DEFAULT_ORDINAL_STEP * 2 }); + expect(updates[2]).toEqual({ id: "c", ordinal: DEFAULT_ORDINAL_STEP * 3 }); + }); + + it("can force sequential reassignment when requested", () => { + const updates = resolveOrdinalConflicts([item("a", 1000), item("b", 2500), item("c", 4500)], { + forceSequential: true, + }); + expect(updates).toHaveLength(2); + expect(updates[0]).toEqual({ id: "b", ordinal: 2000 }); + expect(updates[1]).toEqual({ id: "c", ordinal: 3000 }); + }); +}); + +describe("Core.reorderTask", () => { + const createTasks = async (tasks: Array<[string, string, number?]>) => { + for (const [id, status, ordinal] of tasks) { + await core.createTask(buildTask(id, status, ordinal), false); + } + }; + + it("reorders within a column without touching unaffected tasks", async () => { + await createTasks([ + ["task-1", "To Do", 1000], + ["task-2", "To Do", 2000], + ["task-3", "To Do", 3000], + ]); + + const result = await core.reorderTask({ + taskId: "task-3", + targetStatus: "To Do", + orderedTaskIds: ["task-1", "task-3", "task-2"], + }); + + expect(result.updatedTask.id).toBe("task-3"); + expect(result.updatedTask.ordinal).toBeGreaterThan(1000); + expect(result.updatedTask.ordinal).toBeLessThan(2000); + expect(result.changedTasks.map((task) => task.id)).toEqual(["task-3"]); + + const task2 = await core.filesystem.loadTask("task-2"); + expect(task2?.ordinal).toBe(2000); + }); + + it("rebalances ordinals when collisions exist", async () => { + await createTasks([ + ["task-1", "To Do", 1000], + ["task-2", "To Do", 1000], + ["task-3", "To Do", 1000], + ]); + + const result = await core.reorderTask({ + taskId: "task-3", + targetStatus: "To Do", + orderedTaskIds: ["task-1", "task-3", "task-2"], + }); + + expect(result.changedTasks.map((task) => task.id).sort()).toEqual(["task-2", "task-3"]); + + const task1 = await core.filesystem.loadTask("task-1"); + const task2 = await core.filesystem.loadTask("task-2"); + const task3 = await core.filesystem.loadTask("task-3"); + expect(task1?.ordinal).toBe(1000); + expect(task2?.ordinal).toBe(3000); + expect(task3?.ordinal).toBe(2000); + }); + + it("updates status and ordinal when moving across columns", async () => { + await createTasks([ + ["task-1", "To Do", 1000], + ["task-2", "In Progress", 1000], + ["task-3", "In Progress", 2000], + ]); + + const result = await core.reorderTask({ + taskId: "task-1", + targetStatus: "In Progress", + orderedTaskIds: ["task-1", "task-2", "task-3"], + }); + + expect(result.updatedTask.status).toBe("In Progress"); + expect(result.updatedTask.ordinal).toBeGreaterThan(0); + expect(result.changedTasks.map((task) => task.id)).toContain("task-1"); + + const task2 = await core.filesystem.loadTask("task-2"); + const task3 = await core.filesystem.loadTask("task-3"); + expect(task2?.ordinal).toBe(1000); + expect(task3?.ordinal).toBe(2000); + }); +}); diff --git a/src/test/resolveBinary.test.ts b/src/test/resolveBinary.test.ts new file mode 100644 index 0000000..3cb0079 --- /dev/null +++ b/src/test/resolveBinary.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { getPackageName } = require("../../scripts/resolveBinary.cjs"); + +describe("getPackageName", () => { + it("maps win32 platform to windows package", () => { + expect(getPackageName("win32", "x64")).toBe("backlog.md-windows-x64"); + }); + + it("returns linux name unchanged", () => { + expect(getPackageName("linux", "arm64")).toBe("backlog.md-linux-arm64"); + }); +}); diff --git a/src/test/search-service.test.ts b/src/test/search-service.test.ts new file mode 100644 index 0000000..bd7eb56 --- /dev/null +++ b/src/test/search-service.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { ContentStore } from "../core/content-store.ts"; +import { SearchService } from "../core/search-service.ts"; +import { FileSystem } from "../file-system/operations.ts"; +import type { + Decision, + DecisionSearchResult, + Document, + DocumentSearchResult, + SearchResult, + Task, + TaskSearchResult, +} from "../types/index.ts"; +import { createUniqueTestDir, getPlatformTimeout, safeCleanup, sleep } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("SearchService", () => { + let filesystem: FileSystem; + let store: ContentStore; + let search: SearchService; + + const baseTask: Task = { + id: "task-1", + title: "Centralized search task", + status: "In Progress", + assignee: ["@codex"], + reporter: "@codex", + createdDate: "2025-09-19 09:00", + updatedDate: "2025-09-19 09:10", + labels: ["search"], + dependencies: [], + rawContent: "## Description\nImplements Fuse based service", + priority: "high", + }; + + const baseDoc: Document = { + id: "doc-1", + title: "Search Architecture", + type: "guide", + createdDate: "2025-09-19", + rawContent: "# Search Architecture\nCentralized description", + }; + + const baseDecision: Decision = { + id: "decision-1", + title: "Adopt Fuse.js", + date: "2025-09-18", + status: "accepted", + context: "Need consistent search", + decision: "Use Fuse.js with centralized store", + consequences: "Shared search path", + rawContent: "## Context\nNeed consistent search\n\n## Decision\nUse Fuse.js with centralized store", + }; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("search-service"); + filesystem = new FileSystem(TEST_DIR); + await filesystem.ensureBacklogStructure(); + store = new ContentStore(filesystem); + search = new SearchService(store); + }); + + afterEach(async () => { + search?.dispose(); + store?.dispose(); + try { + await safeCleanup(TEST_DIR); + } catch { + // ignore cleanup errors between tests + } + }); + + it("indexes tasks, documents, and decisions and returns combined results", async () => { + await filesystem.saveTask(baseTask); + await filesystem.saveDocument(baseDoc); + await filesystem.saveDecision(baseDecision); + + await search.ensureInitialized(); + + const results = search.search({ query: "centralized" }); + expect(results).toHaveLength(3); + + const taskResult = results.find(isTaskResult); + expect(taskResult).toBeDefined(); + expect(taskResult?.task.id).toBe("task-1"); + expect(taskResult?.score).not.toBeNull(); + + const docResult = results.find(isDocumentResult); + expect(docResult?.document.id).toBe("doc-1"); + const decisionResult = results.find(isDecisionResult); + expect(decisionResult?.decision.id).toBe("decision-1"); + }); + + it("applies status and priority filters without running a text query", async () => { + const secondTask: Task = { + ...baseTask, + id: "task-2", + title: "Another task", + status: "To Do", + priority: "low", + rawContent: "## Description\nSecondary", + }; + + const thirdTask: Task = { + ...baseTask, + id: "task-3", + title: "In progress medium", + priority: "medium", + rawContent: "## Description\nMedium priority", + }; + + await filesystem.saveTask(baseTask); + await filesystem.saveTask(secondTask); + await filesystem.saveTask(thirdTask); + + await search.ensureInitialized(); + + const statusFiltered = search + .search({ + types: ["task"], + filters: { status: "In Progress" }, + }) + .filter(isTaskResult); + expect(statusFiltered.map((result) => result.task.id)).toStrictEqual(["task-1", "task-3"]); + + const priorityFiltered = search + .search({ + types: ["task"], + filters: { priority: "high" }, + }) + .filter(isTaskResult); + expect(priorityFiltered).toHaveLength(1); + expect(priorityFiltered[0]?.task.id).toBe("task-1"); + + const combinedFiltered = search + .search({ + types: ["task"], + filters: { status: ["In Progress"], priority: ["medium"] }, + }) + .filter(isTaskResult); + expect(combinedFiltered.map((result) => result.task.id)).toStrictEqual(["task-3"]); + }); + + it("refreshes the index when content changes", async () => { + await filesystem.saveTask(baseTask); + await search.ensureInitialized(); + + const initialResults = search.search({ query: "Fuse", types: ["task"] }).filter(isTaskResult); + expect(initialResults).toHaveLength(1); + + await filesystem.saveTask({ + ...baseTask, + rawContent: "## Description\nReindexed to new term", + title: "Centralized service updated", + }); + + await waitForSearch( + async () => search.search({ query: "Reindexed", types: ["task"] }).filter(isTaskResult), + (results) => { + return results.length === 1 && results[0]?.task.title === "Centralized service updated"; + }, + ); + + const staleResults = search.search({ query: "Fuse", types: ["task"] }).filter(isTaskResult); + expect(staleResults).toHaveLength(0); + }); +}); + +function isTaskResult(result: SearchResult): result is TaskSearchResult { + return result.type === "task"; +} + +function isDocumentResult(result: SearchResult): result is DocumentSearchResult { + return result.type === "document"; +} + +function isDecisionResult(result: SearchResult): result is DecisionSearchResult { + return result.type === "decision"; +} + +async function waitForSearch<T>( + operation: () => Promise<T> | T, + predicate: (value: T) => boolean, + timeout = getPlatformTimeout(), + interval = 50, +): Promise<T> { + const deadline = Date.now() + timeout; + let lastValue: T; + while (Date.now() < deadline) { + lastValue = await operation(); + if (predicate(lastValue)) { + return lastValue; + } + await sleep(interval); + } + + lastValue = await operation(); + if (predicate(lastValue)) { + return lastValue; + } + + throw new Error("Timed out waiting for search results to satisfy predicate"); +} diff --git a/src/test/sequences-insert-between.test.ts b/src/test/sequences-insert-between.test.ts new file mode 100644 index 0000000..262c454 --- /dev/null +++ b/src/test/sequences-insert-between.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import { adjustDependenciesForInsertBetween, computeSequences } from "../core/sequences.ts"; +import type { Task } from "../types/index.ts"; + +function t(id: string, deps: string[] = []): Task { + return { + id, + title: id, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: deps, + description: "Test", + }; +} + +describe("adjustDependenciesForInsertBetween", () => { + it("creates new sequence between K and K+1 with dependency updates", () => { + // seq1: 1,2 ; seq2: 3(dep:1,2) ; seq3: 4(dep:3), 5(dep:3) + const tasks = [ + t("task-1"), + t("task-2"), + t("task-3", ["task-1", "task-2"]), + t("task-4", ["task-3"]), + t("task-5", ["task-3"]), + ]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(3); + // Drop task-5 between seq1 (K=1) and seq2 (K+1) + const updated = adjustDependenciesForInsertBetween(tasks, res.sequences, "task-5", 1); + const next = computeSequences(updated); + // Expect: seq1: 1,2 ; seq2: 5 ; seq3: 3 ; seq4: 4 + expect(next.sequences.length).toBe(4); + expect(next.sequences[0]?.tasks.map((x) => x.id)).toEqual(["task-1", "task-2"]); + expect(next.sequences[1]?.tasks.map((x) => x.id)).toEqual(["task-5"]); + expect(next.sequences[2]?.tasks.map((x) => x.id)).toEqual(["task-3"]); + expect(next.sequences[3]?.tasks.map((x) => x.id)).toEqual(["task-4"]); + }); + + it("supports top insertion (K=0): moved becomes Sequence 1; next sequence tasks depend on moved", () => { + // seq1: 1 ; seq2: 2(dep:1) + const tasks = [t("task-1"), t("task-2", ["task-1"]), t("task-3")]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(2); + const updated = adjustDependenciesForInsertBetween(tasks, res.sequences, "task-3", 0); + const next = computeSequences(updated); + // Expect: seq1: 3 ; seq2: 1 ; seq3: 2 + expect(next.sequences.length).toBe(3); + expect(next.sequences[0]?.tasks.map((x) => x.id)).toEqual(["task-3"]); + expect(next.sequences[1]?.tasks.map((x) => x.id)).toEqual(["task-1"]); + expect(next.sequences[2]?.tasks.map((x) => x.id)).toEqual(["task-2"]); + }); + + it("when there are no sequences, top insertion anchors moved via ordinal", () => { + // All tasks unsequenced initially (no deps, no dependents) + const tasks = [t("task-1"), t("task-2")]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(0); + const updated = adjustDependenciesForInsertBetween(tasks, res.sequences, "task-2", 0); + const byId = new Map(updated.map((x) => [x.id, x])); + // moved has ordinal set + expect(byId.get("task-2")?.ordinal).toBe(0); + const next = computeSequences(updated); + expect(next.sequences.length).toBe(1); + expect(next.sequences[0]?.tasks.map((x) => x.id)).toEqual(["task-2"]); + }); +}); diff --git a/src/test/sequences-move.test.ts b/src/test/sequences-move.test.ts new file mode 100644 index 0000000..d6fd69e --- /dev/null +++ b/src/test/sequences-move.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { adjustDependenciesForMove, computeSequences } from "../core/sequences.ts"; +import type { Task } from "../types/index.ts"; + +function t(id: string, deps: string[] = []): Task { + return { + id, + title: id, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: deps, + description: "Test", + }; +} + +describe("adjustDependenciesForMove (join semantics)", () => { + it("sets moved task deps to previous sequence tasks and does not modify next sequence", () => { + // seq1: 1,2 ; seq2: 3(dep:1,2) ; seq3: 4(dep:3) + const tasks = [t("task-1"), t("task-2"), t("task-3", ["task-1", "task-2"]), t("task-4", ["task-3"])]; + const res = computeSequences(tasks); + const seqs = res.sequences; + + // Move task-3 to sequence 1 (target index = 1) + const updated = adjustDependenciesForMove(tasks, seqs, "task-3", 1); + const byId = new Map(updated.map((x) => [x.id, x])); + + // Moved deps should be from previous sequence (none) + expect(byId.get("task-3")?.dependencies).toEqual([]); + + // Next sequence unchanged (no forced dependency to moved) + expect(byId.get("task-4")?.dependencies).toEqual(["task-3"]); + }); + + it("keeps deps and does not add duplicates to next sequence", () => { + // seq1: 1 ; seq2: 2(dep:1), 3(dep:1) ; seq3: 4(dep:2,3) + const tasks = [t("task-1"), t("task-2", ["task-1"]), t("task-3", ["task-1"]), t("task-4", ["task-2", "task-3"])]; + const res = computeSequences(tasks); + const seqs = res.sequences; + + // Move task-2 to seq2 (target=2) -> prev seq = seq1 -> deps should be [task-1] + const updated = adjustDependenciesForMove(tasks, seqs, "task-2", 2); + const byId = new Map(updated.map((x) => [x.id, x])); + expect(byId.get("task-2")?.dependencies).toEqual(["task-1"]); + // task-4 unchanged + expect(byId.get("task-4")?.dependencies).toEqual(["task-2", "task-3"]); + }); +}); diff --git a/src/test/sequences-reorder.test.ts b/src/test/sequences-reorder.test.ts new file mode 100644 index 0000000..b8112d3 --- /dev/null +++ b/src/test/sequences-reorder.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { reorderWithinSequence } from "../core/sequences.ts"; +import type { Task } from "../types/index.ts"; + +function t(id: string, ordinal?: number): Task { + return { + id, + title: id, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: [], + rawContent: "Test", + ...(ordinal !== undefined ? { ordinal } : {}), + }; +} + +describe("reorderWithinSequence", () => { + it("reassigns ordinals within a sequence and leaves others untouched", () => { + const tasks: Task[] = [ + t("task-1", 0), + t("task-2", 1), + t("task-3", 2), + t("task-9"), // outside this sequence + ]; + const updated = reorderWithinSequence(tasks, ["task-1", "task-2", "task-3"], "task-3", 0); + const byId = new Map(updated.map((x) => [x.id, x])); + expect(byId.get("task-3")?.ordinal).toBe(0); + expect(byId.get("task-1")?.ordinal).toBe(1); + expect(byId.get("task-2")?.ordinal).toBe(2); + expect(byId.get("task-9")?.ordinal).toBeUndefined(); + }); + + it("clamps index and preserves dependencies", () => { + const tasks: Task[] = [{ ...t("task-1", 0), dependencies: ["task-x"] }, t("task-2", 1)]; + const updated = reorderWithinSequence(tasks, ["task-1", "task-2"], "task-1", 10); + const byId = new Map(updated.map((x) => [x.id, x])); + expect(byId.get("task-1")?.ordinal).toBe(1); + expect(byId.get("task-1")?.dependencies).toEqual(["task-x"]); + }); +}); diff --git a/src/test/sequences-unsequenced-eligibility.test.ts b/src/test/sequences-unsequenced-eligibility.test.ts new file mode 100644 index 0000000..b66fc3a --- /dev/null +++ b/src/test/sequences-unsequenced-eligibility.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { canMoveToUnsequenced } from "../core/sequences.ts"; +import type { Task } from "../types/index.ts"; + +function t(id: string, deps: string[] = [], extra: Partial<Task> = {}): Task { + return { + id, + title: id, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: deps, + rawContent: "Test", + ...extra, + }; +} + +describe("canMoveToUnsequenced", () => { + it("returns true for isolated tasks (no deps, no dependents)", () => { + const tasks = [t("task-1"), t("task-2")]; + expect(canMoveToUnsequenced(tasks, "task-2")).toBe(true); + }); + + it("returns false when task has dependencies", () => { + const tasks = [t("task-1"), t("task-2", ["task-1"])]; + expect(canMoveToUnsequenced(tasks, "task-2")).toBe(false); + }); + + it("returns false when task has dependents", () => { + const tasks = [t("task-1"), t("task-2", ["task-1"])]; + expect(canMoveToUnsequenced(tasks, "task-1")).toBe(false); + }); +}); diff --git a/src/test/sequences.test.ts b/src/test/sequences.test.ts new file mode 100644 index 0000000..9d32313 --- /dev/null +++ b/src/test/sequences.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test"; +import { computeSequences } from "../core/sequences.ts"; +import type { Task } from "../types/index.ts"; + +function task(id: string, deps: string[] = []): Task { + return { + id, + title: id, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: deps, + description: "Test", + }; +} + +describe("computeSequences (with Unsequenced)", () => { + function mustGet<T>(arr: T[], idx: number): T { + const v = arr[idx]; + if (v === undefined) throw new Error(`expected element at index ${idx}`); + return v; + } + it("puts isolated tasks into Unsequenced bucket", () => { + const tasks = [task("task-1"), task("task-2"), task("task-3")]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(0); + expect(res.unsequenced.map((t) => t.id)).toEqual(["task-1", "task-2", "task-3"]); + }); + + it("handles a simple chain A -> B -> C", () => { + const tasks = [task("task-1"), task("task-2", ["task-1"]), task("task-3", ["task-2"])]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(3); + expect(mustGet(res.sequences, 0).tasks.map((t) => t.id)).toEqual(["task-1"]); + expect(mustGet(res.sequences, 1).tasks.map((t) => t.id)).toEqual(["task-2"]); + expect(mustGet(res.sequences, 2).tasks.map((t) => t.id)).toEqual(["task-3"]); + }); + + it("groups parallel branches (A -> C, B -> C) into same sequence", () => { + const tasks = [task("task-1"), task("task-2"), task("task-3", ["task-1", "task-2"])]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(2); + // First layer contains 1 and 2 in id order + expect(mustGet(res.sequences, 0).tasks.map((t) => t.id)).toEqual(["task-1", "task-2"]); + // Second layer contains 3 + expect(mustGet(res.sequences, 1).tasks.map((t) => t.id)).toEqual(["task-3"]); + }); + + it("handles a more complex graph", () => { + // 1,2 -> 4 ; 3 -> 5 -> 6 + const tasks = [ + task("task-1"), + task("task-2"), + task("task-3"), + task("task-4", ["task-1", "task-2"]), + task("task-5", ["task-3"]), + task("task-6", ["task-5"]), + ]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(3); + expect(mustGet(res.sequences, 0).tasks.map((t) => t.id)).toEqual(["task-1", "task-2", "task-3"]); + // Second layer should include 4 and 5 (order by id) + expect(mustGet(res.sequences, 1).tasks.map((t) => t.id)).toEqual(["task-4", "task-5"]); + // Final layer 6 + expect(mustGet(res.sequences, 2).tasks.map((t) => t.id)).toEqual(["task-6"]); + }); + + it("ignores external dependencies not present in the task set", () => { + const tasks = [task("task-1", ["task-999"])]; + const res = computeSequences(tasks); + expect(res.sequences.length).toBe(1); + expect(mustGet(res.sequences, 0).tasks.map((t) => t.id)).toEqual(["task-1"]); + }); +}); diff --git a/src/test/server-assets.test.ts b/src/test/server-assets.test.ts new file mode 100644 index 0000000..3604b4b --- /dev/null +++ b/src/test/server-assets.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { FileSystem } from "../file-system/operations.ts"; +import { BacklogServer } from "../server/index.ts"; +import { createUniqueTestDir, retry, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +let filesystem: FileSystem; +let server: BacklogServer | null = null; +let serverPort = 0; + +describe("BacklogServer asset serving", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("server-assets"); + filesystem = new FileSystem(TEST_DIR); + await filesystem.ensureBacklogStructure(); + + // ensure config so server starts cleanly + await filesystem.saveConfig({ + projectName: "Server Assets", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: false, + }); + + // create backlog/assets and nested dirs + const backlogRoot = dirname(filesystem.docsDir); + const assetsDir = join(backlogRoot, "assets"); + await mkdir(join(assetsDir, "images"), { recursive: true }); + await mkdir(join(assetsDir, "docs"), { recursive: true }); + + // write a small test asset and a text file + await Bun.write(join(assetsDir, "images", "test.png"), "PNGTEST"); + await Bun.write(join(assetsDir, "docs", "readme.txt"), "Hello assets\n"); + + server = new BacklogServer(TEST_DIR); + await server.start(0, false); + const port = server.getPort(); + expect(port).not.toBeNull(); + serverPort = port ?? 0; + + // wait for server to be reachable + await retry( + async () => { + const res = await fetch(`http://127.0.0.1:${serverPort}/`); + if (!res.ok) throw new Error("server not ready"); + return true; + }, + 10, + 50, + ); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + server = null; + } + await safeCleanup(TEST_DIR); + }); + + it("serves existing image assets with appropriate Content-Type and body", async () => { + const res = await fetch(`http://127.0.0.1:${serverPort}/assets/images/test.png`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + const body = await res.text(); + expect(body).toBe("PNGTEST"); + }); + + it("serves text files with text/plain Content-Type", async () => { + const res = await fetch(`http://127.0.0.1:${serverPort}/assets/docs/readme.txt`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/plain"); + const body = await res.text(); + expect(body).toBe("Hello assets\n"); + }); + + it("returns 404 for missing files", async () => { + const res = await fetch(`http://127.0.0.1:${serverPort}/assets/images/missing.png`); + expect(res.status).toBe(404); + }); + + it("rejects path traversal attempts with 404", async () => { + // attempt to escape assets via .. + const res = await fetch(`http://127.0.0.1:${serverPort}/assets/../config.yml`); + expect(res.status).toBe(404); + + // encoded traversal + const res2 = await fetch(`http://127.0.0.1:${serverPort}/assets/%2e%2e/config.yml`); + expect(res2.status).toBe(404); + }); +}); diff --git a/src/test/server-search-endpoint.test.ts b/src/test/server-search-endpoint.test.ts new file mode 100644 index 0000000..8bed253 --- /dev/null +++ b/src/test/server-search-endpoint.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { FileSystem } from "../file-system/operations.ts"; +import { BacklogServer } from "../server/index.ts"; +import type { Decision, Document, Task } from "../types/index.ts"; +import { createUniqueTestDir, retry, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +let server: BacklogServer | null = null; +let filesystem: FileSystem; +let serverPort = 0; + +const baseTask: Task = { + id: "task-0007", + title: "Server search task", + status: "In Progress", + assignee: ["@codex"], + reporter: "@codex", + createdDate: "2025-09-20 10:00", + updatedDate: "2025-09-20 10:00", + labels: ["search"], + dependencies: [], + description: "Alpha token appears here", + priority: "high", +}; + +const baseDoc: Document = { + id: "doc-9001", + title: "Search Handbook", + type: "guide", + createdDate: "2025-09-20", + updatedDate: "2025-09-20", + rawContent: "# Guide\nAlpha document guidance", +}; + +const baseDecision: Decision = { + id: "decision-9001", + title: "Centralize search", + date: "2025-09-19", + status: "accepted", + context: "Need consistent Alpha search coverage", + decision: "Adopt shared Fuse service", + consequences: "Shared index", + rawContent: "## Context\nAlpha adoption", +}; + +const dependentTask: Task = { + id: "task-0008", + title: "Follow-up integration", + status: "In Progress", + assignee: ["@codex"], + reporter: "@codex", + createdDate: "2025-09-20 10:30", + updatedDate: "2025-09-20 10:30", + labels: ["search"], + dependencies: [baseTask.id], + description: "Depends on task-0007 for completion", + priority: "medium", +}; + +describe("BacklogServer search endpoint", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("server-search"); + filesystem = new FileSystem(TEST_DIR); + await filesystem.ensureBacklogStructure(); + await filesystem.saveConfig({ + projectName: "Server Search", + statuses: ["To Do", "In Progress", "Done"], + labels: [], + milestones: [], + dateFormat: "YYYY-MM-DD", + remoteOperations: false, + }); + + await filesystem.saveTask(baseTask); + await filesystem.saveTask(dependentTask); + await filesystem.saveDocument(baseDoc); + await filesystem.saveDecision(baseDecision); + + server = new BacklogServer(TEST_DIR); + await server.start(0, false); + const port = server.getPort(); + expect(port).not.toBeNull(); + serverPort = port ?? 0; + expect(serverPort).toBeGreaterThan(0); + + await retry( + async () => { + const tasks = await fetchJson<Task[]>("/api/tasks"); + expect(tasks.length).toBeGreaterThan(0); + return tasks; + }, + 10, + 100, + ); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + server = null; + } + await safeCleanup(TEST_DIR); + }); + + it("returns tasks, documents, and decisions from the shared search service", async () => { + const results = await retry( + async () => { + const data = await fetchJson<Array<{ type?: string }>>("/api/search?query=alpha"); + const typeSet = new Set(data.map((item) => item.type)); + if (!typeSet.has("task") || !typeSet.has("document") || !typeSet.has("decision")) { + throw new Error("Search results not yet indexed for all types"); + } + return data; + }, + 20, + 100, + ); + const finalTypes = new Set(results.map((item) => item.type)); + expect(finalTypes.has("task")).toBe(true); + expect(finalTypes.has("document")).toBe(true); + expect(finalTypes.has("decision")).toBe(true); + }); + + it("filters search results by priority and status", async () => { + const url = "/api/search?type=task&status=In%20Progress&priority=high&query=search"; + const results = await fetchJson<Array<{ type: string; task?: Task }>>(url); + expect(results).toHaveLength(1); + expect(results[0]?.type).toBe("task"); + expect(results[0]?.task?.id).toBe(baseTask.id); + }); + + it("filters task listings by priority via the content store", async () => { + const tasks = await fetchJson<Task[]>("/api/tasks?priority=high"); + expect(tasks).toHaveLength(1); + expect(tasks[0]?.id).toBe(baseTask.id); + }); + + it("rejects unsupported priority filters with 400", async () => { + await expect(fetchJson<Task[]>("/api/tasks?priority=urgent")).rejects.toThrow(); + }); + + it("supports zero-padded ids and dependency-aware search", async () => { + const viaLooseId = await fetchJson<Task>("/api/task/7"); + expect(viaLooseId.id).toBe(baseTask.id); + + const paddedViaSearch = await fetchJson<Array<{ type: string; task?: Task }>>("/api/search?type=task&query=task-7"); + const paddedIds = paddedViaSearch.filter((result) => result.type === "task").map((result) => result.task?.id); + expect(paddedIds).toContain(baseTask.id); + + const shortQueryResults = await fetchJson<Array<{ type: string; task?: Task }>>("/api/search?type=task&query=7"); + const shortIds = shortQueryResults.filter((result) => result.type === "task").map((result) => result.task?.id); + expect(shortIds).toContain(baseTask.id); + + const dependencyMatches = await fetchJson<Array<{ type: string; task?: Task }>>( + "/api/search?type=task&query=task-0007", + ); + const dependencyIds = dependencyMatches + .filter((result) => result.type === "task") + .map((result) => result.task?.id) + .filter((id): id is string => Boolean(id)); + expect(dependencyIds).toEqual(expect.arrayContaining([baseTask.id, dependentTask.id])); + }); + + it("returns newly created tasks immediately after POST", async () => { + const createResponse = await fetch(`http://127.0.0.1:${serverPort}/api/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Immediate fetch", + status: "In Progress", + description: "Immediate availability", + }), + }); + expect(createResponse.ok).toBe(true); + const created = (await createResponse.json()) as Task; + expect(created.title).toBe("Immediate fetch"); + const shortId = created.id.replace(/^task-/, ""); + const fetched = await fetchJson<Task>(`/api/task/${shortId}`); + expect(fetched.id).toBe(created.id); + expect(fetched.title).toBe("Immediate fetch"); + }); + + it("rebuilds the Fuse index when markdown content changes", async () => { + await filesystem.saveDocument({ + ...baseDoc, + rawContent: "# Guide\nReindexed beta token", + }); + + await retry( + async () => { + const updated = await fetchJson<Array<{ type?: string }>>("/api/search?query=beta"); + if (!updated.some((item) => item.type === "document")) { + throw new Error("Document not yet reindexed"); + } + return updated; + }, + 40, + 125, + ); + }); +}); + +async function fetchJson<T>(path: string): Promise<T> { + const response = await fetch(`http://127.0.0.1:${serverPort}${path}`); + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + return response.json(); +} diff --git a/src/test/start-id.test.ts b/src/test/start-id.test.ts new file mode 100644 index 0000000..2d73945 --- /dev/null +++ b/src/test/start-id.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); + +async function initGitRepo(dir: string) { + await $`git init -b main`.cwd(dir).quiet(); + await $`git config user.name "Test User"`.cwd(dir).quiet(); + await $`git config user.email test@example.com`.cwd(dir).quiet(); +} + +describe("task id generation", () => { + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-start-id"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + await initGitRepo(TEST_DIR); + const core = new Core(TEST_DIR); + await core.initializeProject("ID Test"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("starts numbering tasks at 1", async () => { + const result = await $`bun ${CLI_PATH} task create First`.cwd(TEST_DIR).quiet(); + expect(result.exitCode).toBe(0); + + const files = await readdir(join(TEST_DIR, "backlog", "tasks")); + const first = files.find((f) => f.startsWith("task-1 -")); + expect(first).toBeDefined(); + }); +}); diff --git a/src/test/statistics.test.ts b/src/test/statistics.test.ts new file mode 100644 index 0000000..07b2b9e --- /dev/null +++ b/src/test/statistics.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test"; +import { getTaskStatistics } from "../core/statistics.ts"; +import type { Task } from "../types/index.ts"; + +describe("getTaskStatistics", () => { + const statuses = ["To Do", "In Progress", "Done"]; + + // Helper to create test tasks with required fields + const createTask = (partial: Partial<Task>): Task => ({ + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + labels: [], + dependencies: [], + createdDate: "2024-01-01", + rawContent: "", + ...partial, + }); + + test("handles empty task list", () => { + const stats = getTaskStatistics([], [], statuses); + + expect(stats.totalTasks).toBe(0); + expect(stats.completedTasks).toBe(0); + expect(stats.completionPercentage).toBe(0); + expect(stats.draftCount).toBe(0); + expect(stats.statusCounts.get("To Do")).toBe(0); + expect(stats.statusCounts.get("In Progress")).toBe(0); + expect(stats.statusCounts.get("Done")).toBe(0); + }); + + test("counts tasks by status correctly", () => { + const tasks: Task[] = [ + createTask({ id: "task-1", title: "Task 1", status: "To Do" }), + createTask({ id: "task-2", title: "Task 2", status: "To Do" }), + createTask({ id: "task-3", title: "Task 3", status: "In Progress" }), + createTask({ id: "task-4", title: "Task 4", status: "Done" }), + createTask({ id: "task-5", title: "Task 5", status: "Done" }), + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + expect(stats.totalTasks).toBe(5); + expect(stats.completedTasks).toBe(2); + expect(stats.completionPercentage).toBe(40); + expect(stats.statusCounts.get("To Do")).toBe(2); + expect(stats.statusCounts.get("In Progress")).toBe(1); + expect(stats.statusCounts.get("Done")).toBe(2); + }); + + test("counts tasks by priority correctly", () => { + const tasks: Task[] = [ + createTask({ id: "task-1", title: "Task 1", status: "To Do", priority: "high" }), + createTask({ id: "task-2", title: "Task 2", status: "To Do", priority: "high" }), + createTask({ id: "task-3", title: "Task 3", status: "In Progress", priority: "medium" }), + createTask({ id: "task-4", title: "Task 4", status: "Done", priority: "low" }), + createTask({ id: "task-5", title: "Task 5", status: "Done" }), // No priority + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + expect(stats.priorityCounts.get("high")).toBe(2); + expect(stats.priorityCounts.get("medium")).toBe(1); + expect(stats.priorityCounts.get("low")).toBe(1); + expect(stats.priorityCounts.get("none")).toBe(1); + }); + + test("counts drafts correctly", () => { + const tasks: Task[] = [createTask({ id: "task-1", title: "Task 1", status: "To Do" })]; + const drafts: Task[] = [ + createTask({ id: "task-2", title: "Draft 1", status: "" }), + createTask({ id: "task-3", title: "Draft 2", status: "" }), + ]; + + const stats = getTaskStatistics(tasks, drafts, statuses); + + expect(stats.totalTasks).toBe(1); + expect(stats.draftCount).toBe(2); + }); + + test("identifies recent activity correctly", () => { + const now = new Date(); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + const tenDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); + + const tasks: Task[] = [ + { + id: "task-1", + title: "Recent Task", + status: "To Do", + createdDate: fiveDaysAgo.toISOString().split("T")[0] as string, + assignee: [], + labels: [], + dependencies: [], + rawContent: "", + }, + { + id: "task-2", + title: "Old Task", + status: "To Do", + createdDate: tenDaysAgo.toISOString().split("T")[0] as string, + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + { + id: "task-3", + title: "Updated Task", + status: "In Progress", + createdDate: tenDaysAgo.toISOString().split("T")[0] as string, + updatedDate: fiveDaysAgo.toISOString().split("T")[0] as string, + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + expect(stats.recentActivity.created.length).toBe(1); + expect(stats.recentActivity.created[0]?.id).toBe("task-1"); + expect(stats.recentActivity.updated.length).toBe(1); + expect(stats.recentActivity.updated[0]?.id).toBe("task-3"); + }); + + test("identifies stale tasks correctly", () => { + const now = new Date(); + const twoMonthsAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + const tasks: Task[] = [ + { + id: "task-1", + title: "Stale Task", + status: "To Do", + createdDate: twoMonthsAgo.toISOString().split("T")[0] as string, + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + { + id: "task-2", + title: "Recent Task", + status: "To Do", + createdDate: oneWeekAgo.toISOString().split("T")[0] as string, + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + { + id: "task-3", + title: "Old but Done", + status: "Done", + createdDate: twoMonthsAgo.toISOString().split("T")[0] as string, + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + expect(stats.projectHealth.staleTasks.length).toBe(1); + expect(stats.projectHealth.staleTasks[0]?.id).toBe("task-1"); + }); + + test("identifies blocked tasks correctly", () => { + const tasks: Task[] = [ + createTask({ id: "task-1", title: "Blocking Task", status: "In Progress" }), + createTask({ id: "task-2", title: "Blocked Task", status: "To Do", dependencies: ["task-1"] }), // Depends on task-1 which is not done + createTask({ id: "task-3", title: "Not Blocked", status: "To Do", dependencies: ["task-4"] }), // Depends on task-4 which is done + createTask({ id: "task-4", title: "Done Task", status: "Done" }), + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + expect(stats.projectHealth.blockedTasks.length).toBe(1); + expect(stats.projectHealth.blockedTasks[0]?.id).toBe("task-2"); + }); + + test("calculates average task age correctly", () => { + const now = new Date(); + const tenDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); + const twentyDaysAgo = new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000); + const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + + const tasks: Task[] = [ + { + id: "task-1", + title: "Active Task", + status: "To Do", + createdDate: tenDaysAgo.toISOString().split("T")[0] as string, + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + { + id: "task-2", + title: "Completed Task", + status: "Done", + createdDate: twentyDaysAgo.toISOString().split("T")[0] as string, + updatedDate: fifteenDaysAgo.toISOString().split("T")[0] as string, // Completed after 5 days + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + { + id: "task-3", + title: "Recently Completed", + status: "Done", + createdDate: tenDaysAgo.toISOString().split("T")[0] as string, + updatedDate: fiveDaysAgo.toISOString().split("T")[0] as string, // Completed after 5 days + assignee: [], + rawContent: "", + labels: [], + dependencies: [], + }, + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + // Task 1: 10 days (active, so uses current age) + // Task 2: 5 days (completed, so uses creation to completion time) + // Task 3: 5 days (completed, so uses creation to completion time) + // Average: (10 + 5 + 5) / 3 = 6.67, rounded to 7 + expect(stats.projectHealth.averageTaskAge).toBe(7); + }); + + test("handles 100% completion correctly", () => { + const tasks: Task[] = [ + createTask({ id: "task-1", title: "Task 1", status: "Done" }), + createTask({ id: "task-2", title: "Task 2", status: "Done" }), + createTask({ id: "task-3", title: "Task 3", status: "Done" }), + ]; + + const stats = getTaskStatistics(tasks, [], statuses); + + expect(stats.completionPercentage).toBe(100); + expect(stats.completedTasks).toBe(3); + expect(stats.totalTasks).toBe(3); + }); +}); diff --git a/src/test/status-callback.test.ts b/src/test/status-callback.test.ts new file mode 100644 index 0000000..5fd5fa9 --- /dev/null +++ b/src/test/status-callback.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Core } from "../core/backlog.ts"; +import { executeStatusCallback } from "../utils/status-callback.ts"; + +describe("Status Change Callbacks", () => { + describe("executeStatusCallback", () => { + const testCwd = process.cwd(); + + test("executes command with environment variables", async () => { + const result = await executeStatusCallback({ + command: 'echo "Task: $TASK_ID, Old: $OLD_STATUS, New: $NEW_STATUS, Title: $TASK_TITLE"', + taskId: "task-123", + oldStatus: "To Do", + newStatus: "In Progress", + taskTitle: "Test Task", + cwd: testCwd, + }); + + expect(result.success).toBe(true); + expect(result.output).toContain("Task: task-123"); + expect(result.output).toContain("Old: To Do"); + expect(result.output).toContain("New: In Progress"); + expect(result.output).toContain("Title: Test Task"); + }); + + test("returns success false for failing command", async () => { + const result = await executeStatusCallback({ + command: "exit 1", + taskId: "task-123", + oldStatus: "To Do", + newStatus: "Done", + taskTitle: "Test Task", + cwd: testCwd, + }); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + test("returns error for empty command", async () => { + const result = await executeStatusCallback({ + command: "", + taskId: "task-123", + oldStatus: "To Do", + newStatus: "Done", + taskTitle: "Test Task", + cwd: testCwd, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Empty command"); + }); + + test("captures stderr on failure", async () => { + const result = await executeStatusCallback({ + command: 'echo "error message" >&2 && exit 1', + taskId: "task-123", + oldStatus: "To Do", + newStatus: "Done", + taskTitle: "Test Task", + cwd: testCwd, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("error message"); + }); + + test("handles special characters in variables", async () => { + const result = await executeStatusCallback({ + command: 'echo "$TASK_TITLE"', + taskId: "task-123", + oldStatus: "To Do", + newStatus: "Done", + taskTitle: 'Task with "quotes" and $pecial chars', + cwd: testCwd, + }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Task with "quotes" and $pecial chars'); + }); + }); + + describe("Core.updateTaskFromInput with callbacks", () => { + let testDir: string; + let core: Core; + let callbackOutputFile: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `backlog-callback-test-${Date.now()}`); + await mkdir(testDir, { recursive: true }); + await mkdir(join(testDir, "backlog", "tasks"), { recursive: true }); + + callbackOutputFile = join(testDir, "callback-output.txt"); + + core = new Core(testDir); + }); + + afterEach(async () => { + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("triggers global callback on status change", async () => { + // Create config with onStatusChange + const configContent = `projectName: Test +statuses: + - To Do + - In Progress + - Done +labels: [] +milestones: [] +dateFormat: yyyy-mm-dd +onStatusChange: 'echo "$TASK_ID:$OLD_STATUS->$NEW_STATUS" > ${callbackOutputFile}' +`; + await writeFile(join(testDir, "backlog", "config.yml"), configContent); + + // Verify config was written correctly + const writtenConfig = await Bun.file(join(testDir, "backlog", "config.yml")).text(); + expect(writtenConfig).toContain("onStatusChange"); + + // Create a task + const { task } = await core.createTaskFromInput({ + title: "Test Callback Task", + status: "To Do", + }); + + // Invalidate config cache to ensure fresh read + core.fs.invalidateConfigCache(); + + // Update status + await core.updateTaskFromInput(task.id, { status: "In Progress" }); + + // Wait a bit for async callback + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Check callback was executed + const output = await Bun.file(callbackOutputFile).text(); + expect(output.trim()).toBe(`${task.id}:To Do->In Progress`); + }); + + test("per-task callback overrides global callback", async () => { + // Create config with global onStatusChange + const configContent = `projectName: Test +statuses: + - To Do + - In Progress + - Done +labels: [] +milestones: [] +dateFormat: yyyy-mm-dd +onStatusChange: 'echo "global" > ${callbackOutputFile}' +`; + await writeFile(join(testDir, "backlog", "config.yml"), configContent); + + // Create a task with per-task callback + const taskContent = `--- +id: task-1 +title: Task with custom callback +status: To Do +assignee: [] +created_date: 2025-01-01 +labels: [] +dependencies: [] +onStatusChange: 'echo "per-task:$NEW_STATUS" > ${callbackOutputFile}' +--- +`; + await writeFile(join(testDir, "backlog", "tasks", "task-1 - Task with custom callback.md"), taskContent); + + // Update status + await core.updateTaskFromInput("task-1", { status: "Done" }); + + // Wait a bit for async callback + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check per-task callback was executed (not global) + const output = await Bun.file(callbackOutputFile).text(); + expect(output.trim()).toBe("per-task:Done"); + }); + + test("no callback when status unchanged", async () => { + // Create config with onStatusChange + const configContent = `projectName: Test +statuses: + - To Do + - In Progress + - Done +labels: [] +milestones: [] +dateFormat: yyyy-mm-dd +onStatusChange: 'echo "callback-ran" > ${callbackOutputFile}' +`; + await writeFile(join(testDir, "backlog", "config.yml"), configContent); + + // Create a task + const { task } = await core.createTaskFromInput({ + title: "Test No Callback Task", + status: "To Do", + }); + + // Update something other than status + await core.updateTaskFromInput(task.id, { title: "Updated Title" }); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check callback was NOT executed + const exists = await Bun.file(callbackOutputFile).exists(); + expect(exists).toBe(false); + }); + + test("no callback when no callback configured", async () => { + // Create config without onStatusChange + const configContent = `projectName: Test +statuses: + - To Do + - In Progress + - Done +labels: [] +milestones: [] +dateFormat: yyyy-mm-dd +`; + await writeFile(join(testDir, "backlog", "config.yml"), configContent); + + // Create a task + const { task } = await core.createTaskFromInput({ + title: "Test No Config Task", + status: "To Do", + }); + + // Update status - should not fail even without callback + const result = await core.updateTaskFromInput(task.id, { status: "In Progress" }); + expect(result.status).toBe("In Progress"); + }); + + test("callback failure does not block status change", async () => { + // Create config with failing callback + const configContent = `projectName: Test +statuses: + - To Do + - In Progress + - Done +labels: [] +milestones: [] +dateFormat: yyyy-mm-dd +onStatusChange: 'exit 1' +`; + await writeFile(join(testDir, "backlog", "config.yml"), configContent); + + // Create a task + const { task } = await core.createTaskFromInput({ + title: "Test Failing Callback Task", + status: "To Do", + }); + + // Update status - should succeed even if callback fails + const result = await core.updateTaskFromInput(task.id, { status: "Done" }); + expect(result.status).toBe("Done"); + }); + + test("triggers callback when reorderTask changes status", async () => { + // Create config with onStatusChange + const configContent = `projectName: Test +statuses: + - To Do + - In Progress + - Done +labels: [] +milestones: [] +dateFormat: yyyy-mm-dd +onStatusChange: 'echo "$TASK_ID:$OLD_STATUS->$NEW_STATUS" >> ${callbackOutputFile}' +`; + await writeFile(join(testDir, "backlog", "config.yml"), configContent); + + // Create a task in "To Do" + const { task } = await core.createTaskFromInput({ + title: "Reorder Callback Test", + status: "To Do", + }); + + // Invalidate config cache + core.fs.invalidateConfigCache(); + + // Reorder task to "In Progress" column (simulating board drag) + await core.reorderTask({ + taskId: task.id, + targetStatus: "In Progress", + orderedTaskIds: [task.id], + }); + + // Wait for callback + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Check callback was executed + const output = await Bun.file(callbackOutputFile).text(); + expect(output.trim()).toBe(`${task.id}:To Do->In Progress`); + }); + }); +}); diff --git a/src/test/status-icon.test.ts b/src/test/status-icon.test.ts new file mode 100644 index 0000000..3851c16 --- /dev/null +++ b/src/test/status-icon.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { formatStatusWithIcon, getStatusColor, getStatusIcon, getStatusStyle } from "../ui/status-icon.ts"; + +describe("Status Icon Component", () => { + describe("getStatusStyle", () => { + test("returns correct style for Done status", () => { + const style = getStatusStyle("Done"); + expect(style.icon).toBe("βœ”"); + expect(style.color).toBe("green"); + }); + + test("returns correct style for In Progress status", () => { + const style = getStatusStyle("In Progress"); + expect(style.icon).toBe("β—’"); + expect(style.color).toBe("yellow"); + }); + + test("returns correct style for Blocked status", () => { + const style = getStatusStyle("Blocked"); + expect(style.icon).toBe("●"); + expect(style.color).toBe("red"); + }); + + test("returns correct style for To Do status", () => { + const style = getStatusStyle("To Do"); + expect(style.icon).toBe("β—‹"); + expect(style.color).toBe("white"); + }); + + test("returns correct style for Review status", () => { + const style = getStatusStyle("Review"); + expect(style.icon).toBe("β—†"); + expect(style.color).toBe("blue"); + }); + + test("returns correct style for Testing status", () => { + const style = getStatusStyle("Testing"); + expect(style.icon).toBe("β–£"); + expect(style.color).toBe("cyan"); + }); + + test("returns default style for unknown status", () => { + const style = getStatusStyle("Unknown Status"); + expect(style.icon).toBe("β—‹"); + expect(style.color).toBe("white"); + }); + }); + + describe("getStatusColor", () => { + test("returns correct color for each status", () => { + expect(getStatusColor("Done")).toBe("green"); + expect(getStatusColor("In Progress")).toBe("yellow"); + expect(getStatusColor("Blocked")).toBe("red"); + expect(getStatusColor("To Do")).toBe("white"); + expect(getStatusColor("Review")).toBe("blue"); + expect(getStatusColor("Testing")).toBe("cyan"); + }); + + test("returns default color for unknown status", () => { + expect(getStatusColor("Unknown")).toBe("white"); + }); + }); + + describe("getStatusIcon", () => { + test("returns correct icon for each status", () => { + expect(getStatusIcon("Done")).toBe("βœ”"); + expect(getStatusIcon("In Progress")).toBe("β—’"); + expect(getStatusIcon("Blocked")).toBe("●"); + expect(getStatusIcon("To Do")).toBe("β—‹"); + expect(getStatusIcon("Review")).toBe("β—†"); + expect(getStatusIcon("Testing")).toBe("β–£"); + }); + + test("returns default icon for unknown status", () => { + expect(getStatusIcon("Unknown")).toBe("β—‹"); + }); + }); + + describe("formatStatusWithIcon", () => { + test("formats status with correct icon", () => { + expect(formatStatusWithIcon("Done")).toBe("βœ” Done"); + expect(formatStatusWithIcon("In Progress")).toBe("β—’ In Progress"); + expect(formatStatusWithIcon("Blocked")).toBe("● Blocked"); + expect(formatStatusWithIcon("To Do")).toBe("β—‹ To Do"); + expect(formatStatusWithIcon("Review")).toBe("β—† Review"); + expect(formatStatusWithIcon("Testing")).toBe("β–£ Testing"); + }); + + test("formats unknown status with default icon", () => { + expect(formatStatusWithIcon("Custom Status")).toBe("β—‹ Custom Status"); + }); + }); +}); diff --git a/src/test/tab-switching.test.ts b/src/test/tab-switching.test.ts new file mode 100644 index 0000000..d7d5c83 --- /dev/null +++ b/src/test/tab-switching.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Tab switching functionality", () => { + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-tab-switching"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests - required for CI + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test Tab Switching Project"); + + // Create test tasks + const tasksDir = core.filesystem.tasksDir; + await writeFile( + join(tasksDir, "task-1 - Test Task.md"), + `--- +id: task-1 +title: Test Task +status: To Do +assignee: [] +created_date: '2025-07-05' +labels: [] +dependencies: [] +--- + +## Description + +Test task for tab switching.`, + ); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("Unified Task domain", () => { + it("should use unified Task interface everywhere", async () => { + // Load tasks + const tasks = await core.filesystem.listTasks(); + expect(tasks.length).toBe(1); + + const task = tasks[0]; + expect(task).toBeDefined(); + + if (!task) return; + + // Verify Task has all the expected fields (including metadata fields) + expect(task.id).toBeDefined(); + expect(task.title).toBeDefined(); + expect(task.status).toBeDefined(); + expect(task.assignee).toBeDefined(); + expect(task.labels).toBeDefined(); + expect(task.dependencies).toBeDefined(); + + // Metadata fields should be optional and available + expect(typeof task.source).toBe("undefined"); // Not set for local tasks loaded from filesystem + expect(typeof task.lastModified).toBe("undefined"); // Not set for basic loaded tasks + + // But they should be settable + const taskWithMetadata = { + ...task, + source: "local" as const, + lastModified: new Date(), + }; + + expect(taskWithMetadata.source).toBe("local"); + expect(taskWithMetadata.lastModified).toBeInstanceOf(Date); + }); + + it("should handle runUnifiedView with preloaded kanban data", async () => { + const tasks = await core.filesystem.listTasks(); + + // Test that runUnifiedView accepts the correct parameters without actually running the UI + expect(() => { + // Just verify the function can be imported and called with correct parameters + const options = { + core, + initialView: "kanban" as const, + tasks, + preloadedKanbanData: { + tasks: tasks.map((t) => ({ ...t, source: "local" as const })), + statuses: ["To Do", "In Progress", "Done"], + }, + }; + + // Verify the options object is valid + expect(options.core).toBeDefined(); + expect(options.initialView).toBe("kanban"); + expect(options.tasks).toBeDefined(); + expect(options.preloadedKanbanData).toBeDefined(); + }).not.toThrow(); + }); + + it("should handle task switching between views", async () => { + const tasks = await core.filesystem.listTasks(); + expect(tasks.length).toBe(1); + + const testTask = tasks[0]; + + // Test that we can create valid options for different view types + const testStates = [ + { view: "task-list" as const, task: testTask }, + { view: "task-detail" as const, task: testTask }, + { view: "kanban" as const, task: testTask }, + ]; + + for (const state of testStates) { + expect(() => { + // Verify we can create valid options for each view type + const options = { + core, + initialView: state.view, + selectedTask: state.task, + tasks, + preloadedKanbanData: { + tasks, + statuses: ["To Do"], + }, + }; + + // Verify the options are valid + expect(options.core).toBeDefined(); + expect(options.initialView).toBe(state.view); + if (state.task) { + expect(options.selectedTask).toEqual(state.task); + } else { + expect(options.selectedTask).toBeNull(); + } + expect(options.tasks).toBeDefined(); + expect(options.preloadedKanbanData).toBeDefined(); + }).not.toThrow(); + } + }); + }); +}); diff --git a/src/test/task-edit-preservation.test.ts b/src/test/task-edit-preservation.test.ts new file mode 100644 index 0000000..393467f --- /dev/null +++ b/src/test/task-edit-preservation.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../index.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +let TEST_DIR: string; + +describe("Task edit section preservation", () => { + const cliPath = join(process.cwd(), "src", "cli.ts"); + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-task-edit-preservation"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Initialize git repo first + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + await $`git config user.email "test@example.com"`.cwd(TEST_DIR).quiet(); + + // Initialize backlog project using Core + const core = new Core(TEST_DIR); + await core.initializeProject("Task Edit Preservation Test"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + it("should preserve all sections when updating description", async () => { + // Create a task with all sections + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-1", + title: "Full task test", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Original description", + }, + false, + ); + + // Add acceptance criteria + await $`bun ${cliPath} task edit 1 --ac "Criterion 1,Criterion 2"`.cwd(TEST_DIR).quiet(); + + // Add implementation plan + await $`bun ${cliPath} task edit 1 --plan "Step 1\nStep 2\nStep 3"`.cwd(TEST_DIR).quiet(); + + // Add implementation notes + await $`bun ${cliPath} task edit 1 --notes "Original implementation notes"`.cwd(TEST_DIR).quiet(); + + // Verify all sections exist + let result = await $`bun ${cliPath} task 1 --plain`.cwd(TEST_DIR).text(); + + expect(result).toContain("Original description"); + expect(result).toContain("Criterion 1"); + expect(result).toContain("Criterion 2"); + expect(result).toContain("Step 1"); + expect(result).toContain("Step 2"); + expect(result).toContain("Step 3"); + expect(result).toContain("Original implementation notes"); + + // Update just the description + await $`bun ${cliPath} task edit 1 -d "UPDATED description"`.cwd(TEST_DIR).quiet(); + + // Verify ALL sections are preserved + result = await $`bun ${cliPath} task 1 --plain`.cwd(TEST_DIR).text(); + + expect(result).toContain("UPDATED description"); + expect(result).toContain("Criterion 1"); + expect(result).toContain("Criterion 2"); + expect(result).toContain("Step 1"); + expect(result).toContain("Step 2"); + expect(result).toContain("Step 3"); + expect(result).toContain("Original implementation notes"); + }); + + it("should preserve all sections when updating acceptance criteria", async () => { + // Create a task with all sections + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-2", + title: "AC update test", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Test description", + }, + false, + ); + + // Add all sections + await $`bun ${cliPath} task edit 2 --ac "Original criterion"`.cwd(TEST_DIR).quiet(); + await $`bun ${cliPath} task edit 2 --plan "Original plan"`.cwd(TEST_DIR).quiet(); + await $`bun ${cliPath} task edit 2 --notes "Original notes"`.cwd(TEST_DIR).quiet(); + + // Add new acceptance criteria (now adds instead of replacing) + await $`bun ${cliPath} task edit 2 --ac "Updated criterion 1" --ac "Updated criterion 2"`.cwd(TEST_DIR).quiet(); + + // Verify all sections are preserved + const result = await $`bun ${cliPath} task 2 --plain`.cwd(TEST_DIR).text(); + + expect(result).toContain("Test description"); + expect(result).toContain("Original criterion"); // Now preserved + expect(result).toContain("Updated criterion 1"); + expect(result).toContain("Updated criterion 2"); + expect(result).toContain("Original plan"); + expect(result).toContain("Original notes"); + }); + + it("should preserve all sections when updating implementation plan", async () => { + // Create a task with all sections + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-3", + title: "Plan update test", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Test description", + }, + false, + ); + + // Add all sections + await $`bun ${cliPath} task edit 3 --ac "Test criterion"`.cwd(TEST_DIR).quiet(); + await $`bun ${cliPath} task edit 3 --plan "Original plan"`.cwd(TEST_DIR).quiet(); + await $`bun ${cliPath} task edit 3 --notes "Original notes"`.cwd(TEST_DIR).quiet(); + + // Update implementation plan + await $`bun ${cliPath} task edit 3 --plan "Updated plan step 1\nUpdated plan step 2"`.cwd(TEST_DIR).quiet(); + + // Verify all sections are preserved + const result = await $`bun ${cliPath} task 3 --plain`.cwd(TEST_DIR).text(); + + expect(result).toContain("Test description"); + expect(result).toContain("Test criterion"); + expect(result).toContain("Updated plan step 1"); + expect(result).toContain("Updated plan step 2"); + expect(result).toContain("Original notes"); + expect(result).not.toContain("Original plan"); + }); + + it("should preserve all sections when updating implementation notes", async () => { + // Create a task with all sections + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-4", + title: "Notes update test", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Test description", + }, + false, + ); + + // Add all sections + await $`bun ${cliPath} task edit 4 --ac "Test criterion"`.cwd(TEST_DIR).quiet(); + await $`bun ${cliPath} task edit 4 --plan "Test plan"`.cwd(TEST_DIR).quiet(); + await $`bun ${cliPath} task edit 4 --notes "Original notes"`.cwd(TEST_DIR).quiet(); + + // Update implementation notes (should overwrite existing) + await $`bun ${cliPath} task edit 4 --notes "Additional notes"`.cwd(TEST_DIR).quiet(); + + // Verify all sections are preserved and notes are appended + const result = await $`bun ${cliPath} task 4 --plain`.cwd(TEST_DIR).text(); + + expect(result).toContain("Test description"); + expect(result).toContain("Test criterion"); + expect(result).toContain("Test plan"); + expect(result).not.toContain("Original notes"); + expect(result).toContain("Additional notes"); + }); + + it("should handle tasks with minimal content", async () => { + // Create a task with just description + const core = new Core(TEST_DIR); + await core.createTask( + { + id: "task-5", + title: "Minimal task test", + status: "To Do", + assignee: [], + createdDate: "2025-07-04", + labels: [], + dependencies: [], + description: "Minimal description", + }, + false, + ); + + // Update description + await $`bun ${cliPath} task edit 5 -d "Updated minimal description"`.cwd(TEST_DIR).quiet(); + + // Should have updated description and default AC text + const result = await $`bun ${cliPath} task 5 --plain`.cwd(TEST_DIR).text(); + + expect(result).toContain("Updated minimal description"); + expect(result).toContain("No acceptance criteria defined"); + }); +}); diff --git a/src/test/task-loader-branch-normalization.test.ts b/src/test/task-loader-branch-normalization.test.ts new file mode 100644 index 0000000..89f334c --- /dev/null +++ b/src/test/task-loader-branch-normalization.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { buildRemoteTaskIndex } from "../core/task-loader.ts"; +import type { GitOperations } from "../git/operations.ts"; + +class MockGit implements Partial<GitOperations> { + public refs: string[] = []; + + async listFilesInTree(ref: string, _path: string): Promise<string[]> { + this.refs.push(ref); + return ["backlog/tasks/task-1 - Test.md"]; + } + + async getBranchLastModifiedMap(_ref: string, _path: string): Promise<Map<string, Date>> { + return new Map([["backlog/tasks/task-1 - Test.md", new Date()]]); + } +} + +describe("buildRemoteTaskIndex branch handling", () => { + it("normalizes various branch forms to canonical refs", async () => { + const git = new MockGit(); + await buildRemoteTaskIndex(git as unknown as GitOperations, ["main", "origin/main", "refs/remotes/origin/main"]); + expect(git.refs).toEqual(["origin/main", "origin/main", "origin/main"]); + }); + + it("filters out invalid branch entries", async () => { + const git = new MockGit(); + await buildRemoteTaskIndex(git as unknown as GitOperations, [ + "main", + "origin", + "origin/HEAD", + "HEAD", + "origin/origin", + "refs/remotes/origin/origin", + ]); + expect(git.refs).toEqual(["origin/main"]); + }); +}); diff --git a/src/test/task-path.test.ts b/src/test/task-path.test.ts new file mode 100644 index 0000000..96f49ca --- /dev/null +++ b/src/test/task-path.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { getTaskFilename, getTaskPath, normalizeTaskId, taskFileExists } from "../utils/task-path.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +describe("Task path utilities", () => { + let TEST_DIR: string; + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-task-path"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests - required for CI + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test Project"); + + // Create some test task files + const tasksDir = core.filesystem.tasksDir; + await writeFile(join(tasksDir, "task-123 - Test Task.md"), "# Test Task 123"); + await writeFile(join(tasksDir, "task-456 - Another Task.md"), "# Another Task 456"); + await writeFile(join(tasksDir, "task-789 - Final Task.md"), "# Final Task 789"); + // Additional: padded and dotted ids + await writeFile(join(tasksDir, "task-0001 - Padded One.md"), "# Padded One"); + await writeFile(join(tasksDir, "task-3.01 - Subtask Padded.md"), "# Subtask Padded 3.01"); + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("normalizeTaskId", () => { + it("should add task- prefix if missing", () => { + expect(normalizeTaskId("123")).toBe("task-123"); + expect(normalizeTaskId("456")).toBe("task-456"); + }); + + it("should not modify task IDs that already have task- prefix", () => { + expect(normalizeTaskId("task-123")).toBe("task-123"); + expect(normalizeTaskId("task-456")).toBe("task-456"); + }); + + it("should handle empty strings", () => { + expect(normalizeTaskId("")).toBe("task-"); + }); + + it("should normalize mixed-case prefixes and preserve numeric padding", () => { + expect(normalizeTaskId("TASK-001")).toBe("task-001"); + expect(normalizeTaskId("Task-42")).toBe("task-42"); + }); + }); + + describe("getTaskPath", () => { + it("should return full path for existing task", async () => { + const path = await getTaskPath("123", core); + expect(path).toBeTruthy(); + expect(path).toContain("task-123 - Test Task.md"); + expect(path).toContain(core.filesystem.tasksDir); + }); + + it("should work with task- prefix", async () => { + const path = await getTaskPath("task-456", core); + expect(path).toBeTruthy(); + expect(path).toContain("task-456 - Another Task.md"); + }); + + it("should resolve zero-padded numeric IDs to the same task", async () => { + // File exists as task-0001; query with 1 + const path1 = await getTaskPath("1", core); + expect(path1).toBeTruthy(); + expect(path1).toContain("task-0001 - Padded One.md"); + + // Query with zero-padded input for non-padded file (123) + const path2 = await getTaskPath("0123", core); + expect(path2).toBeTruthy(); + expect(path2).toContain("task-123 - Test Task.md"); + }); + + it("should resolve case-insensitive task IDs", async () => { + const uppercase = await getTaskPath("TASK-0001", core); + expect(uppercase).toBeTruthy(); + expect(uppercase).toContain("task-0001 - Padded One.md"); + + const mixedCase = await getTaskPath("Task-456", core); + expect(mixedCase).toBeTruthy(); + expect(mixedCase).toContain("task-456 - Another Task.md"); + }); + + it("should return null for non-existent task", async () => { + const path = await getTaskPath("999", core); + expect(path).toBeNull(); + }); + + it("should handle errors gracefully", async () => { + // Pass invalid core to trigger error + const path = await getTaskPath("123", null as unknown as Core); + expect(path).toBeNull(); + }); + }); + + describe("getTaskFilename", () => { + it("should return filename for existing task", async () => { + const filename = await getTaskFilename("789", core); + expect(filename).toBe("task-789 - Final Task.md"); + }); + + it("should resolve dotted IDs ignoring leading zeros in segments", async () => { + const filename = await getTaskFilename("3.1", core); + expect(filename).toBe("task-3.01 - Subtask Padded.md"); + }); + + it("should resolve case-insensitive IDs when fetching filenames", async () => { + const filename = await getTaskFilename("TASK-789", core); + expect(filename).toBe("task-789 - Final Task.md"); + }); + + it("should return null for non-existent task", async () => { + const filename = await getTaskFilename("999", core); + expect(filename).toBeNull(); + }); + }); + + describe("taskFileExists", () => { + it("should return true for existing tasks", async () => { + const exists = await taskFileExists("123", core); + expect(exists).toBe(true); + }); + + it("should return false for non-existent tasks", async () => { + const exists = await taskFileExists("999", core); + expect(exists).toBe(false); + }); + + it("should work with task- prefix", async () => { + const exists = await taskFileExists("task-456", core); + expect(exists).toBe(true); + }); + }); + + describe("integration with Core default", () => { + it("should work without explicit core parameter when in valid project", async () => { + // Change to test directory to use default Core + const originalCwd = process.cwd(); + process.chdir(TEST_DIR); + + try { + const path = await getTaskPath("123"); + expect(path).toBeTruthy(); + expect(path).toContain("task-123 - Test Task.md"); + } finally { + process.chdir(originalCwd); + } + }); + }); +}); diff --git a/src/test/task-sorting.test.ts b/src/test/task-sorting.test.ts new file mode 100644 index 0000000..d59d817 --- /dev/null +++ b/src/test/task-sorting.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { compareTaskIds, parseTaskId, sortByPriority, sortByTaskId, sortTasks } from "../utils/task-sorting.ts"; + +describe("parseTaskId", () => { + test("parses simple task IDs", () => { + expect(parseTaskId("task-1")).toEqual([1]); + expect(parseTaskId("task-10")).toEqual([10]); + expect(parseTaskId("task-100")).toEqual([100]); + }); + + test("parses decimal task IDs", () => { + expect(parseTaskId("task-1.1")).toEqual([1, 1]); + expect(parseTaskId("task-1.2.3")).toEqual([1, 2, 3]); + expect(parseTaskId("task-10.20.30")).toEqual([10, 20, 30]); + }); + + test("handles IDs without task- prefix", () => { + expect(parseTaskId("5")).toEqual([5]); + expect(parseTaskId("5.1")).toEqual([5, 1]); + }); + + test("handles invalid numeric parts", () => { + expect(parseTaskId("task-abc")).toEqual([0]); + expect(parseTaskId("task-1.abc.2")).toEqual([2]); // Mixed numeric/non-numeric extracts trailing number + }); + + test("handles IDs with trailing numbers", () => { + expect(parseTaskId("task-draft")).toEqual([0]); + expect(parseTaskId("task-draft2")).toEqual([2]); + expect(parseTaskId("task-draft10")).toEqual([10]); + expect(parseTaskId("draft2")).toEqual([2]); + expect(parseTaskId("abc123")).toEqual([123]); + }); +}); + +describe("compareTaskIds", () => { + test("sorts simple task IDs numerically", () => { + expect(compareTaskIds("task-2", "task-10")).toBeLessThan(0); + expect(compareTaskIds("task-10", "task-2")).toBeGreaterThan(0); + expect(compareTaskIds("task-5", "task-5")).toBe(0); + }); + + test("sorts decimal task IDs correctly", () => { + expect(compareTaskIds("task-2.1", "task-2.2")).toBeLessThan(0); + expect(compareTaskIds("task-2.2", "task-2.10")).toBeLessThan(0); + expect(compareTaskIds("task-2.10", "task-2.2")).toBeGreaterThan(0); + }); + + test("parent tasks come before subtasks", () => { + expect(compareTaskIds("task-2", "task-2.1")).toBeLessThan(0); + expect(compareTaskIds("task-2.1", "task-2")).toBeGreaterThan(0); + }); + + test("handles different depth levels", () => { + expect(compareTaskIds("task-1.1.1", "task-1.2")).toBeLessThan(0); + expect(compareTaskIds("task-1.2", "task-1.1.1")).toBeGreaterThan(0); + }); + + test("sorts IDs with trailing numbers", () => { + expect(compareTaskIds("task-draft", "task-draft2")).toBeLessThan(0); + expect(compareTaskIds("task-draft2", "task-draft10")).toBeLessThan(0); + expect(compareTaskIds("task-draft10", "task-draft2")).toBeGreaterThan(0); + }); +}); + +describe("sortByTaskId", () => { + test("sorts array of tasks by ID numerically", () => { + const tasks = [ + { id: "task-10", title: "Task 10" }, + { id: "task-2", title: "Task 2" }, + { id: "task-1", title: "Task 1" }, + { id: "task-20", title: "Task 20" }, + { id: "task-3", title: "Task 3" }, + ]; + + const sorted = sortByTaskId(tasks); + expect(sorted.map((t) => t.id)).toEqual(["task-1", "task-2", "task-3", "task-10", "task-20"]); + }); + + test("sorts tasks with decimal IDs correctly", () => { + const tasks = [ + { id: "task-2.10", title: "Subtask 2.10" }, + { id: "task-2.2", title: "Subtask 2.2" }, + { id: "task-2", title: "Task 2" }, + { id: "task-1", title: "Task 1" }, + { id: "task-2.1", title: "Subtask 2.1" }, + ]; + + const sorted = sortByTaskId(tasks); + expect(sorted.map((t) => t.id)).toEqual(["task-1", "task-2", "task-2.1", "task-2.2", "task-2.10"]); + }); + + test("handles mixed simple and decimal IDs", () => { + const tasks = [ + { id: "task-10", title: "Task 10" }, + { id: "task-2.1", title: "Subtask 2.1" }, + { id: "task-2", title: "Task 2" }, + { id: "task-1", title: "Task 1" }, + { id: "task-10.1", title: "Subtask 10.1" }, + { id: "task-3", title: "Task 3" }, + ]; + + const sorted = sortByTaskId(tasks); + expect(sorted.map((t) => t.id)).toEqual(["task-1", "task-2", "task-2.1", "task-3", "task-10", "task-10.1"]); + }); + + test("preserves original array", () => { + const tasks = [ + { id: "task-3", title: "Task 3" }, + { id: "task-1", title: "Task 1" }, + { id: "task-2", title: "Task 2" }, + ]; + + const original = [...tasks]; + sortByTaskId(tasks); + + // Original array order should be preserved + expect(tasks).toEqual(original); + }); +}); + +describe("sortByPriority", () => { + test("sorts tasks by priority order: high > medium > low > undefined", () => { + const tasks = [ + { id: "task-1", priority: "low" as const }, + { id: "task-2", priority: "high" as const }, + { id: "task-3" }, // no priority + { id: "task-4", priority: "medium" as const }, + { id: "task-5", priority: "high" as const }, + ]; + + const sorted = sortByPriority(tasks); + expect(sorted.map((t) => ({ id: t.id, priority: t.priority }))).toEqual([ + { id: "task-2", priority: "high" }, + { id: "task-5", priority: "high" }, + { id: "task-4", priority: "medium" }, + { id: "task-1", priority: "low" }, + { id: "task-3", priority: undefined }, + ]); + }); + + test("sorts tasks with same priority by task ID", () => { + const tasks = [ + { id: "task-10", priority: "high" as const }, + { id: "task-2", priority: "high" as const }, + { id: "task-20", priority: "medium" as const }, + { id: "task-1", priority: "medium" as const }, + ]; + + const sorted = sortByPriority(tasks); + expect(sorted.map((t) => t.id)).toEqual(["task-2", "task-10", "task-1", "task-20"]); + }); + + test("handles all undefined priorities", () => { + const tasks = [{ id: "task-3" }, { id: "task-1" }, { id: "task-2" }]; + + const sorted = sortByPriority(tasks); + expect(sorted.map((t) => t.id)).toEqual(["task-1", "task-2", "task-3"]); + }); + + test("preserves original array", () => { + const tasks = [ + { id: "task-1", priority: "low" as const }, + { id: "task-2", priority: "high" as const }, + ]; + + const original = [...tasks]; + sortByPriority(tasks); + + // Original array order should be preserved + expect(tasks).toEqual(original); + }); +}); + +describe("sortTasks", () => { + test("sorts by priority when field is 'priority'", () => { + const tasks = [ + { id: "task-1", priority: "low" as const }, + { id: "task-2", priority: "high" as const }, + { id: "task-3", priority: "medium" as const }, + ]; + + const sorted = sortTasks(tasks, "priority"); + expect(sorted.map((t) => t.priority)).toEqual(["high", "medium", "low"]); + }); + + test("sorts by ID when field is 'id'", () => { + const tasks = [ + { id: "task-10", priority: "high" as const }, + { id: "task-2", priority: "high" as const }, + { id: "task-1", priority: "high" as const }, + ]; + + const sorted = sortTasks(tasks, "id"); + expect(sorted.map((t) => t.id)).toEqual(["task-1", "task-2", "task-10"]); + }); + + test("handles case-insensitive field names", () => { + const tasks = [ + { id: "task-1", priority: "low" as const }, + { id: "task-2", priority: "high" as const }, + ]; + + const sorted = sortTasks(tasks, "PRIORITY"); + expect(sorted.map((t) => t.priority)).toEqual(["high", "low"]); + }); + + test("defaults to ID sorting for unknown fields", () => { + const tasks = [{ id: "task-10" }, { id: "task-2" }, { id: "task-1" }]; + + const sorted = sortTasks(tasks, "unknown"); + expect(sorted.map((t) => t.id)).toEqual(["task-1", "task-2", "task-10"]); + }); + + test("defaults to ID sorting for empty field", () => { + const tasks = [{ id: "task-10" }, { id: "task-2" }]; + + const sorted = sortTasks(tasks, ""); + expect(sorted.map((t) => t.id)).toEqual(["task-2", "task-10"]); + }); +}); diff --git a/src/test/test-helpers.ts b/src/test/test-helpers.ts new file mode 100644 index 0000000..c3b15d9 --- /dev/null +++ b/src/test/test-helpers.ts @@ -0,0 +1,499 @@ +/** + * Platform-aware test helpers that avoid memory issues on Windows CI + * by testing Core directly instead of spawning CLI processes + */ + +import { join } from "node:path"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import type { TaskCreateInput, TaskUpdateInput } from "../types/index.ts"; +import { normalizeDependencies } from "../utils/task-builders.ts"; + +const CLI_PATH = join(process.cwd(), "src", "cli.ts"); +const isWindows = process.platform === "win32"; + +export interface TaskCreateOptions { + title: string; + description?: string; + assignee?: string; + status?: string; + labels?: string; + priority?: string; + ac?: string; + plan?: string; + notes?: string; + draft?: boolean; + parent?: string; + dependencies?: string; +} + +/** + * Platform-aware task creation that uses Core directly on Windows + * and CLI spawning on Unix systems + */ +export async function createTaskPlatformAware( + options: TaskCreateOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string; taskId?: string }> { + // Always use Core API for tests to avoid CLI process spawning issues + return createTaskViaCore(options, testDir); +} + +async function createTaskViaCore( + options: TaskCreateOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string; taskId?: string }> { + const core = new Core(testDir); + + const normalizedPriority = options.priority ? String(options.priority).toLowerCase() : undefined; + const createInput: TaskCreateInput = { + title: options.title.trim(), + description: options.description, + status: options.status ?? (options.draft ? "Draft" : undefined), + priority: normalizedPriority as TaskCreateInput["priority"], + labels: options.labels + ? options.labels + .split(",") + .map((label) => label.trim()) + .filter((label) => label.length > 0) + : undefined, + assignee: options.assignee ? [options.assignee] : undefined, + dependencies: options.dependencies ? normalizeDependencies(options.dependencies) : undefined, + parentTaskId: options.parent + ? options.parent.startsWith("task-") + ? options.parent + : `task-${options.parent}` + : undefined, + }; + + if (!createInput.title) { + return { + exitCode: 1, + stdout: "", + stderr: "Title is required", + }; + } + + if (options.ac) { + const trimmed = options.ac.trim(); + if (trimmed) { + createInput.acceptanceCriteria = [{ text: trimmed, checked: false }]; + } + } + + if (options.plan) { + createInput.implementationPlan = options.plan; + } + + if (options.notes) { + createInput.implementationNotes = options.notes; + } + + try { + const { task } = await core.createTaskFromInput(createInput); + const isDraft = (task.status ?? "").toLowerCase() === "draft"; + return { + exitCode: 0, + stdout: isDraft ? `Created draft ${task.id}` : `Created task ${task.id}`, + stderr: "", + taskId: task.id, + }; + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +async function _createTaskViaCLI( + options: TaskCreateOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string; taskId?: string }> { + // Build CLI arguments + const args = [CLI_PATH, "task", "create", options.title]; + + if (options.description) args.push("--description", options.description); + if (options.assignee) args.push("--assignee", options.assignee); + if (options.status) args.push("--status", options.status); + if (options.labels) args.push("--labels", options.labels); + if (options.priority) args.push("--priority", options.priority); + if (options.ac) args.push("--ac", options.ac); + if (options.plan) args.push("--plan", options.plan); + if (options.draft) args.push("--draft"); + if (options.parent) args.push("--parent", options.parent); + if (options.dependencies) args.push("--dep", options.dependencies); + + const result = await $`bun ${args}`.cwd(testDir).quiet().nothrow(); + + // Extract task ID from stdout + const match = result.stdout.toString().match(/Created (?:task|draft) (task-\d+)/); + const taskId = match ? match[1] : undefined; + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + taskId, + }; +} + +export interface TaskEditOptions { + taskId: string; + title?: string; + description?: string; + assignee?: string; + status?: string; + labels?: string; + priority?: string; + dependencies?: string; + notes?: string; + plan?: string; +} + +/** + * Platform-aware task editing that uses Core directly on Windows + * and CLI spawning on Unix systems + */ +export async function editTaskPlatformAware( + options: TaskEditOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + // Always use Core API for tests to avoid CLI process spawning issues + return editTaskViaCore(options, testDir); +} + +async function editTaskViaCore( + options: TaskEditOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + try { + const core = new Core(testDir); + + // Load existing task + const taskId = options.taskId.startsWith("task-") ? options.taskId : `task-${options.taskId}`; + const existingTask = await core.filesystem.loadTask(taskId); + if (!existingTask) { + return { + exitCode: 1, + stdout: "", + stderr: `Task ${taskId} not found`, + }; + } + + const updateInput: TaskUpdateInput = { + ...(options.title && { title: options.title }), + ...(options.description && { description: options.description }), + ...(options.status && { status: options.status }), + ...(options.assignee && { assignee: [options.assignee] }), + ...(options.labels && { + labels: options.labels + .split(",") + .map((label) => label.trim()) + .filter((label) => label.length > 0), + }), + ...(options.dependencies && { dependencies: normalizeDependencies(options.dependencies) }), + ...(options.priority && { priority: options.priority as TaskUpdateInput["priority"] }), + ...(options.notes && { implementationNotes: options.notes }), + ...(options.plan && { implementationPlan: options.plan }), + }; + + await core.updateTaskFromInput(taskId, updateInput, false); + return { + exitCode: 0, + stdout: `Updated task ${taskId}`, + stderr: "", + }; + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +async function _editTaskViaCLI( + options: TaskEditOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + // Build CLI arguments + const args = [CLI_PATH, "task", "edit", options.taskId]; + + if (options.title) args.push("--title", options.title); + if (options.description) args.push("--description", options.description); + if (options.assignee) args.push("--assignee", options.assignee); + if (options.status) args.push("--status", options.status); + if (options.labels) args.push("--labels", options.labels); + if (options.priority) args.push("--priority", options.priority); + if (options.dependencies) args.push("--dep", options.dependencies); + if (options.notes) args.push("--notes", options.notes); + if (options.plan) args.push("--plan", options.plan); + + const result = await $`bun ${args}`.cwd(testDir).quiet().nothrow(); + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +export interface TaskViewOptions { + taskId: string; + plain?: boolean; + useViewCommand?: boolean; +} + +/** + * Platform-aware task viewing that uses Core directly on Windows + * and CLI spawning on Unix systems + */ +export async function viewTaskPlatformAware( + options: TaskViewOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + // Always use Core API for tests to avoid CLI process spawning issues + return viewTaskViaCore(options, testDir); +} + +async function viewTaskViaCore( + options: TaskViewOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + try { + const core = new Core(testDir); + const taskId = options.taskId.startsWith("task-") ? options.taskId : `task-${options.taskId}`; + + const task = await core.filesystem.loadTask(taskId); + if (!task) { + return { + exitCode: 1, + stdout: "", + stderr: `Task ${taskId} not found`, + }; + } + + // Format output to match CLI output + let output = `Task ${taskId} - ${task.title}`; + if (options.plain) { + output += `\nStatus: ${task.status}`; + if (task.assignee?.length > 0) { + output += `\nAssignee: ${task.assignee.join(", ")}`; + } + if (task.labels?.length > 0) { + output += `\nLabels: ${task.labels.join(", ")}`; + } + if (task.dependencies?.length > 0) { + output += `\nDependencies: ${task.dependencies.join(", ")}`; + } + if (task.rawContent) { + output += `\n\n${task.rawContent}`; + } + } + + return { + exitCode: 0, + stdout: output, + stderr: "", + }; + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +async function _viewTaskViaCLI( + options: TaskViewOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const args = [CLI_PATH, "task"]; + + // Handle both "task 1" and "task view 1" formats + if (options.useViewCommand) { + args.push("view", options.taskId); + } else { + args.push(options.taskId); + } + + if (options.plain) { + args.push("--plain"); + } + + const result = await $`bun ${args}`.cwd(testDir).quiet().nothrow(); + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +/** + * Platform-aware CLI help command execution + */ +export async function getCliHelpPlatformAware( + command: string[], + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + if (isWindows) { + // On Windows, we can't easily test help output without running CLI + // Return a mock response that matches the expected behavior + return { + exitCode: 0, + stdout: `Usage: task create [options] <title> + +Options: + -d, --description <description> task description + -a, --assignee <assignee> assign to user + -s, --status <status> set task status + -l, --labels <labels> add labels (comma-separated) + --priority <priority> set task priority (high, medium, low) + --ac <criteria> acceptance criteria (comma-separated) + --plan <plan> implementation plan + --draft create as draft + -p, --parent <taskId> specify parent task ID + --dep <dependencies> task dependencies (comma-separated) + --depends-on <dependencies> task dependencies (comma-separated) + -h, --help display help for command`, + stderr: "", + }; + } + + // Test CLI integration on Unix systems + const result = await $`bun ${[CLI_PATH, ...command]}`.cwd(testDir).quiet().nothrow(); + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +export interface TaskListOptions { + plain?: boolean; + status?: string; + assignee?: string; +} + +/** + * Platform-aware task listing that uses Core directly on Windows + * and CLI spawning on Unix systems + */ +export async function listTasksPlatformAware( + options: TaskListOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + // Always use Core API for tests to avoid CLI process spawning issues + return listTasksViaCore(options, testDir); +} + +async function listTasksViaCore( + options: TaskListOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + try { + const core = new Core(testDir); + const tasks = await core.filesystem.listTasks(); + + // Filter by status if provided + let filteredTasks = tasks; + if (options.status) { + const statusFilter = options.status.toLowerCase(); + filteredTasks = tasks.filter((task) => task.status.toLowerCase() === statusFilter); + } + + // Filter by assignee if provided + if (options.assignee) { + filteredTasks = filteredTasks.filter((task) => + task.assignee.some((a) => a.toLowerCase().includes(options.assignee?.toLowerCase() ?? "")), + ); + } + + // Format output to match CLI output + if (options.plain) { + if (filteredTasks.length === 0) { + return { + exitCode: 0, + stdout: "No tasks found", + stderr: "", + }; + } + + // Group by status + const tasksByStatus = new Map<string, typeof filteredTasks>(); + for (const task of filteredTasks) { + const status = task.status || "No Status"; + const existing = tasksByStatus.get(status) || []; + existing.push(task); + tasksByStatus.set(status, existing); + } + + let output = ""; + for (const [status, statusTasks] of tasksByStatus) { + output += `${status}:\n`; + for (const task of statusTasks) { + output += `${task.id} - ${task.title}\n`; + } + output += "\n"; + } + + return { + exitCode: 0, + stdout: output.trim(), + stderr: "", + }; + } + + // Non-plain output (basic format) + let output = ""; + for (const task of filteredTasks) { + output += `${task.id} - ${task.title}\n`; + } + + return { + exitCode: 0, + stdout: output, + stderr: "", + }; + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +async function _listTasksViaCLI( + options: TaskListOptions, + testDir: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const args = [CLI_PATH, "task", "list"]; + + if (options.plain) { + args.push("--plain"); + } + + if (options.status) { + args.push("-s", options.status); + } + + if (options.assignee) { + args.push("-a", options.assignee); + } + + const result = await $`bun ${args}`.cwd(testDir).quiet().nothrow(); + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +export { isWindows }; diff --git a/src/test/test-utils.ts b/src/test/test-utils.ts new file mode 100644 index 0000000..cd742c9 --- /dev/null +++ b/src/test/test-utils.ts @@ -0,0 +1,84 @@ +/** + * Test utilities for creating isolated test environments + * Designed to handle Windows-specific file system quirks and prevent parallel test interference + */ + +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Creates a unique test directory name to avoid conflicts in parallel execution + * All test directories are created under tmp/ to keep the root directory clean + */ +export function createUniqueTestDir(prefix: string): string { + const uuid = randomUUID().slice(0, 8); // Short UUID for readability + const timestamp = Date.now().toString(36); // Base36 timestamp + const pid = process.pid.toString(36); // Process ID for additional uniqueness + return join(process.cwd(), "tmp", `${prefix}-${timestamp}-${pid}-${uuid}`); +} + +/** + * Sleep utility for tests that need to wait + */ +export function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retry utility for operations that might fail intermittently + * Particularly useful for Windows file operations + */ +export async function retry<T>(fn: () => Promise<T>, maxAttempts = 3, delay = 100): Promise<T> { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + if (attempt < maxAttempts) { + await sleep(delay * attempt); // Exponential backoff + } + } + } + + throw lastError || new Error("Retry failed"); +} + +/** + * Windows-safe directory cleanup with retry logic + * Windows can have file locking issues that prevent immediate deletion + */ +export async function safeCleanup(dir: string): Promise<void> { + await retry( + async () => { + await rm(dir, { recursive: true, force: true }); + }, + 5, + 50, + ); // More attempts for cleanup +} + +/** + * Detects if we're running on Windows (useful for conditional test behavior) + */ +export function isWindows(): boolean { + return process.platform === "win32"; +} + +/** + * Gets appropriate timeout for the current platform + * Windows operations tend to be slower due to file system overhead + */ +export function getPlatformTimeout(baseTimeout = 5000): number { + return isWindows() ? baseTimeout * 2 : baseTimeout; +} + +/** + * Gets the exit code from a spawnSync result, handling Windows quirks + * On Windows, result.status can be undefined even for successful processes + */ +export function getExitCode(result: { status: number | null; error?: Error }): number { + return result.status ?? (result.error ? 1 : 0); +} diff --git a/src/test/unicode-rendering.test.ts b/src/test/unicode-rendering.test.ts new file mode 100644 index 0000000..40395ed --- /dev/null +++ b/src/test/unicode-rendering.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { box } from "neo-neo-bblessed"; +import { createScreen } from "../ui/tui.ts"; + +describe("Unicode rendering", () => { + test("Chinese characters display without replacement", () => { + const screen = createScreen({ smartCSR: false }); + const content = "ζ΅‹θ―•δΈ­ζ–‡"; + const b = box({ parent: screen, content }); + screen.render(); + const rendered = String(b.content).replaceAll("\u0003", ""); + expect(rendered).toBe(content); + screen.destroy(); + }); +}); diff --git a/src/test/unified-view-loading.test.ts b/src/test/unified-view-loading.test.ts new file mode 100644 index 0000000..0e8b57f --- /dev/null +++ b/src/test/unified-view-loading.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { Core } from "../core/backlog.ts"; +import { loadTasksForUnifiedView } from "../ui/unified-view.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +describe("loadTasksForUnifiedView", () => { + let testDir: string; + let core: Core; + + beforeEach(() => { + testDir = createUniqueTestDir("unified-view-load"); + core = new Core(testDir); + }); + + afterEach(async () => { + try { + await safeCleanup(testDir); + } catch { + // Ignore cleanup failures in tests + } + }); + + it("uses provided loader progress and closes the loading screen", async () => { + const updates: string[] = []; + let closed = false; + + const result = await loadTasksForUnifiedView(core, { + tasksLoader: async (updateProgress) => { + updateProgress("step one"); + return { tasks: [], statuses: ["To Do", "In Progress"] }; + }, + loadingScreenFactory: async () => ({ + update: (msg: string) => { + updates.push(msg); + }, + close: async () => { + closed = true; + }, + }), + }); + + expect(updates).toContain("step one"); + expect(closed).toBe(true); + expect(result.statuses).toEqual(["To Do", "In Progress"]); + }); +}); diff --git a/src/test/update-task-description.test.ts b/src/test/update-task-description.test.ts new file mode 100644 index 0000000..827a479 --- /dev/null +++ b/src/test/update-task-description.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; +import { updateTaskDescription } from "../markdown/serializer.ts"; +import { extractStructuredSection } from "../markdown/structured-sections.ts"; + +describe("updateTaskDescription", () => { + it("should replace existing description section", () => { + const content = `--- +id: task-1 +title: Test task +--- + +## Description + +Old description + +## Acceptance Criteria + +- [ ] Test criterion + +## Implementation Plan + +Test plan`; + + const result = updateTaskDescription(content, "New description"); + + expect(result).toContain("<!-- SECTION:DESCRIPTION:BEGIN -->"); + expect(extractStructuredSection(result, "description")).toBe("New description"); + expect(extractStructuredSection(result, "implementationPlan")).toBe("Test plan"); + expect(result).not.toContain("Old description"); + }); + + it("should add description section if none exists and preserve other sections", () => { + const content = `--- +id: task-1 +title: Test task +--- + +## Acceptance Criteria + +- [ ] Test criterion`; + + const result = updateTaskDescription(content, "New description"); + + expect(extractStructuredSection(result, "description")).toBe("New description"); + expect(result).toContain("## Acceptance Criteria"); + // Description should come before acceptance criteria + expect(result.indexOf("## Description")).toBeLessThan(result.indexOf("## Acceptance Criteria")); + }); + + it("should handle content without frontmatter and preserve other sections", () => { + const content = `## Acceptance Criteria + +- [ ] Test criterion`; + + const result = updateTaskDescription(content, "New description"); + + expect(extractStructuredSection(result, "description")).toBe("New description"); + expect(result).toContain("## Acceptance Criteria"); + // Description should come first + expect(result.indexOf("## Description")).toBeLessThan(result.indexOf("## Acceptance Criteria")); + }); + + it("should handle empty content", () => { + const content = `--- +id: task-1 +title: Test task +--- + +`; + + const result = updateTaskDescription(content, "New description"); + + expect(extractStructuredSection(result, "description")).toBe("New description"); + }); + + it("should preserve complex sections", () => { + const content = `--- +id: task-1 +title: Test task +--- + +## Description + +Old description + +## Acceptance Criteria + +- [x] Completed criterion +- [ ] Pending criterion + +## Implementation Plan + +1. Step one +2. Step two + +## Implementation Notes + +These are notes with **bold** and *italic* text. + +### Subsection + +More detailed notes.`; + + const result = updateTaskDescription(content, "Updated description"); + + expect(extractStructuredSection(result, "description")).toBe("Updated description"); + expect(result).toContain("- [x] Completed criterion"); + expect(result).toContain("- [ ] Pending criterion"); + expect(extractStructuredSection(result, "implementationPlan")).toContain("1. Step one"); + expect(extractStructuredSection(result, "implementationPlan")).toContain("2. Step two"); + expect(extractStructuredSection(result, "implementationNotes")).toContain("**bold** and *italic*"); + expect(extractStructuredSection(result, "implementationNotes")).toContain("### Subsection"); + expect(result).not.toContain("Old description"); + }); +}); diff --git a/src/test/view-switcher.test.ts b/src/test/view-switcher.test.ts new file mode 100644 index 0000000..404f65d --- /dev/null +++ b/src/test/view-switcher.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { type ViewState, ViewSwitcher } from "../ui/view-switcher.ts"; +import { createUniqueTestDir, safeCleanup } from "./test-utils.ts"; + +describe("View Switcher", () => { + let TEST_DIR: string; + let core: Core; + + beforeEach(async () => { + TEST_DIR = createUniqueTestDir("test-view-switcher"); + await rm(TEST_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(TEST_DIR, { recursive: true }); + + // Configure git for tests - required for CI + await $`git init`.cwd(TEST_DIR).quiet(); + await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet(); + await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet(); + + core = new Core(TEST_DIR); + await core.initializeProject("Test View Switcher Project"); + + // Disable remote operations for tests to prevent background git fetches + const config = await core.filesystem.loadConfig(); + if (config) { + config.remoteOperations = false; + await core.filesystem.saveConfig(config); + } + }); + + afterEach(async () => { + try { + await safeCleanup(TEST_DIR); + } catch { + // Ignore cleanup errors - the unique directory names prevent conflicts + } + }); + + describe("ViewSwitcher initialization", () => { + it("should initialize with task-list view", () => { + const initialState: ViewState = { + type: "task-list", + tasks: [], + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + }); + + const state = switcher.getState(); + expect(state.type).toBe("task-list"); + expect(state.tasks).toEqual([]); + }); + + it("should initialize with task-detail view", () => { + const selectedTask = { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-05", + labels: [], + dependencies: [], + rawContent: "Test task body", + }; + + const initialState: ViewState = { + type: "task-detail", + selectedTask, + tasks: [selectedTask], + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + }); + + const state = switcher.getState(); + expect(state.type).toBe("task-detail"); + expect(state.selectedTask).toEqual(selectedTask); + }); + + it("should initialize with kanban view", () => { + const initialState: ViewState = { + type: "kanban", + kanbanData: { + tasks: [], + statuses: [], + isLoading: true, + }, + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + }); + + const state = switcher.getState(); + expect(state.type).toBe("kanban"); + expect(state.kanbanData?.isLoading).toBe(true); + }); + }); + + describe("State updates", () => { + it("should update state correctly", () => { + const initialState: ViewState = { + type: "task-list", + tasks: [], + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + }); + + const newTask = { + id: "task-1", + title: "Updated Task", + status: "In Progress", + assignee: [], + createdDate: "2025-07-05", + labels: [], + dependencies: [], + rawContent: "Updated task body", + }; + + const updatedState = switcher.updateState({ + selectedTask: newTask, + type: "task-detail", + }); + + expect(updatedState.type).toBe("task-detail"); + expect(updatedState.selectedTask).toEqual(newTask); + }); + }); + + describe("Background loading", () => { + it("should indicate when kanban data is ready", () => { + const initialState: ViewState = { + type: "task-list", + tasks: [], + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + }); + + // Initially should not be ready (no data loaded yet) + expect(switcher.isKanbanReady()).toBe(false); + }); + + it("should start preloading kanban data", () => { + const initialState: ViewState = { + type: "task-list", + tasks: [], + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + }); + + // Mock the preloadKanban method to avoid remote git operations + switcher.preloadKanban = async () => {}; + + // Should not throw when preloading + expect(() => switcher.preloadKanban()).not.toThrow(); + }); + }); + + describe("View change callback", () => { + it("should call onViewChange when state updates", () => { + let callbackState: ViewState | null = null; + + const initialState: ViewState = { + type: "task-list", + tasks: [], + }; + + const switcher = new ViewSwitcher({ + core, + initialState, + onViewChange: (newState) => { + callbackState = newState; + }, + }); + + const newTask = { + id: "task-1", + title: "Test Task", + status: "To Do", + assignee: [], + createdDate: "2025-07-05", + labels: [], + dependencies: [], + rawContent: "Test task body", + }; + + switcher.updateState({ + selectedTask: newTask, + type: "task-detail", + }); + + expect(callbackState).toBeTruthy(); + if (!callbackState) { + throw new Error("callbackState should not be null"); + } + const state = callbackState as unknown as ViewState; + expect(state.type).toBe("task-detail"); + expect(state.selectedTask).toEqual(newTask); + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..1edfa2a --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,229 @@ +export type TaskStatus = string; + +// Structured Acceptance Criterion (domain-level) +export interface AcceptanceCriterion { + index: number; // 1-based + text: string; + checked: boolean; +} + +export interface AcceptanceCriterionInput { + text: string; + checked?: boolean; +} + +export interface Task { + id: string; + title: string; + status: TaskStatus; + assignee: string[]; + reporter?: string; + createdDate: string; + updatedDate?: string; + labels: string[]; + milestone?: string; + dependencies: string[]; + readonly rawContent?: string; // Raw markdown content without frontmatter (read-only: do not modify directly) + description?: string; + implementationPlan?: string; + implementationNotes?: string; + /** Structured acceptance criteria parsed from body (checked state + text + index) */ + acceptanceCriteriaItems?: AcceptanceCriterion[]; + parentTaskId?: string; + subtasks?: string[]; + priority?: "high" | "medium" | "low"; + branch?: string; + ordinal?: number; + filePath?: string; + // Metadata fields + lastModified?: Date; + source?: "local" | "remote" | "completed" | "local-branch"; + /** Optional per-task callback command to run on status change (overrides global config) */ + onStatusChange?: string; +} + +/** + * Check if a task is locally editable (not from a remote or other local branch) + */ +export function isLocalEditableTask(task: Task): boolean { + return task.source === undefined || task.source === "local" || task.source === "completed"; +} + +export interface TaskCreateInput { + title: string; + description?: string; + status?: TaskStatus; + priority?: "high" | "medium" | "low"; + labels?: string[]; + assignee?: string[]; + dependencies?: string[]; + parentTaskId?: string; + implementationPlan?: string; + implementationNotes?: string; + acceptanceCriteria?: AcceptanceCriterionInput[]; + rawContent?: string; +} + +export interface TaskUpdateInput { + title?: string; + description?: string; + status?: TaskStatus; + priority?: "high" | "medium" | "low"; + labels?: string[]; + addLabels?: string[]; + removeLabels?: string[]; + assignee?: string[]; + ordinal?: number; + dependencies?: string[]; + addDependencies?: string[]; + removeDependencies?: string[]; + implementationPlan?: string; + appendImplementationPlan?: string[]; + clearImplementationPlan?: boolean; + implementationNotes?: string; + appendImplementationNotes?: string[]; + clearImplementationNotes?: boolean; + acceptanceCriteria?: AcceptanceCriterionInput[]; + addAcceptanceCriteria?: Array<AcceptanceCriterionInput | string>; + removeAcceptanceCriteria?: number[]; + checkAcceptanceCriteria?: number[]; + uncheckAcceptanceCriteria?: number[]; + rawContent?: string; +} + +export interface TaskListFilter { + status?: string; + assignee?: string; + priority?: "high" | "medium" | "low"; + parentTaskId?: string; +} + +export interface Decision { + id: string; + title: string; + date: string; + status: "proposed" | "accepted" | "rejected" | "superseded"; + context: string; + decision: string; + consequences: string; + alternatives?: string; + readonly rawContent: string; // Raw markdown content without frontmatter +} + +export interface Document { + id: string; + title: string; + type: "readme" | "guide" | "specification" | "other"; + createdDate: string; + updatedDate?: string; + rawContent: string; // Raw markdown content without frontmatter + tags?: string[]; + // Web UI specific fields + name?: string; + path?: string; + lastModified?: string; +} + +export type SearchResultType = "task" | "document" | "decision"; + +export type SearchPriorityFilter = "high" | "medium" | "low"; + +export interface SearchMatch { + key?: string; + indices: Array<[number, number]>; + value?: unknown; +} + +export interface SearchFilters { + status?: string | string[]; + priority?: SearchPriorityFilter | SearchPriorityFilter[]; + assignee?: string | string[]; + labels?: string | string[]; +} + +export interface SearchOptions { + query?: string; + limit?: number; + types?: SearchResultType[]; + filters?: SearchFilters; +} + +export interface TaskSearchResult { + type: "task"; + score: number | null; + task: Task; + matches?: SearchMatch[]; +} + +export interface DocumentSearchResult { + type: "document"; + score: number | null; + document: Document; + matches?: SearchMatch[]; +} + +export interface DecisionSearchResult { + type: "decision"; + score: number | null; + decision: Decision; + matches?: SearchMatch[]; +} + +export type SearchResult = TaskSearchResult | DocumentSearchResult | DecisionSearchResult; + +export interface Sequence { + /** 1-based sequence index */ + index: number; + /** Tasks that can be executed in parallel within this sequence */ + tasks: Task[]; +} + +export interface BacklogConfig { + projectName: string; + defaultAssignee?: string; + defaultReporter?: string; + statuses: string[]; + labels: string[]; + milestones: string[]; + defaultStatus?: string; + dateFormat: string; + maxColumnWidth?: number; + taskResolutionStrategy?: "most_recent" | "most_progressed"; + defaultEditor?: string; + autoOpenBrowser?: boolean; + defaultPort?: number; + remoteOperations?: boolean; + autoCommit?: boolean; + zeroPaddedIds?: number; + timezonePreference?: string; // e.g., 'UTC', 'America/New_York', or 'local' + includeDateTimeInDates?: boolean; // Whether to include time in new dates + bypassGitHooks?: boolean; + checkActiveBranches?: boolean; // Check task states across active branches (default: true) + activeBranchDays?: number; // How many days a branch is considered active (default: 30) + /** Global callback command to run on any task status change. Supports $TASK_ID, $OLD_STATUS, $NEW_STATUS, $TASK_TITLE variables. */ + onStatusChange?: string; + mcp?: { + http?: { + host?: string; + port?: number; + auth?: { + type?: "bearer" | "basic" | "none"; + token?: string; + username?: string; + password?: string; + }; + cors?: { + origin?: string | string[]; + credentials?: boolean; + }; + enableDnsRebindingProtection?: boolean; + allowedHosts?: string[]; + allowedOrigins?: string[]; + }; + }; +} + +export interface ParsedMarkdown { + frontmatter: Record<string, unknown>; + content: string; +} diff --git a/src/types/markdown.d.ts b/src/types/markdown.d.ts new file mode 100644 index 0000000..a47bd62 --- /dev/null +++ b/src/types/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/src/types/neo-neo-bblessed.d.ts b/src/types/neo-neo-bblessed.d.ts new file mode 100644 index 0000000..e2f94c3 --- /dev/null +++ b/src/types/neo-neo-bblessed.d.ts @@ -0,0 +1,90 @@ +declare module "neo-neo-bblessed" { + export interface ProgramInterface { + pause?: () => (() => void) | undefined; + } + + export interface ScreenOptions { + smartCSR?: boolean; + program?: ProgramInterface; + title?: string; + [key: string]: unknown; + } + + export interface ScreenInterface { + program?: ProgramInterface; + key(keys: string | string[], callback: (...args: unknown[]) => void): void; + on(event: string, callback: (...args: unknown[]) => void): void; + append(el: ElementInterface): void; + render(): void; + destroy(): void; + clearRegion(x1: number, x2: number, y1: number, y2: number): void; + width: number; + height: number; + emit(event: string): void; + title?: string; + } + + export interface ElementInterface { + setContent(content: string): void; + focus(): void; + key(keys: string | string[], callback: (...args: unknown[]) => void): void; + on( + event: string, + callback: + | ((ch: string, key: { name: string; ctrl?: boolean; meta?: boolean }) => void) + | ((...args: unknown[]) => void), + ): void; + destroy(): void; + setFront?: () => void; + setScrollPerc?: (value: number) => void; + getLines: () => string[]; + width?: number | string; + height?: number | string; + top?: number | string; + left?: number | string; + bottom?: number | string; + right?: number | string; + options: { wrap?: boolean }; + style?: unknown; + [key: string]: unknown; + } + + export interface BoxInterface extends ElementInterface { + setLabel?(label: string): void; + } + export interface LineInterface extends ElementInterface {} + export interface ListInterface extends ElementInterface { + setItems(items: string[]): void; + select(i: number): void; + selected?: number; + items: Array<unknown>; + style: { selected?: { bg?: string } } & Record<string, unknown>; + } + export interface ScrollableTextInterface extends ElementInterface {} + export interface ScrollableBoxInterface extends BoxInterface { + getScroll(): number; + scrollTo(index: number): void; + } + export interface LogInterface extends ElementInterface { + log(message: string): void; + } + export interface TextboxInterface extends ElementInterface { + value?: string; + getValue(): string; + setValue(value: string): void; + clearValue(): void; + submit(): void; + cancel(): void; + readInput(callback?: (error?: Error, value?: string) => void): void; + } + + export function screen(options?: ScreenOptions): ScreenInterface; + export function program(options?: Record<string, unknown>): ProgramInterface; + export function box(options?: Record<string, unknown>): BoxInterface; + export function line(options?: Record<string, unknown>): LineInterface; + export function list(options?: Record<string, unknown>): ListInterface; + export function scrollablebox(options?: Record<string, unknown>): ScrollableBoxInterface; + export function scrollabletext(options?: Record<string, unknown>): ScrollableTextInterface; + export function log(options?: Record<string, unknown>): LogInterface; + export function textbox(options?: Record<string, unknown>): TextboxInterface; +} diff --git a/src/types/raw.d.ts b/src/types/raw.d.ts new file mode 100644 index 0000000..c199f08 --- /dev/null +++ b/src/types/raw.d.ts @@ -0,0 +1,4 @@ +declare module "*.md?raw" { + const content: string; + export default content; +} diff --git a/src/types/task-edit-args.ts b/src/types/task-edit-args.ts new file mode 100644 index 0000000..602c3a2 --- /dev/null +++ b/src/types/task-edit-args.ts @@ -0,0 +1,27 @@ +export interface TaskEditArgs { + title?: string; + description?: string; + status?: string; + priority?: "high" | "medium" | "low"; + labels?: string[]; + addLabels?: string[]; + removeLabels?: string[]; + assignee?: string[]; + ordinal?: number; + dependencies?: string[]; + implementationPlan?: string; + planSet?: string; + planAppend?: string[]; + planClear?: boolean; + implementationNotes?: string; + notesSet?: string; + notesAppend?: string[]; + notesClear?: boolean; + acceptanceCriteriaSet?: string[]; + acceptanceCriteriaAdd?: string[]; + acceptanceCriteriaRemove?: number[]; + acceptanceCriteriaCheck?: number[]; + acceptanceCriteriaUncheck?: number[]; +} + +export type TaskEditRequest = TaskEditArgs & { id: string }; diff --git a/src/ui/board.ts b/src/ui/board.ts new file mode 100644 index 0000000..ace9cbb --- /dev/null +++ b/src/ui/board.ts @@ -0,0 +1,758 @@ +import type { BoxInterface, ListInterface } from "neo-neo-bblessed"; +import { box, list } from "neo-neo-bblessed"; +import { type BoardLayout, buildKanbanStatusGroups, generateKanbanBoardWithMetadata } from "../board.ts"; +import { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; +import { getTaskPath } from "../utils/task-path.ts"; +import { compareTaskIds } from "../utils/task-sorting.ts"; +import { getStatusIcon } from "./status-icon.ts"; +import { createTaskPopup } from "./task-viewer-with-search.ts"; +import { createScreen } from "./tui.ts"; + +export type ColumnData = { + status: string; + tasks: Task[]; +}; + +type ColumnView = { + status: string; + tasks: Task[]; + list: ListInterface; + box: BoxInterface; +}; + +function isDoneStatus(status: string): boolean { + const normalized = status.trim().toLowerCase(); + return normalized === "done" || normalized === "completed" || normalized === "complete"; +} + +function buildColumnTasks(status: string, items: Task[], byId: Map<string, Task>): Task[] { + const topLevel: Task[] = []; + const childrenByParent = new Map<string, Task[]>(); + const sorted = items.slice().sort((a, b) => { + // Use ordinal for custom sorting if available + const aOrd = a.ordinal; + const bOrd = b.ordinal; + + // If both have ordinals, compare them + if (typeof aOrd === "number" && typeof bOrd === "number") { + if (aOrd !== bOrd) return aOrd - bOrd; + } else if (typeof aOrd === "number") { + // Only A has ordinal -> A comes first + return -1; + } else if (typeof bOrd === "number") { + // Only B has ordinal -> B comes first + return 1; + } + + const columnIsDone = isDoneStatus(status); + if (columnIsDone) { + return compareTaskIds(b.id, a.id); + } + + return compareTaskIds(a.id, b.id); + }); + + for (const task of sorted) { + const parent = task.parentTaskId ? byId.get(task.parentTaskId) : undefined; + if (parent && parent.status === task.status) { + const existing = childrenByParent.get(parent.id) ?? []; + existing.push(task); + childrenByParent.set(parent.id, existing); + continue; + } + topLevel.push(task); + } + + const ordered: Task[] = []; + for (const task of topLevel) { + ordered.push(task); + const subs = childrenByParent.get(task.id) ?? []; + subs.sort((a, b) => compareTaskIds(a.id, b.id)); + ordered.push(...subs); + } + + return ordered; +} + +function prepareBoardColumns(tasks: Task[], statuses: string[]): ColumnData[] { + const { orderedStatuses, groupedTasks } = buildKanbanStatusGroups(tasks, statuses); + const byId = new Map<string, Task>(tasks.map((task) => [task.id, task])); + + return orderedStatuses.map((status) => { + const items = groupedTasks.get(status) ?? []; + const orderedTasks = buildColumnTasks(status, items, byId); + return { status, tasks: orderedTasks }; + }); +} + +function formatTaskListItem(task: Task, isMoving = false): string { + const assignee = task.assignee?.[0] + ? ` {cyan-fg}${task.assignee[0].startsWith("@") ? task.assignee[0] : `@${task.assignee[0]}`}{/}` + : ""; + const labels = task.labels?.length ? ` {yellow-fg}[${task.labels.join(", ")}]{/}` : ""; + const isCrossBranch = Boolean((task as Task & { branch?: string }).branch); + const branch = isCrossBranch ? ` {green-fg}(${(task as Task & { branch?: string }).branch}){/}` : ""; + + // Cross-branch tasks are dimmed to indicate read-only status + const content = `{bold}${task.id}{/bold} - ${task.title}${assignee}${labels}${branch}`; + if (isMoving) { + return `{magenta-fg}β–Ί ${content}{/}`; + } + if (isCrossBranch) { + return `{gray-fg}${content}{/}`; + } + return content; +} + +function formatColumnLabel(status: string, count: number): string { + return `\u00A0${getStatusIcon(status)} ${status || "No Status"} (${count})\u00A0`; +} + +function _arraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +export function shouldRebuildColumns(current: ColumnData[], next: ColumnData[]): boolean { + if (current.length !== next.length) { + return true; + } + for (let index = 0; index < next.length; index += 1) { + const nextColumn = next[index]; + if (!nextColumn) return true; + const prevColumn = current[index]; + if (!prevColumn) return true; + if (prevColumn.status !== nextColumn.status) return true; + if (prevColumn.tasks.length !== nextColumn.tasks.length) return true; + for (let taskIdx = 0; taskIdx < nextColumn.tasks.length; taskIdx += 1) { + const prevTask = prevColumn.tasks[taskIdx]; + const nextTask = nextColumn.tasks[taskIdx]; + if (!prevTask || !nextTask) { + return true; + } + if (prevTask.id !== nextTask.id) { + return true; + } + } + } + return false; +} + +/** + * Render tasks in an interactive TUI when stdout is a TTY. + * Falls back to plain-text board when not in a terminal + * (e.g. piping output to a file or running in CI). + */ +export async function renderBoardTui( + initialTasks: Task[], + statuses: string[], + _layout: BoardLayout, + _maxColumnWidth: number, + options?: { + viewSwitcher?: import("./view-switcher.ts").ViewSwitcher; + onTaskSelect?: (task: Task) => void; + onTabPress?: () => Promise<void>; + subscribeUpdates?: (update: (nextTasks: Task[], nextStatuses: string[]) => void) => void; + }, +): Promise<void> { + if (!process.stdout.isTTY) { + console.log(generateKanbanBoardWithMetadata(initialTasks, statuses, "Project")); + return; + } + + const initialColumns = prepareBoardColumns(initialTasks, statuses); + if (initialColumns.length === 0) { + console.log("No tasks available for the Kanban board."); + return; + } + + await new Promise<void>((resolve) => { + const screen = createScreen({ title: "Backlog Board" }); + const container = box({ + parent: screen, + width: "100%", + height: "100%", + }); + + let currentTasks = initialTasks; + let columns: ColumnView[] = []; + let currentColumnsData = initialColumns; + let currentStatuses = currentColumnsData.map((column) => column.status); + let currentCol = 0; + let popupOpen = false; + + // Move mode state + type MoveOperation = { + taskId: string; + originalStatus: string; + originalIndex: number; + targetStatus: string; + targetIndex: number; + }; + let moveOp: MoveOperation | null = null; + + const footerBox = box({ + parent: screen, + bottom: 0, + left: 0, + height: 1, + width: "100%", + tags: true, + content: + " {cyan-fg}[Tab]{/} Switch View | {cyan-fg}[←→]{/} Columns | {cyan-fg}[↑↓]{/} Tasks | {cyan-fg}[Enter]{/} View | {cyan-fg}[E]{/} Edit | {cyan-fg}[M]{/} Move | {cyan-fg}[q/Esc]{/} Quit", + }); + + const clearColumns = () => { + for (const column of columns) { + column.box.destroy(); + } + columns = []; + }; + + const columnWidthFor = (count: number) => Math.max(1, Math.floor(100 / Math.max(1, count))); + + const getFormattedItems = (tasks: Task[]) => { + return tasks.map((task) => formatTaskListItem(task, moveOp?.taskId === task.id)); + }; + + const createColumnViews = (data: ColumnData[]) => { + clearColumns(); + const widthPercent = columnWidthFor(data.length); + data.forEach((columnData, idx) => { + const left = idx * widthPercent; + const isLast = idx === data.length - 1; + const width = isLast ? `${Math.max(0, 100 - left)}%` : `${widthPercent}%`; + const columnBox = box({ + parent: container, + left: `${left}%`, + top: 0, + width, + height: "100%-1", + border: { type: "line" }, + style: { border: { fg: "gray" } }, + label: formatColumnLabel(columnData.status, columnData.tasks.length), + }); + + const taskList = list({ + parent: columnBox, + top: 1, + left: 1, + width: "100%-4", + height: "100%-3", + keys: false, + mouse: true, + scrollable: true, + tags: true, + style: { selected: { fg: "white" } }, + }); + + taskList.setItems(getFormattedItems(columnData.tasks)); + columns.push({ status: columnData.status, tasks: columnData.tasks, list: taskList, box: columnBox }); + }); + }; + + const setColumnActiveState = (column: ColumnView | undefined, active: boolean) => { + if (!column) return; + const listStyle = column.list.style as { selected?: { bg?: string } }; + // In move mode, use green highlight for the moving task + if (listStyle.selected) listStyle.selected.bg = moveOp && active ? "green" : active ? "blue" : undefined; + const boxStyle = column.box.style as { border?: { fg?: string } }; + if (boxStyle.border) boxStyle.border.fg = active ? "yellow" : "gray"; + }; + + const getSelectedTaskId = (): string | undefined => { + const column = columns[currentCol]; + if (!column) return undefined; + const selectedIndex = column.list.selected ?? 0; + return column.tasks[selectedIndex]?.id; + }; + + const focusColumn = (idx: number, preferredRow?: number) => { + if (popupOpen) return; + if (idx < 0 || idx >= columns.length) return; + const previous = columns[currentCol]; + setColumnActiveState(previous, false); + + currentCol = idx; + const current = columns[currentCol]; + if (!current) return; + + const total = current.tasks.length; + if (total > 0) { + const previousSelected = typeof previous?.list.selected === "number" ? previous.list.selected : 0; + const target = preferredRow !== undefined ? preferredRow : Math.min(previousSelected, total - 1); + current.list.select(Math.max(0, target)); + } + + current.list.focus(); + setColumnActiveState(current, true); + screen.render(); + }; + + const restoreSelection = (taskId?: string) => { + if (columns.length === 0) return; + if (taskId) { + for (let colIdx = 0; colIdx < columns.length; colIdx += 1) { + const column = columns[colIdx]; + if (!column) continue; + const taskIndex = column.tasks.findIndex((task) => task.id === taskId); + if (taskIndex !== -1) { + focusColumn(colIdx, taskIndex); + return; + } + } + } + const safeIndex = Math.min(columns.length - 1, Math.max(0, currentCol)); + focusColumn(safeIndex); + }; + + const applyColumnData = (data: ColumnData[], selectedTaskId?: string) => { + currentColumnsData = data; + data.forEach((columnData, idx) => { + const column = columns[idx]; + if (!column) return; + column.status = columnData.status; + column.tasks = columnData.tasks; + column.list.setItems(getFormattedItems(columnData.tasks)); + column.box.setLabel?.(formatColumnLabel(columnData.status, columnData.tasks.length)); + }); + restoreSelection(selectedTaskId); + }; + + const rebuildColumns = (data: ColumnData[], selectedTaskId?: string) => { + currentColumnsData = data; + currentStatuses = data.map((column) => column.status); + createColumnViews(data); + restoreSelection(selectedTaskId); + }; + + // Pure function to calculate the projected board state + const getProjectedColumns = (allTasks: Task[], operation: MoveOperation | null): ColumnData[] => { + if (!operation) { + return prepareBoardColumns(allTasks, currentStatuses); + } + + // 1. Filter out the moving task from the source + const tasksWithoutMoving = allTasks.filter((t) => t.id !== operation.taskId); + const movingTask = allTasks.find((t) => t.id === operation.taskId); + + if (!movingTask) { + return prepareBoardColumns(allTasks, currentStatuses); + } + + // 2. Prepare columns without the moving task + const columns = prepareBoardColumns(tasksWithoutMoving, currentStatuses); + + // 3. Insert the moving task into the target column at the target index + const targetColumn = columns.find((c) => c.status === operation.targetStatus); + if (targetColumn) { + // Create a "ghost" task with updated status + const ghostTask = { ...movingTask, status: operation.targetStatus }; + + // Clamp index to valid bounds + const safeIndex = Math.max(0, Math.min(operation.targetIndex, targetColumn.tasks.length)); + targetColumn.tasks.splice(safeIndex, 0, ghostTask); + } + + return columns; + }; + + const updateFooter = () => { + if (moveOp) { + footerBox.setContent( + " {green-fg}MOVE MODE{/} | {cyan-fg}[←→]{/} Change Column | {cyan-fg}[↑↓]{/} Reorder | {cyan-fg}[Enter/M]{/} Confirm | {cyan-fg}[Esc]{/} Cancel", + ); + } else { + footerBox.setContent( + " {cyan-fg}[Tab]{/} Switch View | {cyan-fg}[←→]{/} Columns | {cyan-fg}[↑↓]{/} Tasks | {cyan-fg}[Enter]{/} View | {cyan-fg}[E]{/} Edit | {cyan-fg}[M]{/} Move | {cyan-fg}[q/Esc]{/} Quit", + ); + } + }; + + const renderView = () => { + const projectedData = getProjectedColumns(currentTasks, moveOp); + + // If we are moving, we want to select the moving task + const selectedId = moveOp ? moveOp.taskId : getSelectedTaskId(); + + if (projectedData.length === 0) { + const fallbackStatus = currentStatuses[0] ?? "No Status"; + rebuildColumns([{ status: fallbackStatus, tasks: [] }], selectedId); + } else if (shouldRebuildColumns(currentColumnsData, projectedData)) { + rebuildColumns(projectedData, selectedId); + } else { + applyColumnData(projectedData, selectedId); + } + + updateFooter(); + screen.render(); + }; + + rebuildColumns(initialColumns); + const firstColumn = columns[0]; + if (firstColumn) { + currentCol = 0; + setColumnActiveState(firstColumn, true); + if (firstColumn.tasks.length > 0) { + firstColumn.list.select(0); + } + firstColumn.list.focus(); + } + + const updateBoard = (nextTasks: Task[], nextStatuses: string[]) => { + // Update source of truth + currentTasks = nextTasks; + // Only update statuses if they changed (rare in TUI) + if (nextStatuses.length > 0) currentStatuses = nextStatuses; + + renderView(); + }; + + options?.subscribeUpdates?.(updateBoard); + + // Helper to get target column size (excluding the moving task if it's currently there) + const getTargetColumnSize = (status: string): number => { + const columnData = currentColumnsData.find((c) => c.status === status); + if (!columnData) return 0; + // If the moving task is currently in this column, we need to account for it + if (moveOp && moveOp.targetStatus === status) { + // The task is already "in" this column in the projected view + return columnData.tasks.length; + } + // Otherwise, the task will be added to this column + return columnData.tasks.length; + }; + + screen.key(["left", "h"], () => { + if (moveOp) { + const currentStatusIndex = currentStatuses.indexOf(moveOp.targetStatus); + if (currentStatusIndex > 0) { + const prevStatus = currentStatuses[currentStatusIndex - 1]; + if (prevStatus) { + const prevColumnSize = getTargetColumnSize(prevStatus); + moveOp.targetStatus = prevStatus; + // Clamp index to valid range for new column (0 to size, where size means append at end) + moveOp.targetIndex = Math.min(moveOp.targetIndex, prevColumnSize); + renderView(); + } + } + } else { + focusColumn(currentCol - 1); + } + }); + + screen.key(["right", "l"], () => { + if (moveOp) { + const currentStatusIndex = currentStatuses.indexOf(moveOp.targetStatus); + if (currentStatusIndex < currentStatuses.length - 1) { + const nextStatus = currentStatuses[currentStatusIndex + 1]; + if (nextStatus) { + const nextColumnSize = getTargetColumnSize(nextStatus); + moveOp.targetStatus = nextStatus; + // Clamp index to valid range for new column + moveOp.targetIndex = Math.min(moveOp.targetIndex, nextColumnSize); + renderView(); + } + } + } else { + focusColumn(currentCol + 1); + } + }); + + screen.key(["up", "k"], () => { + if (popupOpen) return; + + if (moveOp) { + if (moveOp.targetIndex > 0) { + moveOp.targetIndex--; + renderView(); + } + } else { + const column = columns[currentCol]; + if (!column) return; + const listWidget = column.list; + const selected = listWidget.selected ?? 0; + const total = column.tasks.length; + if (total === 0) return; + const nextIndex = selected > 0 ? selected - 1 : total - 1; + listWidget.select(nextIndex); + screen.render(); + } + }); + + screen.key(["down", "j"], () => { + if (popupOpen) return; + + if (moveOp) { + const column = columns[currentCol]; + // We need to check the projected length to know if we can move down + // The current rendered column has the correct length including the ghost task + if (column && moveOp.targetIndex < column.tasks.length - 1) { + moveOp.targetIndex++; + renderView(); + } + } else { + const column = columns[currentCol]; + if (!column) return; + const listWidget = column.list; + const selected = listWidget.selected ?? 0; + const total = column.tasks.length; + if (total === 0) return; + const nextIndex = selected < total - 1 ? selected + 1 : 0; + listWidget.select(nextIndex); + screen.render(); + } + }); + + screen.key(["enter"], async () => { + if (popupOpen) return; + + // In move mode, Enter confirms the move + if (moveOp) { + await performTaskMove(); + return; + } + + const column = columns[currentCol]; + if (!column) return; + const idx = column.list.selected ?? 0; + if (idx < 0 || idx >= column.tasks.length) return; + const task = column.tasks[idx]; + if (!task) return; + popupOpen = true; + + const popup = await createTaskPopup(screen, task); + if (!popup) { + popupOpen = false; + return; + } + + const { contentArea, close } = popup; + contentArea.key(["escape", "q"], () => { + popupOpen = false; + close(); + columns[currentCol]?.list.focus(); + }); + + contentArea.key(["e", "E"], async () => { + try { + const core = new Core(process.cwd(), { enableWatchers: true }); + const filePath = await getTaskPath(task.id, core); + if (!filePath) return; + type ProgWithPause = { pause?: () => () => void }; + const scr = screen as unknown as { program?: ProgWithPause; leave?: () => void; enter?: () => void }; + const prog = scr.program; + const resumeProgram = typeof prog?.pause === "function" ? prog.pause() : undefined; + try { + scr.leave?.(); + } catch {} + try { + await core.openEditor(filePath); + } finally { + try { + scr.enter?.(); + } catch {} + try { + if (typeof resumeProgram === "function") resumeProgram(); + } catch {} + screen.render(); + } + } catch (_error) { + // Silently handle errors + } + }); + + screen.render(); + }); + + screen.key(["e", "E"], async () => { + if (popupOpen) return; + const column = columns[currentCol]; + if (!column) return; + const idx = column.list.selected ?? 0; + if (idx < 0 || idx >= column.tasks.length) return; + const task = column.tasks[idx]; + if (!task) return; + try { + const core = new Core(process.cwd(), { enableWatchers: true }); + const filePath = await getTaskPath(task.id, core); + if (!filePath) return; + type ProgWithPause = { pause?: () => () => void }; + const scr = screen as unknown as { program?: ProgWithPause; leave?: () => void; enter?: () => void }; + const prog = scr.program; + const resumeProgram = typeof prog?.pause === "function" ? prog.pause() : undefined; + try { + scr.leave?.(); + } catch {} + try { + await core.openEditor(filePath); + } finally { + try { + scr.enter?.(); + } catch {} + try { + if (typeof resumeProgram === "function") resumeProgram(); + } catch {} + screen.render(); + } + } catch (_error) { + // Silently handle errors + } + }); + + const performTaskMove = async () => { + if (!moveOp) return; + + // Check if any actual change occurred + const noChange = moveOp.targetStatus === moveOp.originalStatus && moveOp.targetIndex === moveOp.originalIndex; + + if (noChange) { + // No change, just exit move mode + moveOp = null; + renderView(); + return; + } + + try { + const core = new Core(process.cwd(), { enableWatchers: true }); + const config = await core.fs.loadConfig(); + + // Get the final state from the projection + const projectedData = getProjectedColumns(currentTasks, moveOp); + const targetColumn = projectedData.find((c) => c.status === moveOp?.targetStatus); + + if (!targetColumn) { + moveOp = null; + renderView(); + return; + } + + const orderedTaskIds = targetColumn.tasks.map((task) => task.id); + + // Persist the move using core API + const { updatedTask, changedTasks } = await core.reorderTask({ + taskId: moveOp.taskId, + targetStatus: moveOp.targetStatus, + orderedTaskIds, + autoCommit: config?.autoCommit ?? false, + }); + + // Update local state with all changed tasks (includes ordinal updates) + const changedTasksMap = new Map(changedTasks.map((t) => [t.id, t])); + changedTasksMap.set(updatedTask.id, updatedTask); + currentTasks = currentTasks.map((t) => changedTasksMap.get(t.id) ?? t); + + // Exit move mode + moveOp = null; + + // Render with updated local state + renderView(); + } catch (error) { + // On error, cancel the move and restore original position + if (process.env.DEBUG) { + console.error("Move failed:", error); + } + moveOp = null; + renderView(); + } + }; + const cancelMove = () => { + if (!moveOp) return; + + // Exit move mode - pure state reset + moveOp = null; + + renderView(); + }; + + screen.key(["m", "M"], async () => { + if (popupOpen) return; + + if (!moveOp) { + const column = columns[currentCol]; + if (!column) return; + const taskIndex = column.list.selected ?? 0; + const task = column.tasks[taskIndex]; + if (!task) return; + + // Prevent move mode for cross-branch tasks + if (task.branch) { + footerBox.setContent( + ` {red-fg}Cannot move task from branch "${task.branch}". Switch to that branch to modify it.{/}`, + ); + screen.render(); + setTimeout(() => { + footerBox.setContent( + " {cyan-fg}[Tab]{/} Switch View | {cyan-fg}[←→]{/} Columns | {cyan-fg}[↑↓]{/} Tasks | {cyan-fg}[Enter]{/} View | {cyan-fg}[E]{/} Edit | {cyan-fg}[M]{/} Move | {cyan-fg}[q/Esc]{/} Quit", + ); + screen.render(); + }, 3000); + return; + } + + // Enter move mode - store original position for cancel + moveOp = { + taskId: task.id, + originalStatus: column.status, + originalIndex: taskIndex, + targetStatus: column.status, + targetIndex: taskIndex, + }; + + renderView(); + } else { + // Confirm move (same as Enter in move mode) + await performTaskMove(); + } + }); + + screen.key(["tab"], async () => { + if (popupOpen) return; + const column = columns[currentCol]; + if (column) { + const idx = column.list.selected ?? 0; + if (idx >= 0 && idx < column.tasks.length) { + const task = column.tasks[idx]; + if (task) options?.onTaskSelect?.(task); + } + } + + if (options?.onTabPress) { + screen.destroy(); + await options.onTabPress(); + resolve(); + return; + } + + if (options?.viewSwitcher) { + screen.destroy(); + await options.viewSwitcher.switchView(); + resolve(); + } + }); + + screen.key(["q", "C-c"], () => { + screen.destroy(); + resolve(); + }); + + screen.key(["escape"], () => { + // In move mode, ESC cancels and restores original position + if (moveOp) { + cancelMove(); + return; + } + + if (!popupOpen) { + screen.destroy(); + resolve(); + } + }); + + screen.render(); + }); +} diff --git a/src/ui/checklist.ts b/src/ui/checklist.ts new file mode 100644 index 0000000..ae37867 --- /dev/null +++ b/src/ui/checklist.ts @@ -0,0 +1,103 @@ +/* Checklist alignment utilities for consistent checkbox display */ + +export interface ChecklistItem { + text: string; + checked: boolean; +} + +/** + * Regex patterns for detecting checkbox markdown + */ +export const CHECKBOX_PATTERNS = { + // Matches "- [ ] text" or "- [x] text" with optional leading whitespace + CHECKBOX_LINE: /^\s*-\s*\[([ x])\]\s*(.*)$/, + // Matches just the checkbox part + CHECKBOX_PREFIX: /^-\s*\[([ x])\]\s*/, +} as const; + +/** + * Parse a line to extract checkbox state and text + */ +export function parseCheckboxLine(line: string): ChecklistItem | null { + const match = line.match(CHECKBOX_PATTERNS.CHECKBOX_LINE); + if (!match) return null; + + const [, checkState, text] = match; + return { + text: text?.trim() || "", + checked: checkState === "x", + }; +} + +/** + * Format a checklist item with aligned checkbox display + */ +export function formatChecklistItem( + item: ChecklistItem, + options: { + padding?: string; + checkedSymbol?: string; + uncheckedSymbol?: string; + } = {}, +): string { + const { padding = " ", checkedSymbol = "[x]", uncheckedSymbol = "[ ]" } = options; + + const checkbox = item.checked ? checkedSymbol : uncheckedSymbol; + return `${padding}${checkbox} ${item.text}`; +} + +/** + * Process acceptance criteria section and align checkboxes + */ +export function alignAcceptanceCriteria(criteriaSection: string): string[] { + if (!criteriaSection) return []; + + return criteriaSection + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const item = parseCheckboxLine(line); + if (item) { + return formatChecklistItem(item); + } + // Return non-checkbox lines as-is with minimal padding + return ` ${line}`; + }); +} + +/** + * Extract and format acceptance criteria from markdown content + */ +export function extractAndFormatAcceptanceCriteria(content: string): string[] { + const criteriaSection = extractSection(content, "Acceptance Criteria"); + if (!criteriaSection) return []; + + return alignAcceptanceCriteria(criteriaSection); +} + +/** + * Extract a section from markdown content + */ +function extractSection(content: string, sectionTitle: string): string | undefined { + const regex = new RegExp(`## ${sectionTitle}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`, "i"); + const match = content.match(regex); + return match?.[1]?.trim(); +} + +/** + * Format multiple checklist items with consistent alignment + */ +export function formatChecklist(items: ChecklistItem[]): string[] { + return items.map((item) => formatChecklistItem(item)); +} + +/** + * Parse multiple checkbox lines from text + */ +export function parseCheckboxLines(text: string): ChecklistItem[] { + return text + .split("\n") + .map((line) => parseCheckboxLine(line)) + .filter((item): item is ChecklistItem => item !== null); +} diff --git a/src/ui/code-path.ts b/src/ui/code-path.ts new file mode 100644 index 0000000..b52aab1 --- /dev/null +++ b/src/ui/code-path.ts @@ -0,0 +1,113 @@ +/* Code path detection and styling utilities */ + +/** + * Regex patterns for detecting code paths in backticks + */ +export const CODE_PATH_PATTERNS = { + // Matches `src/cli.ts`, `package.json`, `/full/path/file.ts` + BACKTICKED_PATH: /`([^`]+)`/g, + // Matches file extensions + FILE_EXTENSION: /\.[a-zA-Z0-9]+$/, + // Matches path separators + PATH_SEPARATOR: /[/\\]/, +} as const; + +/** + * Detect if a backticked string is likely a file path + */ +export function isCodePath(content: string): boolean { + // Has file extension OR contains path separator + return CODE_PATH_PATTERNS.FILE_EXTENSION.test(content) || CODE_PATH_PATTERNS.PATH_SEPARATOR.test(content); +} + +/** + * Extract all code paths from text + */ +export function extractCodePaths(text: string): string[] { + const matches = text.match(CODE_PATH_PATTERNS.BACKTICKED_PATH); + if (!matches) return []; + + return matches + .map((match) => match.slice(1, -1)) // Remove backticks + .filter(isCodePath); +} + +/** + * Style a code path for blessed display + */ +export function styleCodePath(path: string): string { + return `{gray-fg}\`${path}\`{/gray-fg}`; +} + +/** + * Transform text to style code paths and place them on separate lines + */ +export function transformCodePaths(text: string): string { + if (!text) return ""; + + // Split into lines to preserve existing line breaks + const lines = text.split("\n"); + const result: string[] = []; + + for (const line of lines) { + let transformedLine = line; + const codePaths = extractCodePaths(line); + + if (codePaths.length === 0) { + // No code paths, add line as-is + result.push(transformedLine); + continue; + } + + // Check if line contains only a code path (possibly with minimal surrounding text) + const lineWithoutPaths = line.replace(/`[^`]+`/g, "").trim(); + const isIsolatedPath = codePaths.length === 1 && lineWithoutPaths.length < 10; + + if (isIsolatedPath) { + // Style the code path in place + for (const path of codePaths) { + transformedLine = transformedLine.replace(`\`${path}\``, styleCodePath(path)); + } + result.push(transformedLine); + } else { + // Extract code paths to separate lines + let workingLine = transformedLine; + const pathsToExtract: string[] = []; + + for (const path of codePaths) { + const backticked = `\`${path}\``; + if (workingLine.includes(backticked)) { + // Remove from line and collect for separate placement, clean up extra spaces + workingLine = workingLine.replace(backticked, " ").replace(/\s+/g, " ").trim(); + pathsToExtract.push(path); + } + } + + // Add the line without code paths (if not empty) + if (workingLine.length > 0) { + result.push(workingLine); + } + + // Add each code path on its own line + for (const path of pathsToExtract) { + result.push(styleCodePath(path)); + } + } + } + + return result.join("\n"); +} + +/** + * Simple styling for plain text (without blessed tags) + */ +export function transformCodePathsPlain(text: string): string { + if (!text) return ""; + + return text.replace(CODE_PATH_PATTERNS.BACKTICKED_PATH, (match, path) => { + if (isCodePath(path)) { + return `\`${path}\``; + } + return match; + }); +} diff --git a/src/ui/components/generic-list.ts b/src/ui/components/generic-list.ts new file mode 100644 index 0000000..efd6d63 --- /dev/null +++ b/src/ui/components/generic-list.ts @@ -0,0 +1,506 @@ +/** + * Generic list component that consolidates selectList, multiSelect, and TaskList functionality + * Provides a unified interface for all list selection patterns in the UI + */ + +import { stdout as output } from "node:process"; +import type { ElementInterface, ListInterface, ScreenInterface } from "neo-neo-bblessed"; +import { list } from "neo-neo-bblessed"; +import { formatHeading } from "../heading.ts"; +import { createScreen } from "../tui.ts"; + +export interface GenericListItem { + id: string; +} + +export interface GenericListOptions<T extends GenericListItem> { + parent?: ElementInterface | ScreenInterface; + title?: string; + items: T[]; + multiSelect?: boolean; + searchable?: boolean; + itemRenderer?: (item: T, index: number, selected: boolean) => string; + groupBy?: (item: T) => string; + selectedIndex?: number; + selectedIndices?: number[]; + onSelect?: (selected: T | T[], index?: number | number[]) => void; + // Called whenever the highlighted item changes (live navigation) + onHighlight?: (selected: T | null, index: number) => void; + width?: string | number; + height?: string | number; + top?: string | number; + left?: string | number; + border?: boolean; + keys?: { + up?: string[]; + down?: string[]; + select?: string[]; + toggle?: string[]; + cancel?: string[]; + search?: string[]; + }; + style?: { + border?: { fg: string }; + selected?: { fg: string; bg: string }; + item?: { fg: string }; + focus?: { border: { fg: string } }; + }; + showHelp?: boolean; +} + +export interface GenericListController<T extends GenericListItem> { + getSelected(): T | T[] | null; + getSelectedIndex(): number | number[]; + setSelectedIndex(index: number): void; + updateItems(items: T[]): void; + focus(): void; + getListBox(): ListInterface; + destroy(): void; +} + +export class GenericList<T extends GenericListItem> implements GenericListController<T> { + private listBox!: ListInterface; + private screen?: ScreenInterface; + private items: T[]; + private filteredItems: T[]; + private selectedIndex: number; + private selectedIndices: Set<number>; + private isMultiSelect: boolean; + private onSelect?: (selected: T | T[], index?: number | number[]) => void; + private onHighlight?: (selected: T | null, index: number) => void; + private itemRenderer: (item: T, index: number, selected: boolean) => string; + private groupBy?: (item: T) => string; + private searchTerm = ""; + private isSearchMode = false; + private options: GenericListOptions<T>; + + constructor(options: GenericListOptions<T>) { + this.options = options; + this.items = options.items || []; + this.filteredItems = [...this.items]; + this.isMultiSelect = options.multiSelect || false; + this.selectedIndex = options.selectedIndex || 0; + this.selectedIndices = new Set(options.selectedIndices || []); + this.onSelect = options.onSelect; + this.onHighlight = options.onHighlight; + this.groupBy = options.groupBy; + + // Default item renderer + this.itemRenderer = + options.itemRenderer || + ((item: T) => { + if ("title" in item && (item as Record<string, unknown>).title) { + return `${item.id} - ${String((item as Record<string, unknown>).title)}`; + } + return item.id; + }); + + if (output.isTTY === false) { + this.handleNonTTY(); + return; + } + + this.createListComponent(); + } + + private handleNonTTY(): void { + // For non-TTY environments, return first item for single select or empty for multi + if (!this.isMultiSelect && this.items.length > 0) { + const firstItem = this.items[0]; + if (firstItem) { + setTimeout(() => this.onSelect?.(firstItem, 0), 0); + } + } else { + setTimeout(() => this.onSelect?.([], []), 0); + } + } + + private createListComponent(): void { + // Create screen if not provided + if (!this.options.parent) { + this.screen = createScreen({ + style: { fg: "white", bg: "black" }, + }); + } + + const parent = this.options.parent || this.screen; + + // Default styling + const defaultStyle = { + border: { fg: "blue" }, + selected: { fg: "white", bg: "blue" }, + item: { fg: "white" }, + focus: { border: { fg: "yellow" } }, + }; + + const style = { ...defaultStyle, ...this.options.style }; + + this.listBox = list({ + parent, + label: this.options.title ? `\u00A0${this.options.title}\u00A0` : undefined, + top: this.options.top || 0, + left: this.options.left || 0, + width: this.options.width || (parent === this.screen ? "90%" : "100%"), + height: this.options.height || (parent === this.screen ? "80%" : "100%"), + border: this.options.border !== false ? "line" : undefined, + style, + tags: true, + // Disable built-in key handling to avoid double-processing with our custom handlers + keys: false, + mouse: true, + scrollable: true, + alwaysScroll: false, + }); + + this.refreshList(); + this.setupEventHandlers(); + this.selectInitialItem(); + } + + private refreshList(): void { + if (!this.listBox) return; + + // Apply search filter + this.filteredItems = this.searchTerm + ? this.items.filter((item) => JSON.stringify(item).toLowerCase().includes(this.searchTerm.toLowerCase())) + : [...this.items]; + + // Build display items + const displayItems: string[] = []; + const itemMap = new Map<number, T | null>(); + let index = 0; + + if (this.groupBy) { + // Group items + const groups = new Map<string, T[]>(); + for (const item of this.filteredItems) { + const group = this.groupBy(item); + if (!groups.has(group)) { + groups.set(group, []); + } + const groupList = groups.get(group); + if (groupList) { + groupList.push(item); + } + } + + // Render groups + for (const [group, groupItems] of groups) { + displayItems.push(formatHeading(group || "No Group", 2)); + itemMap.set(index++, null); // Group header + for (const item of groupItems) { + const isSelected = this.isMultiSelect ? this.selectedIndices.has(index) : false; + const rendered = this.itemRenderer(item, index, isSelected); + const prefix = this.isMultiSelect ? (isSelected ? "[βœ“] " : "[ ] ") : " "; + displayItems.push(prefix + rendered); + itemMap.set(index++, item); + } + } + } else { + // Render flat list + for (let i = 0; i < this.filteredItems.length; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + const isSelected = this.isMultiSelect ? this.selectedIndices.has(i) : false; + const rendered = this.itemRenderer(item, i, isSelected); + const prefix = this.isMultiSelect ? (isSelected ? "[βœ“] " : "[ ] ") : ""; + displayItems.push(prefix + rendered); + itemMap.set(index++, item); + } + } + + // Add search indicator + if (this.options.searchable && this.isSearchMode) { + displayItems.unshift(`{cyan-fg}Search: ${this.searchTerm}_{/}`); + } + + // Add help text + if (this.options.showHelp !== false) { + const helpText = this.buildHelpText(); + displayItems.push("", helpText); + } + + this.listBox.setItems(displayItems); + } + + private buildHelpText(): string { + const parts = ["↑/↓ navigate"]; + + if (this.isMultiSelect) { + parts.push("Space toggle"); + parts.push("Enter confirm"); + } else { + parts.push("Enter select"); + } + + if (this.options.searchable) { + parts.push("/ search"); + } + + parts.push("Esc/q quit"); + return `{gray-fg}${parts.join(" Β· ")}{/gray-fg}`; + } + + private setupEventHandlers(): void { + if (!this.listBox) return; + + // Don't use the select event for navigation - only for explicit selection + // This prevents conflicts between navigation and selection + + // Custom key bindings + const keys = this.options.keys || {}; + + // Circular navigation for up/down (including vim-style keys) + const moveUp = () => { + const total = this.filteredItems.length; + if (total === 0) return; + const sel = typeof this.selectedIndex === "number" ? this.selectedIndex : 0; + const nextIndex = sel > 0 ? sel - 1 : total - 1; + this.listBox.select(nextIndex); + this.selectedIndex = nextIndex; + this.onHighlight?.(this.filteredItems[nextIndex] ?? null, nextIndex); + this.getScreen()?.render?.(); + }; + + const moveDown = () => { + const total = this.filteredItems.length; + if (total === 0) return; + const sel = typeof this.selectedIndex === "number" ? this.selectedIndex : 0; + const nextIndex = sel < total - 1 ? sel + 1 : 0; + this.listBox.select(nextIndex); + this.selectedIndex = nextIndex; + this.onHighlight?.(this.filteredItems[nextIndex] ?? null, nextIndex); + this.getScreen()?.render?.(); + }; + + this.listBox.key(["up", "k"], moveUp); + this.listBox.key(["down", "j"], moveDown); + + // Selection/Toggle + if (this.isMultiSelect) { + this.listBox.key(keys.toggle || ["space"], () => { + this.toggleSelection(this.listBox.selected ?? 0); + }); + + this.listBox.key(keys.select || ["enter"], () => { + this.confirmSelection(); + }); + } else { + this.listBox.key(keys.select || ["enter"], () => { + this.selectedIndex = this.listBox.selected ?? 0; + this.triggerSelection(); + }); + } + + // Search + if (this.options.searchable) { + this.listBox.key(keys.search || ["/"], () => { + this.enterSearchMode(); + }); + + this.listBox.key(["escape"], () => { + if (this.isSearchMode) { + this.exitSearchMode(); + } else { + this.cancel(); + } + }); + } + + // Cancel + this.listBox.key(keys.cancel || ["escape", "q", "C-c"], () => { + this.cancel(); + }); + + // Handle search input + if (this.options.searchable) { + this.listBox.on("keypress", (ch: string, key: { name: string; ctrl?: boolean; meta?: boolean }) => { + if (this.isSearchMode && key.name !== "escape" && key.name !== "enter") { + if (key.name === "backspace") { + this.searchTerm = this.searchTerm.slice(0, -1); + } else if (ch && ch.length === 1 && !key.ctrl && !key.meta) { + this.searchTerm += ch; + } + this.refreshList(); + } + }); + } + } + + private selectInitialItem(): void { + if (this.filteredItems.length > 0) { + const validIndex = Math.min(this.selectedIndex, this.filteredItems.length - 1); + this.listBox.select(validIndex); + this.selectedIndex = validIndex; + // Emit initial highlight so hosts can synchronize detail panes + this.onHighlight?.(this.filteredItems[validIndex] ?? null, validIndex); + // For multi-select, keep internal selectedIndex aligned with highlight + } + } + + private toggleSelection(index: number): void { + if (this.selectedIndices.has(index)) { + this.selectedIndices.delete(index); + } else { + this.selectedIndices.add(index); + } + this.refreshList(); + } + + private confirmSelection(): void { + const selected = Array.from(this.selectedIndices) + .map((i) => this.filteredItems[i]) + .filter((item): item is T => Boolean(item)); + + const indices = Array.from(this.selectedIndices); + this.onSelect?.(selected, indices); + } + + private triggerSelection(): void { + if (this.selectedIndex >= 0 && this.selectedIndex < this.filteredItems.length) { + const selected = this.filteredItems[this.selectedIndex]; + if (selected) { + this.onSelect?.(selected, this.selectedIndex); + } + } + } + + private enterSearchMode(): void { + this.isSearchMode = true; + this.searchTerm = ""; + this.refreshList(); + } + + private exitSearchMode(): void { + this.isSearchMode = false; + this.searchTerm = ""; + this.refreshList(); + } + + private cancel(): void { + if (this.isMultiSelect) { + this.onSelect?.([], []); + } else { + this.onSelect?.(null as unknown as T, -1); + } + } + + // Public interface methods + public getSelected(): T | T[] | null { + if (this.isMultiSelect) { + return Array.from(this.selectedIndices) + .map((i) => this.filteredItems[i]) + .filter((item): item is T => Boolean(item)); + } + return this.selectedIndex >= 0 && this.selectedIndex < this.filteredItems.length + ? (this.filteredItems[this.selectedIndex] ?? null) + : null; + } + + public getSelectedIndex(): number | number[] { + return this.isMultiSelect ? Array.from(this.selectedIndices) : this.selectedIndex; + } + + public setSelectedIndex(index: number): void { + if (!this.listBox || this.filteredItems.length === 0) { + return; + } + const clamped = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + if (this.selectedIndex === clamped) { + // Still emit highlight to ensure host state stays synchronized + this.onHighlight?.(this.filteredItems[clamped] ?? null, clamped); + return; + } + this.selectedIndex = clamped; + this.listBox.select(clamped); + const listWithSelected = this.listBox as ListInterface & { selected?: number }; + listWithSelected.selected = clamped; + this.onHighlight?.(this.filteredItems[clamped] ?? null, clamped); + this.getScreen()?.render?.(); + } + + public updateItems(items: T[]): void { + this.items = items; + this.refreshList(); + this.selectInitialItem(); + } + + public focus(): void { + if (this.listBox) { + this.listBox.focus(); + } + } + + public getListBox(): ListInterface { + return this.listBox; + } + + public destroy(): void { + if (this.listBox) { + this.listBox.destroy(); + } + if (this.screen) { + this.screen.destroy(); + } + } + + private getScreen(): ScreenInterface | undefined { + if (this.screen) return this.screen; + const maybeHasScreen = this.listBox as unknown as { screen?: ScreenInterface }; + return maybeHasScreen?.screen; + } +} + +// Factory function for easier usage +export function createGenericList<T extends GenericListItem>(options: GenericListOptions<T>): GenericList<T> { + return new GenericList<T>(options); +} + +// Promise-based convenience functions for backward compatibility +export async function genericSelectList<T extends GenericListItem>( + title: string, + items: T[], + options?: Partial<GenericListOptions<T>>, +): Promise<T | null> { + if (output.isTTY === false || items.length === 0) { + return null; + } + + return new Promise<T | null>((resolve) => { + const list = new GenericList<T>({ + title, + items, + multiSelect: false, + showHelp: true, + onSelect: (selected) => { + list.destroy(); + resolve(selected as T | null); + }, + ...options, + }); + }); +} + +export async function genericMultiSelect<T extends GenericListItem>( + title: string, + items: T[], + options?: Partial<GenericListOptions<T>>, +): Promise<T[]> { + if (output.isTTY === false) { + return []; + } + + return new Promise<T[]>((resolve) => { + const list = new GenericList<T>({ + title, + items, + multiSelect: true, + showHelp: true, + onSelect: (selected) => { + list.destroy(); + resolve(selected as T[]); + }, + ...options, + }); + }); +} diff --git a/src/ui/enhanced-views.ts b/src/ui/enhanced-views.ts new file mode 100644 index 0000000..83a36c2 --- /dev/null +++ b/src/ui/enhanced-views.ts @@ -0,0 +1,194 @@ +/** + * Enhanced views with Tab key switching between task views and kanban board + */ + +import type { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; +import { renderBoardTui } from "./board.ts"; +import { createLoadingScreen } from "./loading.ts"; +import { type ViewState, ViewSwitcher, type ViewType } from "./view-switcher.ts"; + +export interface EnhancedViewOptions { + core: Core; + initialView: ViewType; + selectedTask?: Task; + tasks?: Task[]; + filter?: { + status?: string; + assignee?: string; + title?: string; + filterDescription?: string; + }; +} + +/** + * Main enhanced view controller that handles Tab switching between views + */ +export async function runEnhancedViews(options: EnhancedViewOptions): Promise<void> { + const initialState: ViewState = { + type: options.initialView, + selectedTask: options.selectedTask, + tasks: options.tasks, + filter: options.filter, + }; + + const _currentView: (() => Promise<void>) | null = null; + let viewSwitcher: ViewSwitcher | null = null; + + // Create view switcher with state change handler + viewSwitcher = new ViewSwitcher({ + core: options.core, + initialState, + onViewChange: async (newState) => { + // Handle view changes triggered by the switcher + await switchToView(newState); + }, + }); + + // Function to switch to a specific view + const switchToView = async (state: ViewState): Promise<void> => { + switch (state.type) { + case "task-list": + case "task-detail": + await switchToTaskView(state); + break; + case "kanban": + await switchToKanbanView(state); + break; + } + }; + + // Function to handle switching to task view + const switchToTaskView = async (state: ViewState): Promise<void> => { + if (!state.tasks || state.tasks.length === 0) { + console.log("No tasks available."); + return; + } + + const taskToView = state.selectedTask || state.tasks[0]; + if (!taskToView) return; + + // Create enhanced task viewer with Tab switching + await viewTaskEnhancedWithSwitching(taskToView, { + tasks: state.tasks, + core: options.core, + title: state.filter?.title, + filterDescription: state.filter?.filterDescription, + startWithDetailFocus: state.type === "task-detail", + viewSwitcher, + onTaskChange: (newTask) => { + // Update state when user navigates to different task + viewSwitcher?.updateState({ + selectedTask: newTask, + type: newTask ? "task-detail" : "task-list", + }); + }, + }); + }; + + // Function to handle switching to kanban view + const switchToKanbanView = async (state: ViewState): Promise<void> => { + if (!state.kanbanData) return; + + if (state.kanbanData.isLoading) { + // Show loading screen while waiting for data + const loadingScreen = await createLoadingScreen("Loading kanban board"); + + try { + // Wait for kanban data to load + const result = await viewSwitcher?.getKanbanData(); + if (!result) throw new Error("Failed to get kanban data"); + const { tasks, statuses } = result; + loadingScreen?.close(); + + // Now show the kanban board + await renderBoardTuiWithSwitching(tasks, statuses, { + viewSwitcher, + onTaskSelect: (task) => { + // When user selects a task in kanban, prepare for potential switch back + viewSwitcher?.updateState({ + selectedTask: task, + }); + }, + }); + } catch (error) { + loadingScreen?.close(); + console.error("Failed to load kanban data:", error); + } + } else if (state.kanbanData.loadError) { + console.error("Error loading kanban board:", state.kanbanData.loadError); + } else { + // Data is ready, show kanban board immediately + await renderBoardTuiWithSwitching(state.kanbanData.tasks, state.kanbanData.statuses, { + viewSwitcher, + onTaskSelect: (task) => { + viewSwitcher?.updateState({ + selectedTask: task, + }); + }, + }); + } + }; + + // Start with the initial view + await switchToView(initialState); +} + +/** + * Enhanced task viewer that supports view switching + */ +async function viewTaskEnhancedWithSwitching( + task: Task, + options: { + tasks?: Task[]; + core: Core; + title?: string; + filterDescription?: string; + startWithDetailFocus?: boolean; + viewSwitcher?: ViewSwitcher; + onTaskChange?: (task: Task) => void; + }, +): Promise<void> { + // Import the original viewTaskEnhanced function + const { viewTaskEnhanced } = await import("./task-viewer-with-search.ts"); + + // For now, use the original function but we'll need to modify it to support Tab switching + // This is a placeholder - we'll need to modify the actual task-viewer-with-search.ts + return viewTaskEnhanced(task, { + tasks: options.tasks, + core: options.core, + title: options.title, + filterDescription: options.filterDescription, + startWithDetailFocus: options.startWithDetailFocus, + // Add view switcher support + viewSwitcher: options.viewSwitcher, + onTaskChange: options.onTaskChange, + }); +} + +/** + * Enhanced kanban board that supports view switching + */ +async function renderBoardTuiWithSwitching( + tasks: Task[], + statuses: string[], + _options: { + viewSwitcher?: ViewSwitcher; + onTaskSelect?: (task: Task) => void; + }, +): Promise<void> { + // Get config for layout and column width + const core = new (await import("../core/backlog.ts")).Core(process.cwd()); + const config = await core.filesystem.loadConfig(); + const layout = "horizontal" as const; // Default layout + const maxColumnWidth = config?.maxColumnWidth || 20; + + // For now, use the original function but we'll need to modify it to support Tab switching + // This is a placeholder - we'll need to modify the actual board.ts + return renderBoardTui(tasks, statuses, layout, maxColumnWidth); +} + +// Re-export for convenience +export { type ViewState, ViewSwitcher, type ViewType } from "./view-switcher.ts"; + +// Helper function import diff --git a/src/ui/heading.ts b/src/ui/heading.ts new file mode 100644 index 0000000..98c0341 --- /dev/null +++ b/src/ui/heading.ts @@ -0,0 +1,63 @@ +/* Heading helper component for consistent terminal UI styling */ +import { box } from "neo-neo-bblessed"; + +export type HeadingLevel = 1 | 2 | 3; + +/** Map heading level β†’ colour + bold flag */ +export function getHeadingStyle(level: HeadingLevel): { color: string; bold: boolean } { + switch (level) { + case 1: + return { color: "bright-white", bold: true }; + case 2: + return { color: "cyan", bold: false }; + default: + return { color: "white", bold: false }; + } +} + +/** Wrap plain text with blessed colour / bold tags */ +export function formatHeading(text: string, level: HeadingLevel): string { + const { color, bold } = getHeadingStyle(level); + const tagColour = color.replace("-", ""); + return bold + ? `{bold}{${tagColour}-fg}${text}{/${tagColour}-fg}{/bold}` + : `{${tagColour}-fg}${text}{/${tagColour}-fg}`; +} + +/** + * Create a heading element (one line). + * Stays compatible with previous async API by returning a resolved Promise. + */ +export async function createHeading( + parent: unknown, + text: string, + level: HeadingLevel, + opts: { top?: number | string; left?: number | string; width?: number | string } = {}, +): Promise<unknown> { + return box({ + parent, + content: formatHeading(text, level), + top: opts.top ?? 0, + left: opts.left ?? 0, + width: opts.width ?? "100%", + height: 1, + tags: true, + style: { fg: getHeadingStyle(level).color, bold: getHeadingStyle(level).bold }, + }); +} + +/** + * Add a heading and return the next free row (with a blank line before it, + * except when at the very top). + */ +export async function addHeadingWithSpacing( + parent: unknown, + text: string, + level: HeadingLevel, + currentTop: number, + opts: { left?: number | string; width?: number | string } = {}, +): Promise<{ element: unknown; nextTop: number }> { + const actualTop = currentTop === 0 ? 0 : currentTop + 1; + const element = await createHeading(parent, text, level, { top: actualTop, ...opts }); + return { element, nextTop: actualTop + 1 }; +} diff --git a/src/ui/loading.ts b/src/ui/loading.ts new file mode 100644 index 0000000..bf1c131 --- /dev/null +++ b/src/ui/loading.ts @@ -0,0 +1,284 @@ +import type { BoxInterface, ScreenInterface } from "neo-neo-bblessed"; +import { box, log } from "neo-neo-bblessed"; +import { createScreen } from "./tui.ts"; + +/** + * Interface for loading screens that can be updated with progress messages. + */ +export interface LoadingScreen { + /** Update the loading screen with a new progress message */ + update: (message: string) => void; + /** Close the loading screen and clean up resources */ + close: () => void; +} + +// Shared constants +const SPINNER_CHARS = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"]; +const SPINNER_INTERVAL_MS = 100; + +/** + * Configuration options for creating loading screens. + * @internal + */ +interface LoadingScreenConfig { + /** Title for the loading screen window */ + title?: string; + /** Initial message to display */ + message: string; + /** Width of the loading box (default: "50%") */ + width?: string | number; + /** Height of the loading box in rows (default: 7) */ + height?: number; + /** Whether to show a spinner animation (default: true) */ + showSpinner?: boolean; + /** Position of the spinner within the screen (default: "center") */ + spinnerPosition?: "center" | "bottom"; + /** Whether the loading box should be scrollable (default: false) */ + allowScrolling?: boolean; +} + +/** + * Creates the basic loading screen components shared by both loading functions. + * Handles TTY fallback and common setup including spinner animation and keyboard shortcuts. + * @internal + * @param config - Configuration options for the loading screen + * @returns Base loading screen components and control functions + */ +function createLoadingScreenBase(config: LoadingScreenConfig): { + screen: ScreenInterface | null; + loadingBox: BoxInterface | null; + spinnerInterval: NodeJS.Timeout | null; + closed: boolean; + update: (message: string) => void; + close: () => void; +} { + const { title = "Loading...", message, height = 7, showSpinner = true, allowScrolling = false } = config; + + // Non-TTY fallback + if (!process.stdout.isTTY) { + console.log(`${message}...`); + return { + screen: null, + loadingBox: null, + spinnerInterval: null, + closed: false, + update: (msg) => console.log(` ${msg}...`), + close: () => {}, + }; + } + + // Create blessed screen + const screen = createScreen({ title }); + let closed = false; + + // Create loading box with proper border - ensure right border renders + const terminalWidth = process.stdout.columns || 80; + const boxWidth = Math.min(70, terminalWidth - 8); // Larger width to prevent text wrapping + + const loadingBox = box({ + parent: screen, + top: "center", + left: "center", + width: boxWidth, + height: Math.min(height, 6), // Keep it compact + border: { + type: "line", + }, + style: { + border: { fg: "cyan" }, + }, + label: " Loading ", + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + scrollable: allowScrolling, + alwaysScroll: allowScrolling, + // Additional properties to ensure proper rendering + tags: false, + wrap: false, + autoPadding: false, + }); + + // Create spinner in the title if requested + let spinnerInterval: NodeJS.Timeout | null = null; + let spinnerIndex = 0; + + if (showSpinner) { + // Start spinner animation in the title + spinnerInterval = setInterval(() => { + spinnerIndex = (spinnerIndex + 1) % SPINNER_CHARS.length; + const spinnerChar = SPINNER_CHARS[spinnerIndex]; + loadingBox.setLabel?.(` ${spinnerChar} Loading `); + screen.render(); + }, SPINNER_INTERVAL_MS); + + // Set initial spinner in label + loadingBox.setLabel?.(` ${SPINNER_CHARS[0]} Loading `); + } + + // Handle escape/Ctrl+C to close AND exit process immediately + screen.key(["escape", "C-c", "q"], () => { + if (!closed) { + closed = true; + if (spinnerInterval) clearInterval(spinnerInterval); + screen.destroy(); + // Exit immediately - don't wait for background operations + process.exit(0); + } + }); + + // Close function + const close = () => { + if (!closed) { + closed = true; + if (spinnerInterval) clearInterval(spinnerInterval); + screen.destroy(); + } + }; + + return { + screen, + loadingBox, + spinnerInterval, + closed, + update: () => {}, // Will be overridden by specific implementations + close, + }; +} + +/** + * Show a loading screen while an async operation runs. + * Falls back to console.log if blessed is not available. + * + * @param message - The message to display during loading + * @param operation - The async operation to run while showing the loading screen + * @returns The result of the async operation + * + * @example + * const result = await withLoadingScreen("Loading data", async () => { + * return await fetchDataFromAPI(); + * }); + */ +export async function withLoadingScreen<T>(message: string, operation: () => Promise<T>): Promise<T> { + const base = createLoadingScreenBase({ + message, + width: 60, // Larger width to prevent wrapping + height: 5, // Compact height + showSpinner: true, + spinnerPosition: "center", + }); + + // Non-TTY fallback handled in base + if (!base.screen) { + return operation(); + } + + // Add message text to loading box - ensure it doesn't overlap borders + if (base.loadingBox) { + // Use a simple box for message line + box({ + parent: base.loadingBox, + top: 0, + left: 2, // More space from left border + width: "100%-6", // Account for borders + padding (2 borders + 4 padding) + height: 1, + align: "center", + content: message, + style: { fg: "white" }, + }); + } + + base.screen.render(); + + // Small delay to ensure loading screen renders before heavy async work starts + // This is especially important on Windows where the terminal might block + await new Promise((resolve) => setTimeout(resolve, 10)); + + try { + const result = await operation(); + base.close(); + return result; + } catch (error) { + base.close(); + throw error; + } +} + +/** + * Create a loading screen that can be updated with progress messages. + * Useful for multi-step operations where you need to show progress updates. + * + * @param initialMessage - The initial message to display + * @returns A LoadingScreen interface with update and close methods, or null if creation fails + * + * @example + * const loader = await createLoadingScreen("Starting process"); + * loader?.update("Step 1: Loading data..."); + * // ... perform operations ... + * loader?.update("Step 2: Processing..."); + * // ... more operations ... + * loader?.close(); + */ +export async function createLoadingScreen(initialMessage: string): Promise<LoadingScreen | null> { + const base = createLoadingScreenBase({ + message: initialMessage, + width: 70, // Larger width to prevent wrapping + height: 6, // Smaller height for better proportions + showSpinner: true, + spinnerPosition: "bottom", + allowScrolling: true, + }); + + // Non-TTY fallback handled in base + if (!base.screen) { + return { + update: base.update, + close: base.close, + }; + } + + // Progress messages area + if (!base.loadingBox) { + return { + update: base.update, + close: base.close, + }; + } + + const messages = log({ + parent: base.loadingBox, + top: 0, + left: 2, // More space from left border + width: "100%-6", // Account for borders + padding (2 borders + 4 padding) + height: "100%-2", // Account for top and bottom borders + tags: true, + style: { fg: "white" }, + wrap: true, // Ensure long lines wrap instead of extending beyond width + }); + + // Add initial message + messages.log(initialMessage); + base.screen.render(); + + // Small delay to ensure loading screen renders before returning control + // This is especially important on Windows where the terminal might block + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Override update function to use the log widget + base.update = (message: string) => { + if (!base.closed) { + messages.log(message); + if (base.screen) { + base.screen.render(); + } + } + }; + + return { + update: base.update, + close: base.close, + }; +} diff --git a/src/ui/overview-tui.ts b/src/ui/overview-tui.ts new file mode 100644 index 0000000..3478972 --- /dev/null +++ b/src/ui/overview-tui.ts @@ -0,0 +1,285 @@ +import { box } from "neo-neo-bblessed"; +import type { TaskStatistics } from "../core/statistics.ts"; +import { getStatusIcon } from "./status-icon.ts"; +import { createScreen } from "./tui.ts"; + +/** + * Render the project overview in an interactive TUI + */ +export async function renderOverviewTui(statistics: TaskStatistics, projectName: string): Promise<void> { + // If not in TTY, fall back to plain text output + if (!process.stdout.isTTY) { + renderPlainTextOverview(statistics, projectName); + return; + } + + return new Promise<void>((resolve) => { + const screen = createScreen({ title: `${projectName} - Overview` }); + + // Main container + const container = box({ + parent: screen, + width: "100%", + height: "100%", + }); + + // Title + box({ + parent: container, + top: 0, + left: "center", + width: "shrink", + height: 3, + content: `{center}{bold}${projectName} - Project Overview{/bold}{/center}`, + tags: true, + style: { + fg: "white", + }, + }); + + // Status Overview Section (Top Left) + const statusBox = box({ + parent: container, + top: 3, + left: 0, + width: "50%", + height: "40%", + border: { type: "line" }, + label: " Status Overview ", + style: { + border: { fg: "gray" }, + }, + tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + }); + + let statusContent = ""; + for (const [status, count] of statistics.statusCounts) { + const icon = getStatusIcon(status); + const percentage = statistics.totalTasks > 0 ? Math.round((count / statistics.totalTasks) * 100) : 0; + statusContent += ` ${icon} {bold}${status}:{/bold} ${count} tasks (${percentage}%)\n`; + } + statusContent += `\n {cyan-fg}Total Tasks:{/cyan-fg} ${statistics.totalTasks}\n`; + statusContent += ` {green-fg}Completion:{/green-fg} ${statistics.completionPercentage}%\n`; + if (statistics.draftCount > 0) { + statusContent += ` {yellow-fg}Drafts:{/yellow-fg} ${statistics.draftCount}\n`; + } + statusBox.setContent(statusContent); + + // Priority Breakdown Section (Top Right) + const priorityBox = box({ + parent: container, + top: 3, + left: "50%", + width: "50%", + height: "40%", + border: { type: "line" }, + label: " Priority Breakdown ", + style: { + border: { fg: "gray" }, + }, + tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + }); + + let priorityContent = ""; + const priorityColors = { + high: "red", + medium: "yellow", + low: "green", + none: "gray", + }; + for (const [priority, count] of statistics.priorityCounts) { + if (count > 0) { + const color = priorityColors[priority as keyof typeof priorityColors] || "white"; + const percentage = statistics.totalTasks > 0 ? Math.round((count / statistics.totalTasks) * 100) : 0; + const displayPriority = + priority === "none" ? "No Priority" : priority.charAt(0).toUpperCase() + priority.slice(1); + priorityContent += ` {${color}-fg}${displayPriority}:{/${color}-fg} ${count} tasks (${percentage}%)\n`; + } + } + priorityBox.setContent(priorityContent); + + // Recent Activity Section (Bottom Left) + const activityBox = box({ + parent: container, + top: "43%", + left: 0, + width: "50%", + height: "28%", + border: { type: "line" }, + label: " Recent Activity ", + style: { + border: { fg: "gray" }, + }, + tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + }); + + let activityContent = "{bold}Recently Created:{/bold}\n"; + if (statistics.recentActivity.created.length > 0) { + for (const task of statistics.recentActivity.created) { + activityContent += ` ${task.id} - ${task.title.substring(0, 40)}${task.title.length > 40 ? "..." : ""}\n`; + } + } else { + activityContent += " {gray-fg}No tasks created in the last 7 days{/gray-fg}\n"; + } + + activityContent += "\n{bold}Recently Updated:{/bold}\n"; + if (statistics.recentActivity.updated.length > 0) { + for (const task of statistics.recentActivity.updated) { + activityContent += ` ${task.id} - ${task.title.substring(0, 40)}${task.title.length > 40 ? "..." : ""}\n`; + } + } else { + activityContent += " {gray-fg}No tasks updated in the last 7 days{/gray-fg}\n"; + } + activityBox.setContent(activityContent); + + // Project Health Section (Bottom Right) + const healthBox = box({ + parent: container, + top: "43%", + left: "50%", + width: "50%", + height: "28%", + border: { type: "line" }, + label: " Project Health ", + style: { + border: { fg: "gray" }, + }, + tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + }); + + let healthContent = `{bold}Average Task Age:{/bold} ${statistics.projectHealth.averageTaskAge} days\n\n`; + + healthContent += "{bold}Stale Tasks:{/bold} {gray-fg}(>30 days without updates){/gray-fg}\n"; + if (statistics.projectHealth.staleTasks.length > 0) { + for (const task of statistics.projectHealth.staleTasks) { + healthContent += ` {yellow-fg}${task.id}{/yellow-fg} - ${task.title.substring(0, 35)}${task.title.length > 35 ? "..." : ""}\n`; + } + } else { + healthContent += " {green-fg}No stale tasks{/green-fg}\n"; + } + + healthContent += "\n{bold}Blocked Tasks:{/bold} {gray-fg}(waiting on dependencies){/gray-fg}\n"; + if (statistics.projectHealth.blockedTasks.length > 0) { + for (const task of statistics.projectHealth.blockedTasks) { + healthContent += ` {red-fg}${task.id}{/red-fg} - ${task.title.substring(0, 35)}${task.title.length > 35 ? "..." : ""}\n`; + } + } else { + healthContent += " {green-fg}No blocked tasks{/green-fg}\n"; + } + healthBox.setContent(healthContent); + + // Instructions at bottom + box({ + parent: container, + bottom: 0, + left: 0, + width: "100%", + height: 3, + content: "{center}Press q or Esc to exit{/center}", + tags: true, + style: { + fg: "gray", + }, + }); + + // Focus on status box for scrolling + statusBox.focus(); + + // Exit handlers + screen.key(["escape", "q", "C-c"], () => { + screen.destroy(); + resolve(); + }); + + screen.render(); + }); +} + +/** + * Render plain text overview for non-TTY environments + */ +function renderPlainTextOverview(statistics: TaskStatistics, projectName: string): void { + console.log(`\n${projectName} - Project Overview\n${"=".repeat(40)}\n`); + + console.log("Status Overview:"); + for (const [status, count] of statistics.statusCounts) { + const percentage = statistics.totalTasks > 0 ? Math.round((count / statistics.totalTasks) * 100) : 0; + console.log(` ${status}: ${count} tasks (${percentage}%)`); + } + console.log(`\n Total Tasks: ${statistics.totalTasks}`); + console.log(` Completion: ${statistics.completionPercentage}%`); + if (statistics.draftCount > 0) { + console.log(` Drafts: ${statistics.draftCount}`); + } + + console.log("\nPriority Breakdown:"); + for (const [priority, count] of statistics.priorityCounts) { + if (count > 0) { + const percentage = statistics.totalTasks > 0 ? Math.round((count / statistics.totalTasks) * 100) : 0; + const displayPriority = + priority === "none" ? "No Priority" : priority.charAt(0).toUpperCase() + priority.slice(1); + console.log(` ${displayPriority}: ${count} tasks (${percentage}%)`); + } + } + + console.log("\nRecent Activity:"); + console.log(" Recently Created:"); + if (statistics.recentActivity.created.length > 0) { + for (const task of statistics.recentActivity.created) { + console.log(` ${task.id} - ${task.title}`); + } + } else { + console.log(" No tasks created in the last 7 days"); + } + + console.log("\n Recently Updated:"); + if (statistics.recentActivity.updated.length > 0) { + for (const task of statistics.recentActivity.updated) { + console.log(` ${task.id} - ${task.title}`); + } + } else { + console.log(" No tasks updated in the last 7 days"); + } + + console.log("\nProject Health:"); + console.log(` Average Task Age: ${statistics.projectHealth.averageTaskAge} days`); + + console.log("\n Stale Tasks (>30 days without updates):"); + if (statistics.projectHealth.staleTasks.length > 0) { + for (const task of statistics.projectHealth.staleTasks) { + console.log(` ${task.id} - ${task.title}`); + } + } else { + console.log(" No stale tasks"); + } + + console.log("\n Blocked Tasks (waiting on dependencies):"); + if (statistics.projectHealth.blockedTasks.length > 0) { + for (const task of statistics.projectHealth.blockedTasks) { + console.log(` ${task.id} - ${task.title}`); + } + } else { + console.log(" No blocked tasks"); + } + console.log(""); +} diff --git a/src/ui/sequences.ts b/src/ui/sequences.ts new file mode 100644 index 0000000..5c255f3 --- /dev/null +++ b/src/ui/sequences.ts @@ -0,0 +1,435 @@ +import { stdout as output } from "node:process"; +import type { BoxInterface } from "neo-neo-bblessed"; +import { box, scrollablebox } from "neo-neo-bblessed"; +import type { Core } from "../index.ts"; +import type { Sequence, Task } from "../types/index.ts"; +import { createTaskPopup } from "./task-viewer-with-search.ts"; +import { createScreen } from "./tui.ts"; + +/** + * Render a simple read-only TUI for sequences. + * - Vertical layout: each sequence has a header and its tasks listed below. + * - Exit with 'q' or 'Esc'. + */ +export async function runSequencesView( + data: { unsequenced: Task[]; sequences: Sequence[] }, + core?: Core, +): Promise<void> { + // Build content string first so we can also support headless environments (CI/tests) + const lines: string[] = []; + if (data.unsequenced.length > 0) { + lines.push("Unsequenced:"); + for (const t of data.unsequenced) lines.push(` ${t.id} - ${t.title}`); + lines.push(""); + } + for (const seq of data.sequences) { + lines.push(`Sequence ${seq.index}:`); + for (const t of seq.tasks) { + lines.push(` ${t.id} - ${t.title}`); + } + lines.push(""); + } + + // Headless/CI fallback: when not a TTY or explicitly requested, just print text content + const forceHeadless = process.env.BACKLOG_HEADLESS === "1" || process.env.CI === "1" || process.env.CI === "true"; + if (output.isTTY === false || forceHeadless) { + console.log(lines.join("\n")); + return; + } + + const screen = createScreen({ smartCSR: true }); + + const container = scrollablebox({ + top: 0, + left: 0, + right: 0, + height: "100%-1", + keys: true, + alwaysScroll: true, + mouse: true, + vi: false, + tags: false, + border: { type: "line" }, + label: " Sequences (read-only) ", + scrollbar: { ch: " ", inverse: true }, + style: { + border: { fg: "gray" }, + scrollbar: { bg: "gray" }, + }, + }); + + // Build bordered blocks for unsequenced and sequences, and individual task lines (for selection) + let y = 0; + type TaskLine = { + node: BoxInterface; + globalIndex: number; + seqIdx: number; + taskIdx: number; + absTop: number; // absolute top inside container + }; + const taskLines: TaskLine[] = []; + // Keep references to sequence blocks for visual indicator during move mode + const seqBlocks: { node: BoxInterface; index: number; top: number; height: number }[] = []; + let global = 0; + // Unsequenced block first + if (data.unsequenced.length > 0) { + const h = Math.max(4, data.unsequenced.length + 4); + const block = box({ + parent: container, + top: y, + left: 0, + right: 0, + height: h, + border: { type: "line" }, + label: " Unsequenced ", + tags: false, + style: { border: { fg: "cyan" } }, + }); + // Track for move target highlighting using index -1 + seqBlocks.push({ node: block, index: -1, top: y, height: h }); + for (let t = 0; t < data.unsequenced.length; t++) { + const lineTop = t + 1; + const task = data.unsequenced[t]; + if (!task) continue; + const node = box({ + parent: block, + top: lineTop, + left: 1, + right: 1, + height: 1, + tags: true, + content: ` ${task.id} - ${task.title}`, + }); + taskLines.push({ node, globalIndex: global++, seqIdx: -1, taskIdx: t, absTop: y + lineTop }); + } + y += h + 1; + } + + for (let s = 0; s < data.sequences.length; s++) { + const seq = data.sequences[s]; + if (!seq) continue; + const tasksSorted = [...seq.tasks].sort((a, b) => { + const ao = a.ordinal ?? Number.MAX_SAFE_INTEGER; + const bo = b.ordinal ?? Number.MAX_SAFE_INTEGER; + if (ao !== bo) return ao - bo; + return a.id.localeCompare(b.id); + }); + // Height calculation: + // - 2 lines for border + // - +1 top padding line, +1 bottom padding line so content doesn't overlap borders + const h = Math.max(4, tasksSorted.length + 4); + const block = box({ + parent: container, + top: y, + left: 0, + right: 0, + height: h, + border: { type: "line" }, + label: ` Sequence ${seq.index} `, + tags: false, + style: { border: { fg: "cyan" } }, + }); + + seqBlocks.push({ node: block, index: seq.index, top: y, height: h }); + + for (let t = 0; t < tasksSorted.length; t++) { + // Render inside bordered content area + const lineTop = t + 1; + const task = tasksSorted[t]; + if (!task) continue; + const node = box({ + parent: block, + top: lineTop, + left: 1, + right: 1, + height: 1, + tags: true, + content: ` ${task.id} - ${task.title}`, + }); + taskLines.push({ node, globalIndex: global++, seqIdx: s, taskIdx: t, absTop: y + lineTop }); + } + + y += h + 1; // 1 line gap between blocks + } + + screen.append(container); + + // Footer hint + const footer = box({ + bottom: 0, + left: 0, + right: 0, + height: 1, + tags: true, + style: { bg: "black", fg: "gray" }, + content: " ↑/↓ navigate Β· Enter view Β· m move Β· q quit Β· Esc close popup/quit ", + }); + screen.append(footer); + + // Navigation and keybindings + let selected = 0; + let popupOpen = false; + let moveMode = false; + + type MoveTarget = { kind: "unsequenced" } | { kind: "sequence"; seqIndex: number } | { kind: "between"; k: number }; + + // Build move targets: optional Unsequenced, and interleaved sequence + between K and K+1 (no top/bottom) + const seqIdxs = data.sequences.map((s) => s.index); + const moveTargets: MoveTarget[] = []; + if (data.unsequenced.length > 0) moveTargets.push({ kind: "unsequenced" }); + for (let i = 0; i < seqIdxs.length; i++) { + const seqIndex = seqIdxs[i] as number; + moveTargets.push({ kind: "sequence", seqIndex }); + // Drop zone only between this sequence and the next + if (i < seqIdxs.length - 1) moveTargets.push({ kind: "between", k: seqIndex }); + } + let targetPos = 0; + + // Drop zone overlay boxes (visible only in move mode) + const dropZoneBoxes = new Map<number, BoxInterface>(); + + function _pickNumber(arr: number[], idx: number, fallback: number): number { + const v = arr[idx]; + return typeof v === "number" ? v : fallback; + } + + function hideDropZones() { + for (const [, node] of dropZoneBoxes) node.destroy(); + dropZoneBoxes.clear(); + } + + function ensureDropZoneOverlays() { + hideDropZones(); + if (!moveMode) return; + // Build overlays using sequence blocks only (index > 0) + const seqOnly = seqBlocks.filter((b) => b.index > 0).sort((a, b) => a.index - b.index); + if (seqOnly.length === 0) return; + // between each pair (k = index of upper sequence) + for (let i = 0; i < seqOnly.length - 1; i++) { + const prev = seqOnly[i]; + if (!prev) continue; + const yPos = prev.top + prev.height; // gap line between blocks + const k = prev.index; // between Sequence k and k+1 + const node = box({ + parent: container, + top: yPos, + left: 0, + right: 0, + height: 1, + style: { bg: "black", fg: "gray" }, + content: ` β–Ό Drop between Sequence ${k} and ${k + 1} `, + }); + dropZoneBoxes.set(k, node); + } + // No top/bottom overlays + } + + function moveFooterText(): string { + const tgt = moveTargets[targetPos]; + let suffix = ""; + if (tgt) { + if (tgt.kind === "unsequenced") suffix = " Β· Target: Unsequenced"; + else if (tgt.kind === "sequence") suffix = ` Β· Target: Sequence ${tgt.seqIndex}`; + else if (tgt.kind === "between") suffix = ` Β· Target: Between Sequence ${tgt.k} and ${tgt.k + 1}`; + } + return ` Move mode: ↑/↓ choose target Β· Enter apply Β· Esc cancel${suffix} `; + } + function refreshHighlight() { + for (const tl of taskLines) { + const seq = data.sequences[tl.seqIdx]; + const isUnseq = tl.seqIdx === -1; + const task = isUnseq ? data.unsequenced[tl.taskIdx] : seq?.tasks[tl.taskIdx]; + if (!task) continue; + const prefix = moveMode && tl.globalIndex === selected ? "->" : " "; + const text = `${prefix} ${task.id} - ${task.title}`; + if (tl.globalIndex === selected && !moveMode) { + // Normal selection highlight when not in move mode + tl.node.setContent(`{inverse}${text}{/inverse}`); + } else { + tl.node.setContent(text); + } + } + // Ensure selected line is in view + const tl = taskLines.find((t) => t.globalIndex === selected); + if (tl) { + const viewTop = container.getScroll(); + const viewHeight = typeof container.height === "number" ? (container.height as number) : 0; + if (tl.absTop < viewTop + 1) { + container.scrollTo(Math.max(0, tl.absTop - 1)); + } else if (viewHeight && tl.absTop > viewTop + viewHeight - 4) { + container.scrollTo(Math.max(0, tl.absTop - viewHeight + 4)); + } + } + screen.render(); + } + + function refreshMoveIndicators() { + // Reset all to default + for (const blk of seqBlocks) { + blk.node.style = { ...(blk.node.style || {}), border: { fg: "cyan" } } as unknown; + } + // Reset overlays + for (const [, dz] of dropZoneBoxes) dz.style = { ...(dz.style || {}), fg: "gray" } as unknown; + if (moveMode) { + const tgt = moveTargets[targetPos]; + if (tgt?.kind === "sequence") { + for (const blk of seqBlocks) { + if (blk.index === tgt.seqIndex) { + blk.node.style = { + ...(blk.node.style || {}), + border: { fg: "yellow", /* pseudo-thicker */ bold: true }, + } as unknown; + } + } + } else if (tgt?.kind === "between") { + const k = tgt.k; + // Do not highlight adjacent sequences for drop-zones; only the drop-zone line itself + const dz = dropZoneBoxes.get(k); + if (dz) dz.style = { ...(dz.style || {}), fg: "yellow" } as unknown; + } + } + screen.render(); + } + + function move(delta: number) { + if (popupOpen) return; + if (moveMode) { + const nextPos = Math.max(0, Math.min(moveTargets.length - 1, targetPos + delta)); + targetPos = nextPos; + refreshHighlight(); + refreshMoveIndicators(); + return; + } + if (taskLines.length === 0) return; + selected = Math.max(0, Math.min(taskLines.length - 1, selected + delta)); + refreshHighlight(); + } + + async function openDetail() { + if (!core) return; + const item = taskLines.find((t) => t.globalIndex === selected); + if (!item) return; + const seq = data.sequences[item.seqIdx]; + const task = item.seqIdx === -1 ? data.unsequenced[item.taskIdx] : seq?.tasks[item.taskIdx]; + if (!task) return; + if (popupOpen) return; + popupOpen = true; + + const popup = await createTaskPopup(screen, task); + if (!popup) { + popupOpen = false; + return; + } + const { contentArea, close } = popup; + contentArea.key(["escape", "q"], () => { + popupOpen = false; + close(); + container.focus(); + }); + screen.render(); + } + + container.focus(); + screen.key(["q", "C-c"], () => screen.destroy()); + // Unified Esc: popup closes itself; else cancel move mode, else quit + screen.key(["escape"], () => { + if (popupOpen) return; + if (moveMode) { + moveMode = false; + footer.setContent(" ↑/↓ navigate Β· Enter view Β· m move Β· q quit Β· Esc close popup/quit "); + hideDropZones(); + refreshHighlight(); + refreshMoveIndicators(); + return; + } + screen.destroy(); + }); + screen.key(["up", "k"], () => move(-1)); + screen.key(["down", "j"], () => move(1)); + // Toggle move mode with 'm' + screen.key(["m", "M"], () => { + if (popupOpen) return; + moveMode = !moveMode; + // Default target is the selected task's current sequence + const item = taskLines.find((t) => t.globalIndex === selected); + if (item) { + if (item.seqIdx === -1) { + // If unsequenced, select Unsequenced target when available, else top-between + const pos = moveTargets.findIndex((t) => t.kind === "unsequenced"); + targetPos = + pos >= 0 + ? pos + : Math.max( + 0, + moveTargets.findIndex((t) => t.kind === "between" && t.k === 0), + ); + } else { + const seqIndex = data.sequences[item.seqIdx]?.index; + const pos = moveTargets.findIndex((t) => t.kind === "sequence" && t.seqIndex === seqIndex); + targetPos = pos >= 0 ? pos : 0; + } + } + // Update footer to indicate mode and overlays + footer.setContent( + moveMode ? moveFooterText() : " ↑/↓ navigate Β· Enter view Β· m move Β· q quit Β· Esc close popup/quit ", + ); + ensureDropZoneOverlays(); + refreshHighlight(); + refreshMoveIndicators(); + }); + screen.key(["enter"], async () => { + if (!moveMode) { + await openDetail(); + return; + } + if (!core) return; + const item = taskLines.find((t) => t.globalIndex === selected); + if (!item) return; + const seq2 = data.sequences[item.seqIdx]; + const task = item.seqIdx === -1 ? data.unsequenced[item.taskIdx] : seq2?.tasks[item.taskIdx]; + if (!task) return; + // Persist changes based on target + const allTasks = await core.queryTasks(); + const tgt = moveTargets[targetPos]; + if (tgt?.kind === "unsequenced") { + const { planMoveToUnsequenced } = await import("../core/sequences.ts"); + const res = planMoveToUnsequenced(allTasks, task.id); + if (!res.ok) { + footer.setContent(` ${res.error} Β· Esc cancel `); + screen.render(); + return; + } + await core.updateTasksBulk(res.changed, `Move ${task.id} to Unsequenced`); + } else if (tgt?.kind === "sequence") { + const { planMoveToSequence } = await import("../core/sequences.ts"); + const changed = planMoveToSequence(allTasks, data.sequences, task.id, tgt.seqIndex); + if (changed.length > 0) await core.updateTasksBulk(changed, `Update dependencies/order for move of ${task.id}`); + } else if (tgt?.kind === "between") { + const { adjustDependenciesForInsertBetween } = await import("../core/sequences.ts"); + const updated = adjustDependenciesForInsertBetween(allTasks, data.sequences, task.id, tgt.k); + const byIdOrig = new Map(allTasks.map((t) => [t.id, t])); + const changed: Task[] = []; + for (const u of updated) { + const orig = byIdOrig.get(u.id); + if (!orig) continue; + const depsChanged = JSON.stringify(orig.dependencies) !== JSON.stringify(u.dependencies); + const ordChanged = (orig.ordinal ?? null) !== (u.ordinal ?? null); + if (depsChanged || ordChanged) changed.push(u); + } + if (changed.length > 0) { + await core.updateTasksBulk(changed, `Insert new sequence via drop between for ${task.id}`); + } + } + // Reload and rerender + const tasksNew = await core.queryTasks(); + const active = tasksNew.filter((t) => (t.status || "").toLowerCase() !== "done"); + const { computeSequences: recompute } = await import("../core/sequences.ts"); + const next = recompute(active); + screen.destroy(); + await runSequencesView(next, core); + }); + + refreshHighlight(); + ensureDropZoneOverlays(); + refreshMoveIndicators(); +} diff --git a/src/ui/simple-unified-view.ts b/src/ui/simple-unified-view.ts new file mode 100644 index 0000000..b935de2 --- /dev/null +++ b/src/ui/simple-unified-view.ts @@ -0,0 +1,142 @@ +/** + * Simplified unified view that manages a single screen for Tab switching + */ + +import type { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; +import { renderBoardTui } from "./board.ts"; +import { viewTaskEnhanced } from "./task-viewer-with-search.ts"; +import type { ViewType } from "./view-switcher.ts"; + +export interface SimpleUnifiedViewOptions { + core: Core; + initialView: ViewType; + selectedTask?: Task; + tasks?: Task[]; + filter?: { + status?: string; + assignee?: string; + priority?: string; + sort?: string; + title?: string; + filterDescription?: string; + }; + preloadedKanbanData?: { + tasks: Task[]; + statuses: string[]; + }; +} + +/** + * Simple unified view that handles Tab switching without multiple screens + */ +export async function runSimpleUnifiedView(options: SimpleUnifiedViewOptions): Promise<void> { + let currentView = options.initialView; + let selectedTask = options.selectedTask; + let isRunning = true; + + // Simple state management without complex ViewSwitcher + const switchView = async (): Promise<void> => { + if (!isRunning) return; + + switch (currentView) { + case "task-list": + case "task-detail": + // Switch to kanban + currentView = "kanban"; + await showKanbanBoard(); + break; + case "kanban": + // Always go to task-list view when switching from board, keeping selected task highlighted + currentView = "task-list"; + await showTaskView(); + break; + } + }; + + const showTaskView = async (): Promise<void> => { + // Extra safeguard: filter out any tasks without proper IDs + const validTasks = (options.tasks || []).filter((t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-")); + + if (!validTasks || validTasks.length === 0) { + console.log("No tasks available."); + isRunning = false; + return; + } + + const taskToView = selectedTask || validTasks[0]; + if (!taskToView) { + isRunning = false; + return; + } + + // Show task viewer with simple view switching + await viewTaskEnhanced(taskToView, { + tasks: validTasks, + core: options.core, + title: options.filter?.title, + filterDescription: options.filter?.filterDescription, + startWithDetailFocus: currentView === "task-detail", + // Use a simple callback instead of complex ViewSwitcher + onTaskChange: (newTask) => { + selectedTask = newTask; + currentView = "task-detail"; + }, + // Custom Tab handler + onTabPress: async () => { + await switchView(); + }, + }); + + isRunning = false; + }; + + const showKanbanBoard = async (): Promise<void> => { + let kanbanTasks: Task[]; + let statuses: string[]; + + if (options.preloadedKanbanData) { + // Use preloaded data but filter for valid tasks + kanbanTasks = options.preloadedKanbanData.tasks.filter( + (t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-"), + ); + statuses = options.preloadedKanbanData.statuses; + } else { + // This shouldn't happen in practice since CLI preloads, but fallback + const validKanbanTasks = (options.tasks || []).filter( + (t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-"), + ); + kanbanTasks = validKanbanTasks.map((t) => ({ ...t, source: "local" as const })); + const config = await options.core.filesystem.loadConfig(); + statuses = config?.statuses || []; + } + + const config = await options.core.filesystem.loadConfig(); + const layout = "horizontal" as const; + const maxColumnWidth = config?.maxColumnWidth || 20; + + // Show kanban board with simple view switching + await renderBoardTui(kanbanTasks, statuses, layout, maxColumnWidth, { + onTaskSelect: (task) => { + selectedTask = task; + }, + // Custom Tab handler + onTabPress: async () => { + await switchView(); + }, + }); + + isRunning = false; + }; + + // Start with the initial view + switch (options.initialView) { + case "task-list": + case "task-detail": + await showTaskView(); + break; + case "kanban": + await showKanbanBoard(); + break; + } +} diff --git a/src/ui/splash.ts b/src/ui/splash.ts new file mode 100644 index 0000000..b5326ea --- /dev/null +++ b/src/ui/splash.ts @@ -0,0 +1,103 @@ +// Simple splash screen renderer for bare `backlog` invocations +// Focus: fast, TUI-friendly, graceful fallback to plain text + +type SplashOptions = { + version: string; + initialized: boolean; + plain?: boolean; + color?: boolean; +}; + +function colorize(enabled: boolean | undefined, code: string, text: string) { + if (!enabled) return text; + return `\x1b[${code}m${text}\x1b[0m`; +} + +const bold = (c: boolean | undefined, s: string) => colorize(c, "1", s); +const dim = (c: boolean | undefined, s: string) => colorize(c, "2", s); +const cyan = (c: boolean | undefined, s: string) => colorize(c, "36", s); +const green = (c: boolean | undefined, s: string) => colorize(c, "32", s); +const _magenta = (c: boolean | undefined, s: string) => colorize(c, "35", s); + +// Removed terminal theme heuristics; keep splash accent simple and consistent + +function getWideLogoLines(): string[] { + // 79 columns wide banner using block characters (fits 80x24) + return [ + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— ", + "β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β• β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—", + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•¦β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β•šβ•β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•β• β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘", + "β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘", + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•¦β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β•šβ•β• β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•", + "β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β• β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β•β• β•šβ•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β• ", + ]; +} + +function getNarrowLogoLines(color: boolean | undefined): string[] { + // Minimal fallback for very narrow terminals + return [bold(color, "Backlog.md")]; +} + +// Terminal hyperlinks (OSC 8). Safely ignored by terminals that don't support them. +function osc8(text: string, url: string, enabled: boolean): string { + if (!enabled) return text; + const start = `\u001B]8;;${url}\u0007`; + const end = "\u001B]8;;\u0007"; + return `${start}${text}${end}`; +} + +export async function printSplash(opts: SplashOptions): Promise<void> { + const { version, initialized, plain, color } = opts; + + const width = Math.max(0, Number(process.stdout.columns || 0)); + // Fixed accent color; no terminal theme detection + const accent = cyan; + + // Use wide banner only for proper widths; otherwise keep it minimal + const useWide = !plain && (width === 0 || width >= 80); + + const lines: string[] = []; + + if (useWide) { + // Add an empty line before the logo for breathing room + lines.push(""); + lines.push(...getWideLogoLines()); + lines.push(""); + lines.push(`${bold(color, "Backlog.md")} ${dim(color, `v${version}`)}`); + } else if (!plain && (width === 0 || width >= 20)) { + // Also add space before the narrow logo variant + lines.push(""); + lines.push(...getNarrowLogoLines(color)); + lines.push(dim(color, `v${version}`)); + } else { + lines.push(`${bold(color, "Backlog.md")} v${version}`); + } + + lines.push(""); + + if (!initialized) { + lines.push(bold(color, "Not initialized")); + lines.push(` ${green(color, "backlog init")} ${dim(color, "Initialize Backlog.md in this repo")}`); + } else { + lines.push(bold(color, "Quickstart")); + lines.push( + ` ${accent(color, 'backlog task create "Title" -d "Description"')} ${dim(color, "Create a new task")}`, + ); + lines.push(` ${accent(color, "backlog task list --plain")} ${dim(color, "List tasks (plain text)")}`); + lines.push(` ${accent(color, "backlog board")} ${dim(color, "Open the TUI Kanban board")}`); + lines.push(` ${accent(color, "backlog browser")} ${dim(color, "Start the web UI")}`); + lines.push(` ${accent(color, "backlog overview")} ${dim(color, "Show project statistics")}`); + } + + lines.push(""); + const linkTarget = "https://backlog.md"; + // Enable hyperlink on TTY regardless of color; respect --plain + const hyperlinkEnabled = !!process.stdout.isTTY && !plain; + const clickable = osc8(linkTarget, linkTarget, hyperlinkEnabled); + lines.push(`${bold(color, "Docs:")} ${clickable}`); + // Add a trailing blank line for visual spacing + lines.push(""); + + // Print and return; do not start any UI loop + for (const l of lines) process.stdout.write(`${l}\n`); +} diff --git a/src/ui/status-icon.ts b/src/ui/status-icon.ts new file mode 100644 index 0000000..10fa6d8 --- /dev/null +++ b/src/ui/status-icon.ts @@ -0,0 +1,53 @@ +/* Status icon and color mappings for consistent UI display */ + +export interface StatusStyle { + icon: string; + color: string; +} + +/** + * Get the icon and color for a given status + * @param status - The task status + * @returns The icon and color for the status + */ +export function getStatusStyle(status: string): StatusStyle { + const statusMap: Record<string, StatusStyle> = { + Done: { icon: "βœ”", color: "green" }, + "In Progress": { icon: "β—’", color: "yellow" }, + Blocked: { icon: "●", color: "red" }, + "To Do": { icon: "β—‹", color: "white" }, + Review: { icon: "β—†", color: "blue" }, + Testing: { icon: "β–£", color: "cyan" }, + }; + + // Return the mapped style or default for unknown statuses + return statusMap[status] || { icon: "β—‹", color: "white" }; +} + +/** + * Get just the color for a status (for backward compatibility) + * @param status - The task status + * @returns The color for the status + */ +export function getStatusColor(status: string): string { + return getStatusStyle(status).color; +} + +/** + * Get just the icon for a status + * @param status - The task status + * @returns The icon for the status + */ +export function getStatusIcon(status: string): string { + return getStatusStyle(status).icon; +} + +/** + * Format a status with its icon + * @param status - The task status + * @returns The formatted status string with icon + */ +export function formatStatusWithIcon(status: string): string { + const style = getStatusStyle(status); + return `${style.icon} ${status}`; +} diff --git a/src/ui/task-viewer-with-search.ts b/src/ui/task-viewer-with-search.ts new file mode 100644 index 0000000..d8417b7 --- /dev/null +++ b/src/ui/task-viewer-with-search.ts @@ -0,0 +1,1423 @@ +/* Task viewer with search/filter header UI */ + +import { stdout as output } from "node:process"; +import type { + BoxInterface, + LineInterface, + ListInterface, + ScreenInterface, + ScrollableTextInterface, +} from "neo-neo-bblessed"; +import { box, line, list, scrollabletext, textbox } from "neo-neo-bblessed"; +import { Core } from "../core/backlog.ts"; +import { + buildAcceptanceCriteriaItems, + formatDateForDisplay, + formatTaskPlainText, +} from "../formatters/task-plain-text.ts"; +import type { Task, TaskSearchResult } from "../types/index.ts"; +import { createTaskSearchIndex } from "../utils/task-search.ts"; +import { formatChecklistItem } from "./checklist.ts"; +import { transformCodePaths } from "./code-path.ts"; +import { createGenericList, type GenericList } from "./components/generic-list.ts"; +import { formatHeading } from "./heading.ts"; +import { createLoadingScreen } from "./loading.ts"; +import { formatStatusWithIcon, getStatusColor } from "./status-icon.ts"; +import { createScreen } from "./tui.ts"; + +type SelectedStyle = { bg?: string; fg?: string }; + +type SelectableList = Pick<ListInterface, "style">; + +interface KeypressEvent { + name?: string; +} + +function resolveListIndex(args: unknown[]): number { + if (typeof args[1] === "number") { + return args[1]; + } + if (typeof args[0] === "number") { + return args[0]; + } + return 0; +} + +function setSelectedColors(list: SelectableList, colors: SelectedStyle): void { + const style = list.style as StyleWithSelected; + style.selected = { ...(style.selected ?? {}), ...colors }; +} + +interface StyleWithSelected { + selected?: SelectedStyle; + [key: string]: unknown; +} + +type BorderCapable = Pick<BoxInterface, "style">; + +function setBorderColor(element: BorderCapable, color: string): void { + const style = element.style as { border?: { fg?: string } }; + style.border = { ...(style.border ?? {}), fg: color }; +} + +function getPriorityDisplay(priority?: "high" | "medium" | "low"): string { + switch (priority) { + case "high": + return " {red-fg}●{/}"; + case "medium": + return " {yellow-fg}●{/}"; + case "low": + return " {green-fg}●{/}"; + default: + return ""; + } +} + +/** + * Display task details with search/filter header UI + */ +export async function viewTaskEnhanced( + task: Task, + options: { + tasks?: Task[]; + core?: Core; + title?: string; + filterDescription?: string; + searchQuery?: string; + statusFilter?: string; + priorityFilter?: string; + startWithDetailFocus?: boolean; + startWithSearchFocus?: boolean; + viewSwitcher?: import("./view-switcher.ts").ViewSwitcher; + onTaskChange?: (task: Task) => void; + onTabPress?: () => Promise<void>; + onFilterChange?: (filters: { searchQuery: string; statusFilter: string; priorityFilter: string }) => void; + } = {}, +): Promise<void> { + if (output.isTTY === false) { + console.log(formatTaskPlainText(task)); + return; + } + + // Get project root and setup services + const cwd = process.cwd(); + const core = options.core || new Core(cwd, { enableWatchers: true }); + + // Show loading screen while loading tasks (can be slow with cross-branch loading) + let allTasks: Task[]; + let statuses: string[]; + let priorities: string[]; + // When tasks are provided, use in-memory search; otherwise use ContentStore-backed search + let taskSearchIndex: ReturnType<typeof createTaskSearchIndex> | null = null; + let searchService: Awaited<ReturnType<typeof core.getSearchService>> | null = null; + let contentStore: Awaited<ReturnType<typeof core.getContentStore>> | null = null; + + if (options.tasks) { + // Tasks already provided - use in-memory search (no ContentStore loading) + allTasks = options.tasks.filter((t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-")); + const config = await core.filesystem.loadConfig(); + statuses = config?.statuses || ["To Do", "In Progress", "Done"]; + priorities = ["high", "medium", "low"]; + taskSearchIndex = createTaskSearchIndex(allTasks); + } else { + // Need to load tasks - show loading screen + const loadingScreen = await createLoadingScreen("Loading tasks"); + try { + loadingScreen?.update("Loading configuration..."); + const config = await core.filesystem.loadConfig(); + statuses = config?.statuses || ["To Do", "In Progress", "Done"]; + priorities = ["high", "medium", "low"]; + + loadingScreen?.update("Loading tasks from branches..."); + contentStore = await core.getContentStore(); + searchService = await core.getSearchService(); + + loadingScreen?.update("Preparing task list..."); + const tasks = await core.queryTasks(); + allTasks = tasks.filter((t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-")); + } finally { + await loadingScreen?.close(); + } + } + + // State for filtering - normalize filters to match configured values + let searchQuery = options.searchQuery || ""; + + // Find the canonical status value from configured statuses (case-insensitive) + let statusFilter = ""; + if (options.statusFilter) { + const lowerFilter = options.statusFilter.toLowerCase(); + const matchedStatus = statuses.find((s) => s.toLowerCase() === lowerFilter); + statusFilter = matchedStatus || ""; + } + + // Priority is already lowercase + let priorityFilter = options.priorityFilter || ""; + let filteredTasks = [...allTasks]; + const filtersActive = Boolean(searchQuery || statusFilter || priorityFilter); + let requireInitialFilterSelection = filtersActive; + + // Find the initial selected task + let currentSelectedTask = task; + let selectionRequestId = 0; + let noResultsMessage: string | null = null; + + const screen = createScreen({ title: options.title || "Backlog Tasks" }); + + // Main container + const container = box({ + parent: screen, + width: "100%", + height: "100%", + }); + + // Create header box for search/filter controls - takes up top 3 lines + const headerBox = box({ + parent: container, + top: 0, + left: 0, + width: "100%", + height: 3, + border: { + type: "line", + }, + style: { + border: { fg: "cyan" }, + }, + label: "\u00A0Search & Filters\u00A0", + }); + + // Search label + box({ + parent: headerBox, + content: "Search:", + top: 0, + left: 1, + width: 7, + height: 1, + tags: true, + }); + + // Search input textbox - use inputOnFocus for automatic input mode + const searchInput = textbox({ + parent: headerBox, + value: searchQuery, + top: 0, + left: 9, + width: "30%", + height: 1, + inputOnFocus: true, // Automatically enter input mode on focus + mouse: true, + keys: true, + ignoreKeys: ["tab"], // Ignore tab key to allow navigation + style: { + fg: "white", + bg: "black", + focus: { + fg: "black", + bg: "cyan", + bold: true, + }, + }, + }); + + // Status filter label + box({ + parent: headerBox, + content: "Status:", + top: 0, + left: "42%", + width: 7, + height: 1, + tags: true, + }); + + // Status selector with dropdown arrow + // Calculate initial selected index for status filter + const initialStatusIndex = statusFilter ? statuses.indexOf(statusFilter) + 1 : 0; + + const statusSelector = list({ + parent: headerBox, + items: ["All β–Ό", ...statuses.map((s) => `${s} `)], + selected: initialStatusIndex >= 0 ? initialStatusIndex : 0, + top: 0, + left: "50%", + width: 15, + height: 1, + mouse: true, + keys: true, + interactive: true, + style: { + fg: "white", + bg: "black", + selected: { + bg: "black", // Default to no highlight + fg: "white", + }, + item: { + hover: { + bg: "blue", + }, + }, + }, + }); + + // Priority filter label + box({ + parent: headerBox, + content: "Priority:", + top: 0, + left: "67%", + width: 9, + height: 1, + tags: true, + }); + + // Priority selector with dropdown arrow + // Calculate initial selected index for priority filter + const initialPriorityIndex = priorityFilter ? priorities.indexOf(priorityFilter) + 1 : 0; + + const prioritySelector = list({ + parent: headerBox, + items: ["All β–Ό", "high ", "medium ", "low "], + selected: initialPriorityIndex >= 0 ? initialPriorityIndex : 0, + top: 0, + left: "77%", + width: 10, + height: 1, + mouse: true, + keys: true, + interactive: true, + style: { + fg: "white", + bg: "black", + selected: { + bg: "black", // Default to no highlight + fg: "white", + }, + item: { + hover: { + bg: "blue", + }, + }, + }, + }); + + // Set initial selections + statusSelector.select(statusFilter ? statuses.indexOf(statusFilter) + 1 : 0); + prioritySelector.select(priorityFilter ? priorities.indexOf(priorityFilter) + 1 : 0); + + // Task list pane (left 40%) + const taskListPane = box({ + parent: container, + top: 3, + left: 0, + width: "40%", + height: "100%-4", // Account for header and help bar + border: { + type: "line", + }, + style: { + border: { fg: "gray" }, + }, + label: `\u00A0Tasks (${filteredTasks.length})\u00A0`, + }); + + // Detail pane - use right: 0 to ensure it extends to window edge like the header + const detailPane = box({ + parent: container, + top: 3, + left: "40%", + right: 0, // Extend to right edge instead of calculating width + height: "100%-4", + border: { + type: "line", + }, + style: { + border: { fg: "gray" }, + }, + label: "\u00A0Details\u00A0", + }); + + function setActivePane(active: "list" | "detail" | "none") { + const listBorder = taskListPane.style as { border?: { fg?: string } }; + const detailBorder = detailPane.style as { border?: { fg?: string } }; + if (listBorder.border) listBorder.border.fg = active === "list" ? "yellow" : "gray"; + if (detailBorder.border) detailBorder.border.fg = active === "detail" ? "yellow" : "gray"; + } + + function focusTaskList(): void { + if (!taskList) { + if (descriptionBox) { + currentFocus = "detail"; + setActivePane("detail"); + descriptionBox.focus(); + updateHelpBar(); + screen.render(); + } + return; + } + currentFocus = "list"; + setActivePane("list"); + taskList.focus(); + updateHelpBar(); + screen.render(); + } + + function focusDetailPane(): void { + if (!descriptionBox) return; + currentFocus = "detail"; + setActivePane("detail"); + descriptionBox.focus(); + updateHelpBar(); + screen.render(); + } + + // Helper to notify filter changes + function notifyFilterChange() { + if (options.onFilterChange) { + options.onFilterChange({ + searchQuery, + statusFilter, + priorityFilter, + }); + } + } + + // Function to apply filters and refresh the task list + function applyFilters() { + // Check for non-empty search query or active filters + if (searchQuery.trim() || statusFilter || priorityFilter) { + // Use in-memory search if available, otherwise use ContentStore-backed search + if (taskSearchIndex) { + filteredTasks = taskSearchIndex.search({ + query: searchQuery, + status: statusFilter || undefined, + priority: priorityFilter as "high" | "medium" | "low" | undefined, + }); + } else if (searchService) { + const searchResults = searchService.search({ + query: searchQuery, + filters: { + status: statusFilter || undefined, + priority: priorityFilter as "high" | "medium" | "low" | undefined, + }, + types: ["task"], + }); + filteredTasks = searchResults.filter((r): r is TaskSearchResult => r.type === "task").map((r) => r.task); + } else { + filteredTasks = [...allTasks]; + } + } else { + // No filters, show all tasks + filteredTasks = [...allTasks]; + } + + // Update the task list label + if (taskListPane.setLabel) { + taskListPane.setLabel(`\u00A0Tasks (${filteredTasks.length})\u00A0`); + } + + if (filteredTasks.length === 0) { + if (taskList) { + taskList.destroy(); + taskList = null; + } + const activeFilters: string[] = []; + const trimmedQuery = searchQuery.trim(); + if (trimmedQuery) { + activeFilters.push(`Search: {cyan-fg}${trimmedQuery}{/}`); + } + if (statusFilter) { + activeFilters.push(`Status: {cyan-fg}${statusFilter}{/}`); + } + if (priorityFilter) { + activeFilters.push(`Priority: {cyan-fg}${priorityFilter}{/}`); + } + let listPaneMessage: string; + if (activeFilters.length > 0) { + noResultsMessage = `{bold}No tasks match your current filters{/bold}\n${activeFilters.map((f) => ` β€’ ${f}`).join("\n")}\n\n{gray-fg}Try adjusting the search or clearing filters.{/}`; + listPaneMessage = `{bold}No matching tasks{/bold}\n\n${activeFilters.map((f) => ` β€’ ${f}`).join("\n")}`; + } else { + noResultsMessage = + "{bold}No tasks available{/bold}\n{gray-fg}Create a task with {cyan-fg}backlog task create{/cyan-fg}.{/}"; + listPaneMessage = "{bold}No tasks available{/bold}"; + } + showListEmptyState(listPaneMessage); + refreshDetailPane(); + screen.render(); + return; + } + + noResultsMessage = null; + hideListEmptyState(); + + if (taskList) { + taskList.destroy(); + taskList = null; + } + const listController = createTaskList(); + taskList = listController; + if (listController) { + const forceFirst = requireInitialFilterSelection; + let desiredIndex = filteredTasks.findIndex((t) => t.id === currentSelectedTask.id); + if (forceFirst || desiredIndex < 0) { + desiredIndex = 0; + } + const currentIndexRaw = listController.getSelectedIndex(); + const currentIndex = Array.isArray(currentIndexRaw) ? (currentIndexRaw[0] ?? 0) : currentIndexRaw; + if (forceFirst || currentIndex !== desiredIndex) { + listController.setSelectedIndex(desiredIndex); + } + requireInitialFilterSelection = false; + } + + // Ensure detail pane is refreshed when transitioning from no-results to results + refreshDetailPane(); + screen.render(); + } + + // Task list component + let taskList: GenericList<Task> | null = null; + let listEmptyStateBox: BoxInterface | null = null; + + function showListEmptyState(message: string) { + if (listEmptyStateBox) { + listEmptyStateBox.destroy(); + } + listEmptyStateBox = box({ + parent: taskListPane, + top: 1, + left: 1, + width: "100%-4", + height: "100%-3", + content: message, + tags: true, + style: { + fg: "gray", + }, + }); + } + + function hideListEmptyState() { + if (listEmptyStateBox) { + listEmptyStateBox.destroy(); + listEmptyStateBox = null; + } + } + + async function applySelection(selectedTask: Task | null) { + if (!selectedTask) return; + if (currentSelectedTask && selectedTask.id === currentSelectedTask.id) { + return; + } + currentSelectedTask = selectedTask; + options.onTaskChange?.(selectedTask); + const requestId = ++selectionRequestId; + refreshDetailPane(); + screen.render(); + const refreshed = await core.getTask(selectedTask.id); + if (requestId !== selectionRequestId) { + return; + } + if (refreshed) { + currentSelectedTask = refreshed; + options.onTaskChange?.(refreshed); + } + refreshDetailPane(); + screen.render(); + } + + function createTaskList(): GenericList<Task> | null { + const initialIndex = Math.max( + 0, + filteredTasks.findIndex((t) => t.id === currentSelectedTask.id), + ); + + taskList = createGenericList<Task>({ + parent: taskListPane, + title: "", + items: filteredTasks, + selectedIndex: initialIndex, + border: false, + top: 1, + left: 1, + width: "100%-4", + height: "100%-3", + itemRenderer: (task: Task) => { + const statusIcon = formatStatusWithIcon(task.status); + const statusColor = getStatusColor(task.status); + const assigneeText = task.assignee?.length + ? ` {cyan-fg}${task.assignee[0]?.startsWith("@") ? task.assignee[0] : `@${task.assignee[0]}`}{/}` + : ""; + const labelsText = task.labels?.length ? ` {yellow-fg}[${task.labels.join(", ")}]{/}` : ""; + const priorityText = getPriorityDisplay(task.priority); + const isCrossBranch = Boolean((task as Task & { branch?: string }).branch); + const branchText = isCrossBranch ? ` {green-fg}(${(task as Task & { branch?: string }).branch}){/}` : ""; + + const content = `{${statusColor}-fg}${statusIcon}{/} {bold}${task.id}{/bold} - ${task.title}${priorityText}${assigneeText}${labelsText}${branchText}`; + // Dim cross-branch tasks to indicate read-only status + return isCrossBranch ? `{gray-fg}${content}{/}` : content; + }, + onSelect: (selected: Task | Task[]) => { + const selectedTask = Array.isArray(selected) ? selected[0] : selected; + void applySelection(selectedTask || null); + }, + onHighlight: (selected: Task | null) => { + void applySelection(selected); + }, + showHelp: false, + }); + + // Focus handler for task list + if (taskList) { + const listBox = taskList.getListBox(); + listBox.on("focus", () => { + currentFocus = "list"; + setActivePane("list"); + screen.render(); + updateHelpBar(); + }); + listBox.on("blur", () => { + setActivePane("none"); + screen.render(); + }); + listBox.key(["right", "l"], () => { + focusDetailPane(); + return false; + }); + } + + return taskList; + } + + // Detail pane refresh function + let headerDetailBox: BoxInterface | undefined; + let divider: LineInterface | undefined; + let descriptionBox: ScrollableTextInterface | undefined; + + function refreshDetailPane() { + if (headerDetailBox) headerDetailBox.destroy(); + if (divider) divider.destroy(); + if (descriptionBox) descriptionBox.destroy(); + + const configureDetailBox = (boxInstance: ScrollableTextInterface) => { + descriptionBox = boxInstance; + const scrollable = boxInstance as unknown as { + scroll?: (offset: number) => void; + setScroll?: (offset: number) => void; + setScrollPerc?: (perc: number) => void; + }; + + const pageAmount = () => { + const height = typeof boxInstance.height === "number" ? boxInstance.height : 0; + return height > 0 ? Math.max(1, height - 3) : 0; + }; + + boxInstance.key(["pageup", "b"], () => { + const delta = pageAmount(); + if (delta > 0) { + scrollable.scroll?.(-delta); + screen.render(); + } + return false; + }); + boxInstance.key(["pagedown", "space"], () => { + const delta = pageAmount(); + if (delta > 0) { + scrollable.scroll?.(delta); + screen.render(); + } + return false; + }); + boxInstance.key(["home", "g"], () => { + scrollable.setScroll?.(0); + screen.render(); + return false; + }); + boxInstance.key(["end", "G"], () => { + scrollable.setScrollPerc?.(100); + screen.render(); + return false; + }); + boxInstance.on("focus", () => { + currentFocus = "detail"; + setActivePane("detail"); + updateHelpBar(); + screen.render(); + }); + boxInstance.on("blur", () => { + if (currentFocus !== "detail") { + setActivePane(currentFocus === "list" ? "list" : "none"); + screen.render(); + } + }); + boxInstance.key(["left", "h"], () => { + focusTaskList(); + return false; + }); + boxInstance.key(["escape"], () => { + focusTaskList(); + return false; + }); + if (currentFocus === "detail") { + setImmediate(() => boxInstance.focus()); + } + }; + + if (noResultsMessage) { + screen.title = options.title || "Backlog Tasks"; + + headerDetailBox = box({ + parent: detailPane, + top: 0, + left: 1, + right: 1, + height: "shrink", + tags: true, + wrap: true, + scrollable: false, + padding: { left: 1, right: 1 }, + content: "{bold}No tasks to display{/bold}", + }); + + descriptionBox = undefined; + divider = undefined; + const messageBox = scrollabletext({ + parent: detailPane, + top: (typeof headerDetailBox.bottom === "number" ? headerDetailBox.bottom : 0) + 1, + left: 1, + right: 1, + bottom: 1, + keys: true, + vi: true, + mouse: true, + tags: true, + wrap: true, + padding: { left: 1, right: 1, top: 0, bottom: 0 }, + content: noResultsMessage, + }); + + configureDetailBox(messageBox); + screen.render(); + return; + } + + screen.title = `Task ${currentSelectedTask.id} - ${currentSelectedTask.title}`; + + const detailContent = generateDetailContent(currentSelectedTask); + + // Calculate header height based on content and available width + const detailPaneWidth = typeof detailPane.width === "number" ? detailPane.width : 60; + const availableWidth = detailPaneWidth - 6; // 2 for border, 2 for box padding, 2 for header padding + + let headerLineCount = 0; + for (const line of detailContent.headerContent) { + const plainText = line.replace(/\{[^}]+\}/g, ""); + const lineCount = Math.max(1, Math.ceil(plainText.length / availableWidth)); + headerLineCount += lineCount; + } + + headerDetailBox = box({ + parent: detailPane, + top: 0, + left: 1, + right: 1, + height: headerLineCount, + tags: true, + wrap: true, + scrollable: false, + padding: { left: 1, right: 1 }, + content: detailContent.headerContent.join("\n"), + }); + + divider = line({ + parent: detailPane, + top: headerLineCount, + left: 1, + right: 1, + orientation: "horizontal", + style: { + fg: "gray", + }, + }); + + const bodyContainer = scrollabletext({ + parent: detailPane, + top: headerLineCount + 1, + left: 1, + right: 1, + bottom: 1, + keys: true, + vi: true, + mouse: true, + tags: true, + wrap: true, + padding: { left: 1, right: 1, top: 0, bottom: 0 }, + content: detailContent.bodyContent.join("\n"), + }); + + configureDetailBox(bodyContainer); + } + + // State for tracking focus + let currentFocus: "search" | "status" | "priority" | "list" | "detail" = "list"; + + // Event handlers for search and filters + searchInput.on("submit", (value: unknown) => { + searchQuery = String(value || ""); + applyFilters(); + notifyFilterChange(); + // Don't change focus - keep search input active for continued editing + searchInput.focus(); + }); + + // Live search as user types - monitor value changes + let searchCheckInterval: Timer | null = null; + + const startSearchMonitoring = () => { + if (!searchCheckInterval) { + searchCheckInterval = setInterval(() => { + if (currentFocus === "search" && searchInput.getValue) { + const newValue = searchInput.getValue(); + if (newValue !== searchQuery) { + searchQuery = String(newValue); + applyFilters(); + notifyFilterChange(); + } + } + }, 100); // Check every 100ms for changes + } + }; + + const stopSearchMonitoring = () => { + if (searchCheckInterval) { + clearInterval(searchCheckInterval); + searchCheckInterval = null; + } + }; + + searchInput.on("cancel", () => { + // On Escape, move focus to task list + if (taskList) { + focusTaskList(); + } + }); + + // Handle status selector changes with immediate filtering + statusSelector.on("select", (...args: unknown[]) => { + const index = resolveListIndex(args); + statusFilter = index === 0 ? "" : statuses[index - 1] || ""; + applyFilters(); + notifyFilterChange(); + if (taskList) { + focusTaskList(); + } + }); + + // Live status filter on arrow navigation (no Enter needed) + statusSelector.on("select item", (...args: unknown[]) => { + const index = resolveListIndex(args); + statusFilter = index === 0 ? "" : statuses[index - 1] || ""; + applyFilters(); + notifyFilterChange(); + }); + + // Also update on keypress for immediate feedback + statusSelector.on("keypress", (_ch: string, key: KeypressEvent) => { + if (key?.name === "up" || key?.name === "down") { + setImmediate(() => { + const idx = statusSelector.selected; + statusFilter = idx !== undefined && idx === 0 ? "" : statuses[(idx ?? 0) - 1] || ""; + applyFilters(); + notifyFilterChange(); + }); + } + }); + + // Handle priority selector changes with immediate filtering + prioritySelector.on("select", (...args: unknown[]) => { + const index = resolveListIndex(args); + priorityFilter = index === 0 ? "" : priorities[index - 1] || ""; + applyFilters(); + notifyFilterChange(); + if (taskList) { + focusTaskList(); + } + }); + + // Live priority filter on arrow navigation (no Enter needed) + prioritySelector.on("select item", (...args: unknown[]) => { + const index = resolveListIndex(args); + priorityFilter = index === 0 ? "" : priorities[index - 1] || ""; + applyFilters(); + notifyFilterChange(); + }); + + // Also update on keypress for immediate feedback + prioritySelector.on("keypress", (_ch: string, key: KeypressEvent) => { + if (key?.name === "up" || key?.name === "down") { + setImmediate(() => { + const idx = prioritySelector.selected; + priorityFilter = idx !== undefined && idx === 0 ? "" : priorities[(idx ?? 0) - 1] || ""; + applyFilters(); + notifyFilterChange(); + }); + } + }); + + // Handle tab navigation from search input + searchInput.key(["tab"], () => { + // Save current value + const currentValue = searchInput.getValue ? searchInput.getValue() : searchInput.value; + searchQuery = String(currentValue || ""); + // Apply any pending search + applyFilters(); + // Cancel edit mode + searchInput.cancel(); + // Switch focus + currentFocus = "status"; + statusSelector.focus(); + updateHelpBar(); + // Prevent event from bubbling + return false; + }); + + // Handle down arrow from search input + searchInput.key(["down"], () => { + // Save current value + const currentValue = searchInput.getValue ? searchInput.getValue() : searchInput.value; + searchQuery = String(currentValue || ""); + // Apply any pending search + applyFilters(); + // Cancel edit mode + searchInput.cancel(); + // Switch to task list + if (taskList) { + focusTaskList(); + } + // Prevent event from bubbling + return false; + }); + + // Focus handlers for filters + searchInput.on("focus", () => { + currentFocus = "search"; + // Highlight header box when filter is active + setBorderColor(headerBox, "yellow"); + setActivePane("none"); + screen.render(); + updateHelpBar(); + startSearchMonitoring(); + // No need to call readInput - inputOnFocus handles it automatically + }); + + searchInput.on("blur", () => { + stopSearchMonitoring(); + // Reset header box border + if (currentFocus !== "status" && currentFocus !== "priority") { + setBorderColor(headerBox, "cyan"); + } + setActivePane(currentFocus === "detail" ? "detail" : currentFocus === "list" ? "list" : "none"); + screen.render(); + }); + + statusSelector.on("focus", () => { + currentFocus = "status"; + // Highlight header box when filter is active + setBorderColor(headerBox, "yellow"); + setActivePane("none"); + // Update style to show blue highlight when focused + setSelectedColors(statusSelector, { bg: "blue", fg: "white" }); + screen.render(); + updateHelpBar(); + }); + + statusSelector.on("blur", () => { + // Remove blue highlight when not focused + setSelectedColors(statusSelector, { bg: "black", fg: "white" }); + // Reset header box border + setBorderColor(headerBox, "cyan"); + setActivePane(currentFocus === "detail" ? "detail" : currentFocus === "list" ? "list" : "none"); + screen.render(); + }); + + prioritySelector.on("focus", () => { + currentFocus = "priority"; + // Highlight header box when filter is active + setBorderColor(headerBox, "yellow"); + setActivePane("none"); + // Update style to show blue highlight when focused + setSelectedColors(prioritySelector, { bg: "blue", fg: "white" }); + screen.render(); + updateHelpBar(); + }); + + prioritySelector.on("blur", () => { + // Remove blue highlight when not focused + setSelectedColors(prioritySelector, { bg: "black", fg: "white" }); + // Reset header box border + setBorderColor(headerBox, "cyan"); + setActivePane(currentFocus === "detail" ? "detail" : currentFocus === "list" ? "list" : "none"); + screen.render(); + }); + + // Tab navigation between search and filters + function cycleFilter(reverse = false) { + // Stop monitoring when leaving search + if (currentFocus === "search") { + stopSearchMonitoring(); + } + + if (reverse) { + switch (currentFocus) { + case "search": + currentFocus = "priority"; + prioritySelector.focus(); + break; + case "status": + currentFocus = "search"; + searchInput.focus(); + // readInput is called in the focus handler + break; + case "priority": + currentFocus = "status"; + statusSelector.focus(); + break; + default: + currentFocus = "search"; + searchInput.focus(); + // readInput is called in the focus handler + } + } else { + switch (currentFocus) { + case "search": + currentFocus = "status"; + statusSelector.focus(); + break; + case "status": + currentFocus = "priority"; + prioritySelector.focus(); + break; + case "priority": + currentFocus = "search"; + searchInput.focus(); + // readInput is called in the focus handler + break; + default: + currentFocus = "search"; + searchInput.focus(); + // readInput is called in the focus handler + } + } + updateHelpBar(); + } + + // Tab key handling within filters + // Note: searchInput tab/down are handled in the _listener override above + + statusSelector.key(["tab"], () => { + cycleFilter(); + }); + + prioritySelector.key(["tab"], () => { + cycleFilter(); + }); + + statusSelector.key(["S-tab"], () => { + cycleFilter(true); + }); + + prioritySelector.key(["S-tab"], () => { + cycleFilter(true); + }); + + // Keyboard shortcuts - use "/" as primary (standard), Ctrl+F as secondary + screen.key(["/"], () => { + // Just focus the search input - the focus handler will do the rest + searchInput.focus(); + }); + + // Also support Ctrl+F as an alternative (common in modern apps) + screen.key(["C-f"], () => { + // Just focus the search input - the focus handler will do the rest + searchInput.focus(); + }); + + // Quick access to status filter + screen.key(["s", "S"], () => { + // Just focus the status selector - the focus handler will do the rest + statusSelector.focus(); + }); + + // Quick access to priority filter + screen.key(["p", "P"], () => { + // Just focus the priority selector - the focus handler will do the rest + prioritySelector.focus(); + }); + + screen.key(["escape"], () => { + // If in search/filter mode, go back to task list + if (currentFocus !== "list") { + if (searchInput.getValue && searchInput.getValue() !== searchQuery) { + searchInput.setValue(searchQuery); + } + if (taskList) { + focusTaskList(); + } + } else { + // If already in task list, quit + stopSearchMonitoring(); + searchService?.dispose(); + contentStore?.dispose(); + screen.destroy(); + process.exit(0); + } + }); + + // Help bar at bottom + const helpBar = box({ + parent: container, + bottom: 0, + left: 0, + width: "100%", + height: 1, + tags: true, + content: "", + }); + + // Dynamic help bar content + function updateHelpBar() { + let content = ""; + + if (currentFocus === "search") { + // Search-specific help - filters apply live as you type + content = + " {cyan-fg}[Tab]{/} Next Filter | {cyan-fg}[↓]{/} Task List | {cyan-fg}[Esc]{/} Cancel | {gray-fg}(Live search){/}"; + } else if (currentFocus === "status" || currentFocus === "priority") { + // Status/Priority filter help - changes apply immediately + content = + " {cyan-fg}[Tab]{/} Next Filter | {cyan-fg}[Shift+Tab]{/} Prev Filter | {cyan-fg}[↑↓]{/} Select | {cyan-fg}[Esc]{/} Back to Tasks | {gray-fg}(Live filter){/}"; + } else if (currentFocus === "detail") { + content = " {cyan-fg}[←]{/} Task List | {cyan-fg}[↑↓]{/} Scroll | {cyan-fg}[q/Esc]{/} Quit"; + } else { + // Task list help - show all available shortcuts + content = + " {cyan-fg}[Tab]{/} Switch View | {cyan-fg}[/]{/} Search | {cyan-fg}[s]{/} Status | {cyan-fg}[p]{/} Priority | {cyan-fg}[↑↓]{/} Navigate | {cyan-fg}[q/Esc]{/} Quit"; + } + + helpBar.setContent(content); + screen.render(); + } + + // Initial help bar content + updateHelpBar(); + + // Tab key handling for view switching - only when in task list + if (options.onTabPress) { + screen.key(["tab"], async () => { + // Only switch views if we're in the task list, not in filters + if (currentFocus === "list") { + // Cleanup before switching + searchService?.dispose(); + contentStore?.dispose(); + screen.destroy(); + await options.onTabPress?.(); + } + // If in filters, Tab is handled by cycleFilter + }); + } + + // Quit handlers + screen.key(["q", "C-c"], () => { + stopSearchMonitoring(); + searchService?.dispose(); + contentStore?.dispose(); + screen.destroy(); + process.exit(0); + }); + + // Initial setup + // Apply filters first if any are set + if (filtersActive) { + applyFilters(); + } else { + taskList = createTaskList(); + } + refreshDetailPane(); + + if (options.startWithSearchFocus) { + // Start with search input focused - the focus handler will set everything up + searchInput.focus(); + } else if (options.startWithDetailFocus) { + if (descriptionBox) { + focusDetailPane(); + } + } else { + // Focus the task list initially and highlight it + const list = taskList as GenericList<Task> | null; + if (list) { + focusTaskList(); + } + } + + screen.render(); + + // Wait for screen to close + return new Promise<void>((resolve) => { + screen.on("destroy", () => { + stopSearchMonitoring(); + searchService?.dispose(); + contentStore?.dispose(); + resolve(); + }); + }); +} + +function generateDetailContent(task: Task): { headerContent: string[]; bodyContent: string[] } { + const headerContent = [ + ` {${getStatusColor(task.status)}-fg}${formatStatusWithIcon(task.status)}{/} {bold}{blue-fg}${task.id}{/blue-fg}{/bold} - ${task.title}`, + ]; + + // Add cross-branch indicator if task is from another branch + const isCrossBranch = Boolean((task as Task & { branch?: string }).branch); + if (isCrossBranch) { + const branchName = (task as Task & { branch?: string }).branch; + headerContent.push( + ` {yellow-fg}⚠ Read-only:{/} This task exists in branch {green-fg}${branchName}{/}. Switch to that branch to edit it.`, + ); + } + + const bodyContent: string[] = []; + bodyContent.push(formatHeading("Details", 2)); + + const metadata: string[] = []; + metadata.push(`{bold}Created:{/bold} ${formatDateForDisplay(task.createdDate)}`); + if (task.updatedDate && task.updatedDate !== task.createdDate) { + metadata.push(`{bold}Updated:{/bold} ${formatDateForDisplay(task.updatedDate)}`); + } + if (task.priority) { + const priorityDisplay = getPriorityDisplay(task.priority); + const priorityText = task.priority.charAt(0).toUpperCase() + task.priority.slice(1); + metadata.push(`{bold}Priority:{/bold} ${priorityText}${priorityDisplay}`); + } + if (task.assignee?.length) { + const assigneeList = task.assignee.map((a) => (a.startsWith("@") ? a : `@${a}`)).join(", "); + metadata.push(`{bold}Assignee:{/bold} {cyan-fg}${assigneeList}{/}`); + } + if (task.labels?.length) { + metadata.push(`{bold}Labels:{/bold} ${task.labels.map((l) => `{yellow-fg}[${l}]{/}`).join(" ")}`); + } + if (task.reporter) { + const reporterText = task.reporter.startsWith("@") ? task.reporter : `@${task.reporter}`; + metadata.push(`{bold}Reporter:{/bold} {cyan-fg}${reporterText}{/}`); + } + if (task.milestone) { + metadata.push(`{bold}Milestone:{/bold} {magenta-fg}${task.milestone}{/}`); + } + if (task.parentTaskId) { + metadata.push(`{bold}Parent:{/bold} {blue-fg}${task.parentTaskId}{/}`); + } + if (task.subtasks?.length) { + metadata.push(`{bold}Subtasks:{/bold} ${task.subtasks.length} task${task.subtasks.length > 1 ? "s" : ""}`); + } + if (task.dependencies?.length) { + metadata.push(`{bold}Dependencies:{/bold} ${task.dependencies.join(", ")}`); + } + + bodyContent.push(metadata.join("\n")); + bodyContent.push(""); + + bodyContent.push(formatHeading("Description", 2)); + const descriptionText = task.description?.trim(); + const descriptionContent = descriptionText + ? transformCodePaths(descriptionText) + : "{gray-fg}No description provided{/}"; + bodyContent.push(descriptionContent); + bodyContent.push(""); + + bodyContent.push(formatHeading("Acceptance Criteria", 2)); + const checklistItems = buildAcceptanceCriteriaItems(task); + if (checklistItems.length > 0) { + const formattedCriteria = checklistItems.map((item) => + formatChecklistItem( + { + text: transformCodePaths(item.text), + checked: item.checked, + }, + { + padding: " ", + checkedSymbol: "{green-fg}βœ“{/}", + uncheckedSymbol: "{gray-fg}β—‹{/}", + }, + ), + ); + bodyContent.push(formattedCriteria.join("\n")); + } else { + bodyContent.push("{gray-fg}No acceptance criteria defined{/}"); + } + bodyContent.push(""); + + const implementationPlan = task.implementationPlan?.trim(); + if (implementationPlan) { + bodyContent.push(formatHeading("Implementation Plan", 2)); + bodyContent.push(transformCodePaths(implementationPlan)); + bodyContent.push(""); + } + + const implementationNotes = task.implementationNotes?.trim(); + if (implementationNotes) { + bodyContent.push(formatHeading("Implementation Notes", 2)); + bodyContent.push(transformCodePaths(implementationNotes)); + bodyContent.push(""); + } + + return { headerContent, bodyContent }; +} + +export async function createTaskPopup( + screen: ScreenInterface, + task: Task, +): Promise<{ + background: BoxInterface; + popup: BoxInterface; + contentArea: ScrollableTextInterface; + close: () => void; +} | null> { + if (output.isTTY === false) return null; + + const popup = box({ + parent: screen, + top: "center", + left: "center", + width: "85%", + height: "80%", + border: "line", + style: { + border: { fg: "gray" }, + }, + keys: true, + tags: true, + autoPadding: true, + }); + + const background = box({ + parent: screen, + top: Number(popup.top ?? 0) - 1, + left: Number(popup.left ?? 0) - 2, + width: Number(popup.width ?? 0) + 4, + height: Number(popup.height ?? 0) + 2, + style: { + bg: "black", + }, + }); + + popup.setFront?.(); + + const { headerContent, bodyContent } = generateDetailContent(task); + + // Calculate header height based on content and available width + // Account for popup padding, border, and header padding + const popupWidth = typeof popup.width === "number" ? popup.width : 80; + const availableWidth = popupWidth - 6; // 2 for border, 2 for box padding, 2 for header padding + + // Calculate wrapped line count for header content + let headerLineCount = 0; + for (const line of headerContent) { + // Strip blessed tags for length calculation + const plainText = line.replace(/\{[^}]+\}/g, ""); + const lineCount = Math.max(1, Math.ceil(plainText.length / availableWidth)); + headerLineCount += lineCount; + } + + box({ + parent: popup, + top: 0, + left: 1, + right: 1, + height: headerLineCount, + tags: true, + wrap: true, + scrollable: false, + padding: { left: 1, right: 1 }, + content: headerContent.join("\n"), + }); + + line({ + parent: popup, + top: headerLineCount, + left: 1, + right: 1, + orientation: "horizontal", + style: { + fg: "gray", + }, + }); + + box({ + parent: popup, + content: " Esc ", + top: -1, + right: 1, + width: 5, + height: 1, + style: { + fg: "white", + bg: "blue", + }, + }); + + const contentArea = scrollabletext({ + parent: popup, + top: headerLineCount + 1, + left: 1, + right: 1, + bottom: 1, + keys: true, + vi: true, + mouse: true, + tags: true, + wrap: true, + padding: { left: 1, right: 1, top: 0, bottom: 0 }, + content: bodyContent.join("\n"), + }); + + const closePopup = () => { + popup.destroy(); + background.destroy(); + screen.render(); + }; + + popup.key(["escape", "q", "C-c"], () => { + closePopup(); + return false; + }); + + contentArea.on("focus", () => { + const popupStyle = popup.style as { border?: { fg?: string } }; + popupStyle.border = { ...(popupStyle.border ?? {}), fg: "yellow" }; + screen.render(); + }); + + contentArea.on("blur", () => { + const popupStyle = popup.style as { border?: { fg?: string } }; + popupStyle.border = { ...(popupStyle.border ?? {}), fg: "gray" }; + screen.render(); + }); + + contentArea.key(["escape"], () => { + closePopup(); + return false; + }); + + setImmediate(() => { + contentArea.focus(); + }); + + return { + background, + popup, + contentArea, + close: closePopup, + }; +} diff --git a/src/ui/tui.ts b/src/ui/tui.ts new file mode 100644 index 0000000..c30a8bc --- /dev/null +++ b/src/ui/tui.ts @@ -0,0 +1,103 @@ +/* + * Lightweight wrapper around the `blessed` terminal UI library. + * + * With Bun's `--compile` the dependency is bundled, so we import it + * directly and only fall back to plain text when not running in a TTY. + */ + +import { stdin as input, stdout as output } from "node:process"; +import type { ProgramInterface, ScreenInterface, ScreenOptions } from "neo-neo-bblessed"; +import { screen as blessedScreen, box, program as createProgram } from "neo-neo-bblessed"; + +type ErrorConstructor = new () => unknown; + +function constructError(value: unknown): Error | undefined { + if (typeof value !== "function") { + return undefined; + } + + try { + const candidate = new (value as ErrorConstructor)(); + return candidate instanceof Error ? candidate : undefined; + } catch { + return undefined; + } +} + +function normalizeToError(value: unknown): Error { + if (value instanceof Error) { + return value; + } + + const constructed = constructError(value); + if (constructed) { + return constructed; + } + + return new Error(String(value ?? "Unknown screen error")); +} + +export function createScreen(options: Partial<ScreenOptions> = {}): ScreenInterface { + const program: ProgramInterface = createProgram({ tput: false }); + const screen = blessedScreen({ smartCSR: true, program, fullUnicode: true, ...options }); + + // Windows runners occasionally surface file system watcher errors as plain objects + // (rather than Error instances). Blessed rethrows unhandled "error" events by + // constructing the first argument, which explodes when it is a string. Attach a + // defensive handler so these platform-specific events don't crash tests. + screen.on("error", (err) => { + const normalizedError = normalizeToError(err); + if (process.env.DEBUG) { + console.warn("TUI screen error", normalizedError); + } + throw normalizedError; + }); + + return screen; +} + +// Ask the user for a single line of input. Falls back to readline. +export async function promptText(message: string, defaultValue = ""): Promise<string> { + // Always use readline for simple text input to avoid blessed rendering quirks + const { createInterface } = await import("node:readline/promises"); + const rl = createInterface({ input, output }); + const answer = (await rl.question(`${message} `)).trim(); + rl.close(); + return answer || defaultValue; +} + +// Display long content in a scrollable viewer. +export async function scrollableViewer(content: string): Promise<void> { + if (output.isTTY === false) { + console.log(content); + return; + } + + return new Promise<void>((resolve) => { + const screen = createScreen({ + style: { fg: "white", bg: "black" }, + }); + + const viewer = box({ + parent: screen, + content, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + width: "100%", + height: "100%", + padding: { left: 1, right: 1 }, + wrap: true, + }); + + screen.key(["escape", "q", "C-c"], () => { + screen.destroy(); + resolve(); + }); + + viewer.focus(); + screen.render(); + }); +} diff --git a/src/ui/unified-view.ts b/src/ui/unified-view.ts new file mode 100644 index 0000000..720c4b1 --- /dev/null +++ b/src/ui/unified-view.ts @@ -0,0 +1,350 @@ +/** + * Unified view manager that handles Tab switching between task views and kanban board + */ + +import type { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; +import { watchConfig } from "../utils/config-watcher.ts"; +import { watchTasks } from "../utils/task-watcher.ts"; +import { renderBoardTui } from "./board.ts"; +import { createLoadingScreen } from "./loading.ts"; +import { viewTaskEnhanced } from "./task-viewer-with-search.ts"; +import { type ViewState, ViewSwitcher, type ViewType } from "./view-switcher.ts"; + +export interface UnifiedViewOptions { + core: Core; + initialView: ViewType; + selectedTask?: Task; + tasks?: Task[]; + tasksLoader?: (updateProgress: (message: string) => void) => Promise<{ tasks: Task[]; statuses: string[] }>; + loadingScreenFactory?: (initialMessage: string) => Promise<LoadingScreen | null>; + title?: string; + filter?: { + status?: string; + assignee?: string; + priority?: string; + sort?: string; + title?: string; + filterDescription?: string; + searchQuery?: string; + parentTaskId?: string; + }; + preloadedKanbanData?: { + tasks: Task[]; + statuses: string[]; + }; +} + +type LoadingScreen = { + update(message: string): void; + close(): Promise<void> | void; +}; + +export interface UnifiedViewLoadResult { + tasks: Task[]; + statuses: string[]; +} + +export async function loadTasksForUnifiedView( + core: Core, + options: Pick<UnifiedViewOptions, "tasks" | "tasksLoader" | "loadingScreenFactory">, +): Promise<UnifiedViewLoadResult> { + if (options.tasks && options.tasks.length > 0) { + const config = await core.filesystem.loadConfig(); + return { + tasks: options.tasks, + statuses: config?.statuses || ["To Do", "In Progress", "Done"], + }; + } + + const loader = + options.tasksLoader || + (async (updateProgress: (message: string) => void): Promise<{ tasks: Task[]; statuses: string[] }> => { + const tasks = await core.loadTasks(updateProgress); + const config = await core.filesystem.loadConfig(); + return { + tasks, + statuses: config?.statuses || ["To Do", "In Progress", "Done"], + }; + }); + + const loadingScreenFactory = options.loadingScreenFactory || createLoadingScreen; + const loadingScreen = await loadingScreenFactory("Loading tasks"); + + try { + const result = await loader((message) => { + loadingScreen?.update(message); + }); + + return { + tasks: result.tasks, + statuses: result.statuses, + }; + } finally { + await loadingScreen?.close(); + } +} + +type ViewResult = "switch" | "exit"; + +/** + * Main unified view controller that handles Tab switching between views + */ +export async function runUnifiedView(options: UnifiedViewOptions): Promise<void> { + try { + const { tasks: loadedTasks, statuses: loadedStatuses } = await loadTasksForUnifiedView(options.core, { + tasks: options.tasks, + tasksLoader: options.tasksLoader, + loadingScreenFactory: options.loadingScreenFactory, + }); + + const baseTasks = (loadedTasks || []).filter((t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-")); + if (baseTasks.length === 0) { + if (options.filter?.parentTaskId) { + console.log(`No child tasks found for parent task ${options.filter.parentTaskId}.`); + } else { + console.log("No tasks found."); + } + return; + } + const initialState: ViewState = { + type: options.initialView, + selectedTask: options.selectedTask, + tasks: baseTasks, + filter: options.filter, + // Initialize kanban data if starting with kanban view + kanbanData: + options.initialView === "kanban" + ? { + tasks: baseTasks, + statuses: loadedStatuses, + isLoading: false, + } + : undefined, + }; + + let isRunning = true; + let viewSwitcher: ViewSwitcher | null = null; + let currentView: ViewType = options.initialView; + let selectedTask: Task | undefined = options.selectedTask; + let tasks = baseTasks; + let kanbanStatuses = loadedStatuses ?? []; + let boardUpdater: ((nextTasks: Task[], nextStatuses: string[]) => void) | null = null; + + const getRenderableTasks = () => + tasks.filter((task) => task.id && task.id.trim() !== "" && task.id.startsWith("task-")); + + const emitBoardUpdate = () => { + if (!boardUpdater) return; + boardUpdater(getRenderableTasks(), kanbanStatuses); + }; + let isInitialLoad = true; // Track if this is the first view load + + // Track current filter state + const currentFilters = { + searchQuery: options.filter?.searchQuery || "", + statusFilter: options.filter?.status || "", + priorityFilter: options.filter?.priority || "", + }; + + // Create view switcher (without problematic onViewChange callback) + viewSwitcher = new ViewSwitcher({ + core: options.core, + initialState, + }); + const watcher = watchTasks(options.core, { + onTaskAdded(task) { + tasks.push(task); + const state = viewSwitcher?.getState(); + viewSwitcher?.updateState({ + tasks, + kanbanData: state?.kanbanData ? { ...state.kanbanData, tasks } : undefined, + }); + emitBoardUpdate(); + }, + onTaskChanged(task) { + const idx = tasks.findIndex((t) => t.id === task.id); + if (idx >= 0) { + tasks[idx] = task; + } else { + tasks.push(task); + } + const state = viewSwitcher?.getState(); + viewSwitcher?.updateState({ + tasks, + kanbanData: state?.kanbanData ? { ...state.kanbanData, tasks } : undefined, + }); + emitBoardUpdate(); + }, + onTaskRemoved(taskId) { + tasks = tasks.filter((t) => t.id !== taskId); + if (selectedTask?.id === taskId) { + selectedTask = tasks[0]; + } + const state = viewSwitcher?.getState(); + viewSwitcher?.updateState({ + tasks, + kanbanData: state?.kanbanData ? { ...state.kanbanData, tasks } : undefined, + }); + emitBoardUpdate(); + }, + }); + process.on("exit", () => watcher.stop()); + + const configWatcher = watchConfig(options.core, { + onConfigChanged: (config) => { + kanbanStatuses = config?.statuses ?? []; + emitBoardUpdate(); + }, + }); + + process.on("exit", () => configWatcher.stop()); + + // Function to show task view + const showTaskView = async (): Promise<ViewResult> => { + const availableTasks = tasks.filter((t) => t.id && t.id.trim() !== "" && t.id.startsWith("task-")); + + if (availableTasks.length === 0) { + console.log("No tasks available."); + return "exit"; + } + + // Find the task to view - if selectedTask has an ID, find it in available tasks + let taskToView: Task | undefined; + if (selectedTask?.id) { + const foundTask = availableTasks.find((t) => t.id === selectedTask?.id); + taskToView = foundTask || availableTasks[0]; + } else { + taskToView = availableTasks[0]; + } + + if (!taskToView) { + console.log("No task selected."); + return "exit"; + } + + // Show enhanced task viewer with view switching support + return new Promise<ViewResult>((resolve) => { + let result: ViewResult = "exit"; // Default to exit + + const onTabPress = async () => { + result = "switch"; + }; + + // Determine initial focus based on where we're coming from + // - If we have a search query on initial load, focus search + // - If currentView is task-detail, focus detail + // - Otherwise (including when coming from kanban), focus task list + const hasSearchQuery = options.filter ? "searchQuery" in options.filter : false; + const shouldFocusSearch = isInitialLoad && hasSearchQuery; + + viewTaskEnhanced(taskToView, { + tasks: availableTasks, + core: options.core, + title: options.filter?.title, + filterDescription: options.filter?.filterDescription, + searchQuery: currentFilters.searchQuery, + statusFilter: currentFilters.statusFilter, + priorityFilter: currentFilters.priorityFilter, + startWithDetailFocus: currentView === "task-detail", + startWithSearchFocus: shouldFocusSearch, + onTaskChange: (newTask) => { + selectedTask = newTask; + currentView = "task-detail"; + }, + onFilterChange: (filters) => { + currentFilters.searchQuery = filters.searchQuery; + currentFilters.statusFilter = filters.statusFilter; + currentFilters.priorityFilter = filters.priorityFilter; + }, + onTabPress, + }).then(() => { + // If user wants to exit, do it immediately + if (result === "exit") { + process.exit(0); + } + resolve(result); + }); + }); + }; + + // Function to show kanban view + const showKanbanView = async (): Promise<ViewResult> => { + // Use the already-loaded tasks - no need for separate kanban loading + const kanbanTasks = getRenderableTasks(); + const statuses = kanbanStatuses; + + const config = await options.core.filesystem.loadConfig(); + const layout = "horizontal" as const; + const maxColumnWidth = config?.maxColumnWidth || 20; + + // Show kanban board with view switching support + return new Promise<ViewResult>((resolve) => { + let result: ViewResult = "exit"; // Default to exit + + const onTabPress = async () => { + result = "switch"; + }; + + renderBoardTui(kanbanTasks, statuses, layout, maxColumnWidth, { + onTaskSelect: (task) => { + selectedTask = task; + }, + onTabPress, + subscribeUpdates: (updater) => { + boardUpdater = updater; + emitBoardUpdate(); + }, + }).then(() => { + // If user wants to exit, do it immediately + if (result === "exit") { + process.exit(0); + } + boardUpdater = null; + resolve(result); + }); + }); + }; + + // Main view loop + while (isRunning) { + // Show the current view and get the result + let result: ViewResult; + switch (currentView) { + case "task-list": + case "task-detail": + result = await showTaskView(); + break; + case "kanban": + result = await showKanbanView(); + break; + default: + result = "exit"; + } + + // After the first view, we're no longer on initial load + isInitialLoad = false; + + // Handle the result + if (result === "switch") { + // User pressed Tab, switch to the next view + switch (currentView) { + case "task-list": + case "task-detail": + currentView = "kanban"; + break; + case "kanban": + // Always go to task-list view when switching from board, keeping selected task highlighted + currentView = "task-list"; + break; + } + } else { + // User pressed q/Esc, exit the loop + isRunning = false; + } + } + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/src/ui/view-switcher.ts b/src/ui/view-switcher.ts new file mode 100644 index 0000000..3a062c4 --- /dev/null +++ b/src/ui/view-switcher.ts @@ -0,0 +1,348 @@ +/** + * View switcher module for handling Tab key navigation between task views and kanban board + * with intelligent background loading and state preservation. + */ + +import type { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; + +export type ViewType = "task-list" | "task-detail" | "kanban"; + +export interface ViewState { + type: ViewType; + selectedTask?: Task; + tasks?: Task[]; + filter?: { + status?: string; + assignee?: string; + priority?: string; + sort?: string; + title?: string; + filterDescription?: string; + searchQuery?: string; + parentTaskId?: string; + }; + kanbanData?: { + tasks: Task[]; + statuses: string[]; + isLoading: boolean; + loadError?: string; + }; +} + +export interface ViewSwitcherOptions { + core: Core; + initialState: ViewState; + onViewChange?: (newState: ViewState) => void; +} + +/** + * Background loading state for kanban board data + */ +class BackgroundLoader { + private loadingPromise: Promise<Task[]> | null = null; + private cachedTasks: Task[] | null = null; + private lastLoadTime = 0; + private readonly CACHE_TTL = 30000; // 30 seconds + private onProgress?: (message: string) => void; + private abortController?: AbortController; + private lastProgressMessage = ""; + + constructor(private core: Core) {} + + /** + * Start loading kanban data in the background + */ + startLoading(): void { + // Don't start new loading if already loading or cache is fresh + if (this.loadingPromise || this.isCacheFresh()) { + return; + } + + // Clear last progress message when starting fresh load + this.lastProgressMessage = ""; + + // Create new abort controller for this loading operation + this.abortController = new AbortController(); + this.loadingPromise = this.loadKanbanData(); + } + + /** + * Get kanban data - either from cache or by waiting for loading + */ + async getKanbanData(): Promise<{ tasks: Task[]; statuses: string[] }> { + // Return cached data if fresh + if (this.isCacheFresh() && this.cachedTasks) { + const config = await this.core.filesystem.loadConfig(); + return { + tasks: this.cachedTasks, + statuses: config?.statuses || [], + }; + } + + // Start loading if not already + if (!this.loadingPromise) { + this.abortController = new AbortController(); + this.loadingPromise = this.loadKanbanData(); + } else { + // If loading is already in progress, send a status update to the current progress callback + this.onProgress?.("Loading tasks from local and remote branches..."); + } + + // Wait for loading to complete + const tasks = await this.loadingPromise; + const config = await this.core.filesystem.loadConfig(); + + return { + tasks, + statuses: config?.statuses || [], + }; + } + + /** + * Check if we have fresh cached data + */ + isReady(): boolean { + return this.isCacheFresh() && this.cachedTasks !== null; + } + + /** + * Get loading status + */ + isLoading(): boolean { + return this.loadingPromise !== null && !this.isCacheFresh(); + } + + private isCacheFresh(): boolean { + return Date.now() - this.lastLoadTime < this.CACHE_TTL; + } + + private async loadKanbanData(): Promise<Task[]> { + try { + // Check for cancellation at the start + if (this.abortController?.signal.aborted) { + throw new Error("Loading cancelled"); + } + + // Create a progress wrapper that stores the last message + const progressWrapper = (msg: string) => { + this.lastProgressMessage = msg; + this.onProgress?.(msg); + }; + + // Use the shared Core method for loading board tasks + const filteredTasks = await this.core.loadTasks(progressWrapper, this.abortController?.signal); + + // Cache the results + this.cachedTasks = filteredTasks; + this.lastLoadTime = Date.now(); + this.loadingPromise = null; + this.lastProgressMessage = ""; // Clear progress message after completion + + return filteredTasks; + } catch (error) { + this.loadingPromise = null; + this.lastProgressMessage = ""; // Clear progress message on error + // If it's a cancellation, don't treat it as an error + if (error instanceof Error && error.message === "Loading cancelled") { + return []; // Return empty array instead of exiting + } + throw error; + } + } + + /** + * Set progress callback for loading updates + */ + setProgressCallback(callback: (message: string) => void): void { + this.onProgress = callback; + // If we have a last progress message and loading is in progress, send it immediately + if (this.lastProgressMessage && this.loadingPromise) { + callback(this.lastProgressMessage); + } + } + + /** + * Seed the cache with pre-loaded tasks to avoid redundant loading + */ + seedCache(tasks: Task[]): void { + this.cachedTasks = tasks; + this.lastLoadTime = Date.now(); + } + + /** + * Cancel any ongoing loading operations + */ + cancelLoading(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = undefined; + } + this.loadingPromise = null; + } +} + +/** + * Main view switcher class + */ +export class ViewSwitcher { + private state: ViewState; + private backgroundLoader: BackgroundLoader; + private onViewChange?: (newState: ViewState) => void; + + constructor(options: ViewSwitcherOptions) { + this.state = options.initialState; + this.backgroundLoader = new BackgroundLoader(options.core); + this.onViewChange = options.onViewChange; + + // If starting with kanban view and we already have loaded tasks, seed the cache + if (this.state.type === "kanban" && this.state.kanbanData?.tasks && !this.state.kanbanData.isLoading) { + this.backgroundLoader.seedCache(this.state.kanbanData.tasks); + } + // Note: We no longer auto-start background loading - tasks are loaded once + // at the unified view level and passed through + } + + /** + * Get current view state + */ + getState(): ViewState { + return { ...this.state }; + } + + /** + * Switch to the next view based on current state + */ + async switchView(): Promise<ViewState> { + switch (this.state.type) { + case "task-list": + case "task-detail": + // Switch to kanban board + return await this.switchToKanban(); + case "kanban": + // Switch back to previous task view + return this.switchToTaskView(); + default: + return this.state; + } + } + + /** + * Switch to kanban board view + */ + private async switchToKanban(): Promise<ViewState> { + try { + if (this.backgroundLoader.isReady()) { + // Data is ready, switch instantly + const { tasks, statuses } = await this.backgroundLoader.getKanbanData(); + this.state = { + ...this.state, + type: "kanban", + kanbanData: { + tasks, + statuses, + isLoading: false, + }, + }; + } else { + // Data is still loading, indicate loading state + this.state = { + ...this.state, + type: "kanban", + kanbanData: { + tasks: [], + statuses: [], + isLoading: true, + }, + }; + } + + this.onViewChange?.(this.state); + return this.state; + } catch (error) { + // Handle loading error + this.state = { + ...this.state, + type: "kanban", + kanbanData: { + tasks: [], + statuses: [], + isLoading: false, + loadError: error instanceof Error ? error.message : "Failed to load kanban data", + }, + }; + + this.onViewChange?.(this.state); + return this.state; + } + } + + /** + * Switch back to task view (preserve previous view type) + */ + private switchToTaskView(): ViewState { + // Default to task-list if no previous task view + const viewType = this.state.selectedTask ? "task-detail" : "task-list"; + + this.state = { + ...this.state, + type: viewType, + }; + + // Start background loading for next potential kanban switch + this.backgroundLoader.startLoading(); + + this.onViewChange?.(this.state); + return this.state; + } + + /** + * Update the current state (used when user navigates within a view) + */ + updateState(updates: Partial<ViewState>): ViewState { + this.state = { ...this.state, ...updates }; + + // Start background loading if switching to task views + if (this.state.type === "task-list" || this.state.type === "task-detail") { + this.backgroundLoader.startLoading(); + } + + this.onViewChange?.(this.state); + return this.state; + } + + /** + * Check if kanban data is ready for instant switching + */ + isKanbanReady(): boolean { + return this.backgroundLoader.isReady(); + } + + /** + * Pre-load kanban data + */ + preloadKanban(): void { + this.backgroundLoader.startLoading(); + } + + /** + * Get kanban data - delegates to background loader + */ + async getKanbanData(): Promise<{ tasks: Task[]; statuses: string[] }> { + return await this.backgroundLoader.getKanbanData(); + } + + /** + * Set progress callback for loading updates + */ + setProgressCallback(callback: (message: string) => void): void { + this.backgroundLoader.setProgressCallback(callback); + } + + /** + * Clean up resources and cancel any ongoing operations + */ + cleanup(): void { + this.backgroundLoader.cancelLoading(); + } +} diff --git a/src/utils/agent-selection.ts b/src/utils/agent-selection.ts new file mode 100644 index 0000000..27a72b7 --- /dev/null +++ b/src/utils/agent-selection.ts @@ -0,0 +1,59 @@ +import type { AgentInstructionFile } from "../agent-instructions.ts"; + +export const PLACEHOLDER_AGENT_VALUE = "__agent_selection_placeholder__" as const; + +export type AgentSelectionValue = AgentInstructionFile | "none" | typeof PLACEHOLDER_AGENT_VALUE; + +export interface AgentSelectionInput { + selected?: AgentSelectionValue[] | null; + highlighted?: AgentSelectionValue | null; + useHighlightFallback?: boolean; +} + +export interface AgentSelectionOutcome { + files: AgentInstructionFile[]; + needsRetry: boolean; + skipped: boolean; +} + +function uniqueOrder(values: AgentSelectionValue[]): AgentSelectionValue[] { + const seen = new Set<AgentSelectionValue>(); + const ordered: AgentSelectionValue[] = []; + for (const value of values) { + if (!value) continue; + if (seen.has(value)) continue; + seen.add(value); + ordered.push(value); + } + return ordered; +} + +export function processAgentSelection({ + selected, + highlighted, + useHighlightFallback, +}: AgentSelectionInput): AgentSelectionOutcome { + const normalizedSelected = Array.isArray(selected) ? [...selected] : []; + + if ( + normalizedSelected.length === 0 && + highlighted && + highlighted !== "none" && + highlighted !== PLACEHOLDER_AGENT_VALUE && + useHighlightFallback + ) { + normalizedSelected.push(highlighted); + } + + const ordered = uniqueOrder(normalizedSelected); + const agentFiles = ordered.filter( + (value): value is AgentInstructionFile => value !== "none" && value !== PLACEHOLDER_AGENT_VALUE, + ); + const skipSelected = ordered.includes("none") && agentFiles.length === 0; + + if (agentFiles.length === 0 && !skipSelected) { + return { files: [], needsRetry: true, skipped: false }; + } + + return { files: agentFiles, needsRetry: false, skipped: skipSelected }; +} diff --git a/src/utils/app-info.ts b/src/utils/app-info.ts new file mode 100644 index 0000000..7dbe34e --- /dev/null +++ b/src/utils/app-info.ts @@ -0,0 +1,7 @@ +/** + * Lightweight helper for package metadata that does not justify build-time embedding yet. + * If we ever need to embed the value, mirror the approach used in version.ts. + */ +export function getPackageName(): string { + return "backlog.md"; +} diff --git a/src/utils/assignee.ts b/src/utils/assignee.ts new file mode 100644 index 0000000..5cae9b0 --- /dev/null +++ b/src/utils/assignee.ts @@ -0,0 +1,7 @@ +export function normalizeAssignee(task: { assignee?: string | string[] }): void { + if (typeof task.assignee === "string") { + task.assignee = [task.assignee]; + } else if (!Array.isArray(task.assignee)) { + task.assignee = []; + } +} diff --git a/src/utils/config-watcher.ts b/src/utils/config-watcher.ts new file mode 100644 index 0000000..9cf972a --- /dev/null +++ b/src/utils/config-watcher.ts @@ -0,0 +1,42 @@ +import { type FSWatcher, watch } from "node:fs"; +import type { Core } from "../core/backlog.ts"; +import type { BacklogConfig } from "../types/index.ts"; + +export interface ConfigWatcherCallbacks { + onConfigChanged?: (config: BacklogConfig | null) => void | Promise<void>; +} + +export function watchConfig(core: Core, callbacks: ConfigWatcherCallbacks): { stop: () => void } { + const configPath = core.filesystem.configFilePath; + let watcher: FSWatcher | null = null; + + const stop = () => { + if (watcher) { + try { + watcher.close(); + } catch { + // Ignore + } + watcher = null; + } + }; + + try { + watcher = watch(configPath, async (eventType) => { + if (eventType !== "change" && eventType !== "rename") { + return; + } + try { + core.filesystem.invalidateConfigCache(); + const config = await core.filesystem.loadConfig(); + await callbacks.onConfigChanged?.(config); + } catch { + // Ignore read errors; subsequent events will retry + } + }); + } catch { + // If watching fails (e.g., file missing), keep silent; caller can retry via onConfigChanged + } + + return { stop }; +} diff --git a/src/utils/document-id.ts b/src/utils/document-id.ts new file mode 100644 index 0000000..5cf6ec6 --- /dev/null +++ b/src/utils/document-id.ts @@ -0,0 +1,25 @@ +function ensureDocumentPrefix(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^doc-(.+)$/i); + const body = match ? match[1] : trimmed; + return `doc-${body}`; +} + +function extractDocumentNumber(value: string): string | null { + const trimmed = value.trim(); + const match = trimmed.match(/^(?:doc-)?0*([0-9]+)$/i); + return match?.[1] ?? null; +} + +export function normalizeDocumentId(id: string): string { + return ensureDocumentPrefix(id); +} + +export function documentIdsEqual(left: string, right: string): boolean { + const leftNumber = extractDocumentNumber(left); + const rightNumber = extractDocumentNumber(right); + if (leftNumber !== null && rightNumber !== null) { + return leftNumber === rightNumber; + } + return normalizeDocumentId(left).toLowerCase() === normalizeDocumentId(right).toLowerCase(); +} diff --git a/src/utils/editor.ts b/src/utils/editor.ts new file mode 100644 index 0000000..a310d8a --- /dev/null +++ b/src/utils/editor.ts @@ -0,0 +1,103 @@ +import { platform } from "node:os"; +import { $ } from "bun"; +import type { BacklogConfig } from "../types/index.ts"; + +/** + * Get the default editor based on the operating system + */ +function getPlatformDefaultEditor(): string { + const os = platform(); + switch (os) { + case "win32": + return "notepad"; + case "darwin": + // macOS typically has nano available + return "nano"; + case "linux": + return "nano"; + default: + // Fallback to vi which is available on most unix systems + return "vi"; + } +} + +/** + * Resolve the editor command based on configuration, environment, and platform defaults + * Priority: EDITOR env var -> config.defaultEditor -> platform default + */ +export function resolveEditor(config?: BacklogConfig | null): string { + // First check environment variable + const editorEnv = process.env.EDITOR; + if (editorEnv) { + return editorEnv; + } + + // Then check config + if (config?.defaultEditor) { + return config.defaultEditor; + } + + // Finally use platform default + return getPlatformDefaultEditor(); +} + +/** + * Check if an editor command is available on the system + */ +export async function isEditorAvailable(editor: string): Promise<boolean> { + try { + // Try to run the editor with --version or --help to check if it exists + // Split the editor command in case it has arguments + const parts = editor.split(" "); + const command = parts[0] ?? editor; + + // For Windows, just check if the command exists + if (platform() === "win32") { + try { + await $`where ${command}`.quiet(); + return true; + } catch { + return false; + } + } + + // For Unix-like systems, use which + try { + await $`which ${command}`.quiet(); + return true; + } catch { + return false; + } + } catch { + return false; + } +} + +/** + * Open a file in the editor + */ +export async function openInEditor(filePath: string, config?: BacklogConfig | null): Promise<boolean> { + const editor = resolveEditor(config); + + try { + // Split the editor command in case it has arguments + const parts = editor.split(" "); + const command = parts[0] ?? editor; + const args = [...parts.slice(1), filePath]; + + // Use Bun.spawn with explicit stdio inheritance for interactive editors + // Interactive editors like vim/neovim require direct access to stdin/stdout/stderr + // to properly render their UI and receive user input + const subprocess = Bun.spawn([command, ...args], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await subprocess.exited; + return exitCode === 0; + } catch (error) { + console.error(`Failed to open editor: ${error}`); + return false; + } +} diff --git a/src/utils/id-generators.ts b/src/utils/id-generators.ts new file mode 100644 index 0000000..292907b --- /dev/null +++ b/src/utils/id-generators.ts @@ -0,0 +1,148 @@ +import { DEFAULT_DIRECTORIES } from "../constants/index.ts"; +import type { Core } from "../index.ts"; + +/** + * Generate the next available document ID by checking all branches and local documents + * @param core Core instance for filesystem and git operations + * @returns Promise<string> Next available document ID (e.g., "doc-001") + */ +export async function generateNextDocId(core: Core): Promise<string> { + const config = await core.filesystem.loadConfig(); + // Load local documents + const docs = await core.filesystem.listDocuments(); + const allIds: string[] = []; + + try { + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + + // Skip remote operations if disabled + if (config?.remoteOperations === false) { + if (process.env.DEBUG) { + console.log("Remote operations disabled - generating ID from local documents only"); + } + } else { + await core.gitOps.fetch(); + } + + const branches = await core.gitOps.listAllBranches(); + + // Load files from all branches in parallel + const branchFilePromises = branches.map(async (branch) => { + const files = await core.gitOps.listFilesInTree(branch, `${backlogDir}/docs`); + return files + .map((file) => { + const match = file.match(/doc-(\d+)/); + return match ? `doc-${match[1]}` : null; + }) + .filter((id): id is string => id !== null); + }); + + const branchResults = await Promise.all(branchFilePromises); + for (const branchIds of branchResults) { + allIds.push(...branchIds); + } + } catch (error) { + // Suppress errors for offline mode or other git issues + if (process.env.DEBUG) { + console.error("Could not fetch remote document IDs:", error); + } + } + + // Add local document IDs + for (const doc of docs) { + allIds.push(doc.id); + } + + // Find the highest numeric ID + let max = 0; + for (const id of allIds) { + const match = id.match(/^doc-(\d+)$/); + if (match) { + const num = Number.parseInt(match[1] || "0", 10); + if (num > max) max = num; + } + } + + const nextIdNumber = max + 1; + const padding = config?.zeroPaddedIds; + + if (padding && typeof padding === "number" && padding > 0) { + const paddedId = String(nextIdNumber).padStart(padding, "0"); + return `doc-${paddedId}`; + } + + return `doc-${nextIdNumber}`; +} + +/** + * Generate the next available decision ID by checking all branches and local decisions + * @param core Core instance for filesystem and git operations + * @returns Promise<string> Next available decision ID (e.g., "decision-001") + */ +export async function generateNextDecisionId(core: Core): Promise<string> { + const config = await core.filesystem.loadConfig(); + // Load local decisions + const decisions = await core.filesystem.listDecisions(); + const allIds: string[] = []; + + try { + const backlogDir = DEFAULT_DIRECTORIES.BACKLOG; + + // Skip remote operations if disabled + if (config?.remoteOperations === false) { + if (process.env.DEBUG) { + console.log("Remote operations disabled - generating ID from local decisions only"); + } + } else { + await core.gitOps.fetch(); + } + + const branches = await core.gitOps.listAllBranches(); + + // Load files from all branches in parallel + const branchFilePromises = branches.map(async (branch) => { + const files = await core.gitOps.listFilesInTree(branch, `${backlogDir}/decisions`); + return files + .map((file) => { + const match = file.match(/decision-(\d+)/); + return match ? `decision-${match[1]}` : null; + }) + .filter((id): id is string => id !== null); + }); + + const branchResults = await Promise.all(branchFilePromises); + for (const branchIds of branchResults) { + allIds.push(...branchIds); + } + } catch (error) { + // Suppress errors for offline mode or other git issues + if (process.env.DEBUG) { + console.error("Could not fetch remote decision IDs:", error); + } + } + + // Add local decision IDs + for (const decision of decisions) { + allIds.push(decision.id); + } + + // Find the highest numeric ID + let max = 0; + for (const id of allIds) { + const match = id.match(/^decision-(\d+)$/); + if (match) { + const num = Number.parseInt(match[1] || "0", 10); + if (num > max) max = num; + } + } + + const nextIdNumber = max + 1; + const padding = config?.zeroPaddedIds; + + if (padding && typeof padding === "number" && padding > 0) { + const paddedId = String(nextIdNumber).padStart(padding, "0"); + return `decision-${paddedId}`; + } + + return `decision-${nextIdNumber}`; +} diff --git a/src/utils/status-callback.ts b/src/utils/status-callback.ts new file mode 100644 index 0000000..a684f96 --- /dev/null +++ b/src/utils/status-callback.ts @@ -0,0 +1,69 @@ +import { spawn } from "bun"; + +export interface StatusCallbackOptions { + command: string; + taskId: string; + oldStatus: string; + newStatus: string; + taskTitle: string; + cwd: string; +} + +export interface StatusCallbackResult { + success: boolean; + output?: string; + error?: string; + exitCode?: number; +} + +/** + * Executes a status change callback command with variable injection. + * Variables are passed as environment variables to the shell command. + * + * @param options - The callback options including command and task details + * @returns The result of the callback execution + */ +export async function executeStatusCallback(options: StatusCallbackOptions): Promise<StatusCallbackResult> { + const { command, taskId, oldStatus, newStatus, taskTitle, cwd } = options; + + if (!command || command.trim().length === 0) { + return { success: false, error: "Empty command" }; + } + + try { + const env = { + ...process.env, + TASK_ID: taskId, + OLD_STATUS: oldStatus, + NEW_STATUS: newStatus, + TASK_TITLE: taskTitle, + }; + + const proc = spawn({ + cmd: ["sh", "-c", command], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + + const exitCode = await proc.exited; + const success = exitCode === 0; + + const output = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); + + return { + success, + output: output || undefined, + exitCode, + ...(stderr.trim() && !success && { error: stderr.trim() }), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/utils/status.ts b/src/utils/status.ts new file mode 100644 index 0000000..f247fc7 --- /dev/null +++ b/src/utils/status.ts @@ -0,0 +1,41 @@ +import { Core } from "../core/backlog.ts"; + +/** + * Load valid statuses from project configuration. + */ +export async function getValidStatuses(core?: Core): Promise<string[]> { + const c = core ?? new Core(process.cwd()); + const config = await c.filesystem.loadConfig(); + return config?.statuses || []; +} + +/** + * Find the canonical status (matching config casing) for a given input. + * Loads configured statuses and matches case-insensitively and space-insensitively. + * Returns the canonical value or null if no match is found. + * + * Examples: + * - "todo" matches "To Do" + * - "in progress" matches "In Progress" + * - "DONE" matches "Done" + */ +export async function getCanonicalStatus(input: string | undefined, core?: Core): Promise<string | null> { + if (!input) return null; + const statuses = await getValidStatuses(core); + // Normalize: lowercase, trim, and remove all whitespace + const normalized = String(input).trim().toLowerCase().replace(/\s+/g, ""); + if (!normalized) return null; + for (const s of statuses) { + // Normalize config status the same way + const configNormalized = s.toLowerCase().replace(/\s+/g, ""); + if (configNormalized === normalized) return s; // preserve configured casing + } + return null; +} + +/** + * Format a list of valid statuses for display. + */ +export function formatValidStatuses(configuredStatuses: string[]): string { + return configuredStatuses.join(", "); +} diff --git a/src/utils/task-builders.ts b/src/utils/task-builders.ts new file mode 100644 index 0000000..c46af4a --- /dev/null +++ b/src/utils/task-builders.ts @@ -0,0 +1,127 @@ +import type { Core } from "../core/backlog.ts"; +import { normalizeTaskId, taskIdsEqual } from "./task-path.ts"; + +/** + * Shared utilities for building tasks and validating dependencies + * Used by both CLI and MCP to ensure consistent behavior + */ + +/** + * Normalize dependencies to proper task-X format + * Handles both array and comma-separated string inputs + */ +export function normalizeDependencies(dependencies: unknown): string[] { + if (!dependencies) return []; + const normalizeList = (values: string[]): string[] => + values + .map((value) => value.trim()) + .filter((value): value is string => value.length > 0) + .map((value) => normalizeTaskId(value)); + + if (Array.isArray(dependencies)) { + return normalizeList( + dependencies.flatMap((dep) => + String(dep) + .split(",") + .map((d) => d.trim()), + ), + ); + } + + return normalizeList(String(dependencies).split(",")); +} + +/** + * Validate that all dependencies exist in the current project + * Returns arrays of valid and invalid dependency IDs + */ +export async function validateDependencies( + dependencies: string[], + core: Core, +): Promise<{ valid: string[]; invalid: string[] }> { + const valid: string[] = []; + const invalid: string[] = []; + if (dependencies.length === 0) { + return { valid, invalid }; + } + // Load both tasks and drafts to validate dependencies + const [tasks, drafts] = await Promise.all([core.filesystem.listTasks(), core.filesystem.listDrafts()]); + const knownIds = [...tasks.map((t) => t.id), ...drafts.map((d) => d.id)]; + for (const dep of dependencies) { + const match = knownIds.find((id) => taskIdsEqual(dep, id)); + if (match) { + valid.push(match); + } else { + invalid.push(dep); + } + } + return { valid, invalid }; +} + +/** + * Process acceptance criteria options from CLI/MCP arguments + * Handles both --ac and --acceptance-criteria options + */ +export function processAcceptanceCriteriaOptions(options: { + ac?: string | string[]; + acceptanceCriteria?: string | string[]; +}): string[] { + const criteria: string[] = []; + // Process --ac options + if (options.ac) { + const acCriteria = Array.isArray(options.ac) ? options.ac : [options.ac]; + criteria.push(...acCriteria.map((c) => String(c).trim()).filter(Boolean)); + } + // Process --acceptance-criteria options + if (options.acceptanceCriteria) { + const accCriteria = Array.isArray(options.acceptanceCriteria) + ? options.acceptanceCriteria + : [options.acceptanceCriteria]; + criteria.push(...accCriteria.map((c) => String(c).trim()).filter(Boolean)); + } + return criteria; +} + +/** + * Normalize a list of string values by trimming whitespace, dropping empties, and deduplicating. + * Returns `undefined` when the resulting list is empty so callers can skip optional updates. + */ +export function normalizeStringList(values: string[] | undefined): string[] | undefined { + if (!values) return undefined; + const unique = Array.from(new Set(values.map((value) => String(value).trim()).filter((value) => value.length > 0))); + return unique.length > 0 ? unique : undefined; +} + +/** + * Convert Commander-style option values into a string array. + * Handles single values, repeated flags, and undefined/null inputs. + */ +export function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((item) => String(item)); + } + if (value === undefined || value === null) { + return []; + } + return [String(value)]; +} + +/** + * Parse a Commander option (single value or array) into a strictly positive integer list. + * Throws an Error when any value is invalid so callers can surface CLI-friendly messaging. + */ +export function parsePositiveIndexList(value: unknown): number[] { + const entries = Array.isArray(value) ? value : value !== undefined && value !== null ? [value] : []; + return entries.map((entry) => { + const parsed = Number.parseInt(String(entry), 10); + if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 1) { + throw new Error(`Invalid index: ${String(entry)}. Index must be a positive number (1-based).`); + } + return parsed; + }); +} + +export function stringArraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); +} diff --git a/src/utils/task-edit-builder.ts b/src/utils/task-edit-builder.ts new file mode 100644 index 0000000..71c9c0e --- /dev/null +++ b/src/utils/task-edit-builder.ts @@ -0,0 +1,132 @@ +import type { TaskUpdateInput } from "../types/index.ts"; +import type { TaskEditArgs } from "../types/task-edit-args.ts"; +import { normalizeStringList } from "./task-builders.ts"; + +function sanitizeStringArray(values: string[] | undefined): string[] | undefined { + if (!values) return undefined; + const trimmed = values.map((value) => String(value).trim()).filter((value) => value.length > 0); + return trimmed.length > 0 ? trimmed : undefined; +} + +function sanitizeAppend(values: string[] | undefined): string[] | undefined { + const sanitized = sanitizeStringArray(values); + if (!sanitized) { + return undefined; + } + return sanitized; +} + +function toAcceptanceCriteriaEntries(values: string[] | undefined) { + if (!values) return undefined; + const trimmed = values.map((value) => String(value).trim()).filter((value) => value.length > 0); + if (trimmed.length === 0) { + return undefined; + } + return trimmed.map((text, index) => ({ text, checked: false, index: index + 1 })); +} + +export function buildTaskUpdateInput(args: TaskEditArgs): TaskUpdateInput { + const updateInput: TaskUpdateInput = {}; + + if (typeof args.title === "string") { + updateInput.title = args.title; + } + + if (typeof args.description === "string") { + updateInput.description = args.description; + } + + if (typeof args.status === "string") { + updateInput.status = args.status; + } + + if (typeof args.priority === "string") { + updateInput.priority = args.priority; + } + + if (typeof args.ordinal === "number") { + updateInput.ordinal = args.ordinal; + } + + const labels = normalizeStringList(args.labels); + if (labels) { + updateInput.labels = labels; + } + + const addLabels = normalizeStringList(args.addLabels); + if (addLabels) { + updateInput.addLabels = addLabels; + } + + const removeLabels = normalizeStringList(args.removeLabels); + if (removeLabels) { + updateInput.removeLabels = removeLabels; + } + + const assignee = normalizeStringList(args.assignee); + if (assignee) { + updateInput.assignee = assignee; + } + + const dependencies = sanitizeStringArray(args.dependencies); + if (dependencies) { + updateInput.dependencies = dependencies; + } + + const planSet = args.planSet ?? args.implementationPlan; + if (typeof planSet === "string") { + updateInput.implementationPlan = planSet; + } + + const planAppends = sanitizeAppend(args.planAppend); + if (planAppends) { + updateInput.appendImplementationPlan = planAppends; + } + + if (args.planClear) { + updateInput.clearImplementationPlan = true; + } + + const notesSet = args.notesSet ?? args.implementationNotes; + if (typeof notesSet === "string") { + updateInput.implementationNotes = notesSet; + } + + const notesAppends = sanitizeAppend(args.notesAppend); + if (notesAppends) { + updateInput.appendImplementationNotes = notesAppends; + } + + if (args.notesClear) { + updateInput.clearImplementationNotes = true; + } + + const criteriaSet = toAcceptanceCriteriaEntries(args.acceptanceCriteriaSet); + if (criteriaSet) { + updateInput.acceptanceCriteria = criteriaSet; + } + + if (Array.isArray(args.acceptanceCriteriaAdd) && args.acceptanceCriteriaAdd.length > 0) { + const additions = args.acceptanceCriteriaAdd + .map((text) => String(text).trim()) + .filter((text) => text.length > 0) + .map((text) => ({ text, checked: false })); + if (additions.length > 0) { + updateInput.addAcceptanceCriteria = additions; + } + } + + if (Array.isArray(args.acceptanceCriteriaRemove) && args.acceptanceCriteriaRemove.length > 0) { + updateInput.removeAcceptanceCriteria = [...args.acceptanceCriteriaRemove]; + } + + if (Array.isArray(args.acceptanceCriteriaCheck) && args.acceptanceCriteriaCheck.length > 0) { + updateInput.checkAcceptanceCriteria = [...args.acceptanceCriteriaCheck]; + } + + if (Array.isArray(args.acceptanceCriteriaUncheck) && args.acceptanceCriteriaUncheck.length > 0) { + updateInput.uncheckAcceptanceCriteria = [...args.acceptanceCriteriaUncheck]; + } + + return updateInput; +} diff --git a/src/utils/task-path.ts b/src/utils/task-path.ts new file mode 100644 index 0000000..6969100 --- /dev/null +++ b/src/utils/task-path.ts @@ -0,0 +1,136 @@ +import { join } from "node:path"; +import { Core } from "../core/backlog.ts"; + +// Interface for task path resolution context +interface TaskPathContext { + filesystem: { + tasksDir: string; + }; +} + +/** + * Normalize a task ID by ensuring the "task-" prefix is present (case-insensitive) + * while preserving the numeric/content portion as provided. + */ +export function normalizeTaskId(taskId: string): string { + const trimmed = taskId.trim(); + const match = trimmed.match(/^task-(.+)$/i); + const body = match ? match[1] : trimmed; + return `task-${body}`; +} + +function extractTaskBody(value: string): string | null { + const trimmed = value.trim(); + if (trimmed === "") return ""; + const match = trimmed.match(/^(?:task-)?([0-9]+(?:\.[0-9]+)*)$/i); + return match?.[1] ?? null; +} + +function extractTaskIdFromFilename(filename: string): string | null { + const match = filename.match(/^task-([0-9]+(?:\.[0-9]+)*)/i); + if (!match || !match[1]) return null; + return normalizeTaskId(`task-${match[1]}`); +} + +export function taskIdsEqual(left: string, right: string): boolean { + const leftBody = extractTaskBody(left); + const rightBody = extractTaskBody(right); + + if (leftBody && rightBody) { + const leftSegs = leftBody.split(".").map((seg) => Number.parseInt(seg, 10)); + const rightSegs = rightBody.split(".").map((seg) => Number.parseInt(seg, 10)); + if (leftSegs.length !== rightSegs.length) { + return false; + } + return leftSegs.every((value, index) => value === rightSegs[index]); + } + + return normalizeTaskId(left).toLowerCase() === normalizeTaskId(right).toLowerCase(); +} + +function idsMatchLoosely(inputId: string, filename: string): boolean { + const candidate = extractTaskIdFromFilename(filename); + if (!candidate) return false; + return taskIdsEqual(inputId, candidate); +} + +/** + * Get the file path for a task by ID + */ +export async function getTaskPath(taskId: string, core?: Core | TaskPathContext): Promise<string | null> { + const coreInstance = core || new Core(process.cwd()); + + try { + const files = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: coreInstance.filesystem.tasksDir })); + const normalizedId = normalizeTaskId(taskId); + // First try exact prefix match for speed + let taskFile = files.find((f) => f.startsWith(`${normalizedId} -`) || f.startsWith(`${normalizedId}-`)); + + // If not found, try loose numeric match ignoring leading zeros + if (!taskFile) { + taskFile = files.find((f) => idsMatchLoosely(taskId, f)); + } + + if (taskFile) { + return join(coreInstance.filesystem.tasksDir, taskFile); + } + + return null; + } catch { + return null; + } +} + +/** + * Get the file path for a draft by ID + */ +export async function getDraftPath(taskId: string, core: Core): Promise<string | null> { + try { + const draftsDir = await core.filesystem.getDraftsDir(); + const files = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: draftsDir })); + const normalizedId = normalizeTaskId(taskId); + // First exact match + let draftFile = files.find((f) => f.startsWith(`${normalizedId} -`) || f.startsWith(`${normalizedId}-`)); + // Fallback to loose numeric match ignoring leading zeros + if (!draftFile) { + draftFile = files.find((f) => idsMatchLoosely(taskId, f)); + } + + if (draftFile) { + return join(draftsDir, draftFile); + } + + return null; + } catch { + return null; + } +} + +/** + * Get the filename (without directory) for a task by ID + */ +export async function getTaskFilename(taskId: string, core?: Core | TaskPathContext): Promise<string | null> { + const coreInstance = core || new Core(process.cwd()); + + try { + const files = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: coreInstance.filesystem.tasksDir })); + const normalizedId = normalizeTaskId(taskId); + // First exact match + let taskFile = files.find((f) => f.startsWith(`${normalizedId} -`) || f.startsWith(`${normalizedId}-`)); + if (!taskFile) { + taskFile = files.find((f) => idsMatchLoosely(taskId, f)); + } + + return taskFile || null; + } catch { + return null; + } +} + +/** + * Check if a task file exists + */ +export async function taskFileExists(taskId: string, core?: Core | TaskPathContext): Promise<boolean> { + const path = await getTaskPath(taskId, core); + return path !== null; +} diff --git a/src/utils/task-search.ts b/src/utils/task-search.ts new file mode 100644 index 0000000..4dfa538 --- /dev/null +++ b/src/utils/task-search.ts @@ -0,0 +1,123 @@ +/** + * In-memory task search using Fuse.js + * Used when tasks are already loaded to avoid re-fetching via ContentStore + */ + +import Fuse from "fuse.js"; +import type { Task } from "../types/index.ts"; + +interface TaskSearchOptions { + query?: string; + status?: string; + priority?: "high" | "medium" | "low"; +} + +interface TaskSearchIndex { + search(options: TaskSearchOptions): Task[]; +} + +const TASK_ID_PREFIX = "task-"; + +function createTaskIdVariants(id: string): string[] { + const segments = parseTaskIdSegments(id); + if (!segments) { + const normalized = id.startsWith(TASK_ID_PREFIX) ? id : `${TASK_ID_PREFIX}${id}`; + return id === normalized ? [normalized] : [normalized, id]; + } + const canonicalSuffix = segments.join("."); + const variants = new Set<string>(); + const normalized = id.startsWith(TASK_ID_PREFIX) ? id : `${TASK_ID_PREFIX}${id}`; + variants.add(normalized); + variants.add(`${TASK_ID_PREFIX}${canonicalSuffix}`); + variants.add(canonicalSuffix); + if (id !== normalized) { + variants.add(id); + } + return Array.from(variants); +} + +function parseTaskIdSegments(value: string): number[] | null { + const withoutPrefix = value.startsWith(TASK_ID_PREFIX) ? value.slice(TASK_ID_PREFIX.length) : value; + if (!/^[0-9]+(?:\.[0-9]+)*$/.test(withoutPrefix)) { + return null; + } + return withoutPrefix.split(".").map((segment) => Number.parseInt(segment, 10)); +} + +interface SearchableTask { + task: Task; + title: string; + bodyText: string; + id: string; + idVariants: string[]; + statusLower: string; + priorityLower?: string; +} + +function buildSearchableTask(task: Task): SearchableTask { + const bodyParts: string[] = []; + if (task.description) bodyParts.push(task.description); + if (task.implementationPlan) bodyParts.push(task.implementationPlan); + if (task.implementationNotes) bodyParts.push(task.implementationNotes); + if (task.labels?.length) bodyParts.push(task.labels.join(" ")); + if (task.assignee?.length) bodyParts.push(task.assignee.join(" ")); + + return { + task, + title: task.title, + bodyText: bodyParts.join(" "), + id: task.id, + idVariants: createTaskIdVariants(task.id), + statusLower: (task.status || "").toLowerCase(), + priorityLower: task.priority?.toLowerCase(), + }; +} + +/** + * Create an in-memory search index for tasks + */ +export function createTaskSearchIndex(tasks: Task[]): TaskSearchIndex { + const searchableTasks = tasks.map(buildSearchableTask); + + const fuse = new Fuse(searchableTasks, { + includeScore: true, + threshold: 0.35, + ignoreLocation: true, + minMatchCharLength: 2, + keys: [ + { name: "title", weight: 0.35 }, + { name: "bodyText", weight: 0.3 }, + { name: "id", weight: 0.2 }, + { name: "idVariants", weight: 0.1 }, + ], + }); + + return { + search(options: TaskSearchOptions): Task[] { + let results: SearchableTask[]; + + // If we have a query, use Fuse for fuzzy search + if (options.query?.trim()) { + const fuseResults = fuse.search(options.query.trim()); + results = fuseResults.map((r) => r.item); + } else { + // No query - start with all tasks + results = [...searchableTasks]; + } + + // Apply status filter + if (options.status) { + const statusLower = options.status.toLowerCase(); + results = results.filter((t) => t.statusLower === statusLower); + } + + // Apply priority filter + if (options.priority) { + const priorityLower = options.priority.toLowerCase(); + results = results.filter((t) => t.priorityLower === priorityLower); + } + + return results.map((r) => r.task); + }, + }; +} diff --git a/src/utils/task-sorting.ts b/src/utils/task-sorting.ts new file mode 100644 index 0000000..edd7d79 --- /dev/null +++ b/src/utils/task-sorting.ts @@ -0,0 +1,185 @@ +/** + * Parse a task ID into its numeric components for proper sorting. + * Handles both simple IDs (task-5) and decimal IDs (task-5.2.1) + */ +export function parseTaskId(taskId: string): number[] { + // Remove the "task-" prefix if present + const numericPart = taskId.replace(/^task-/, ""); + + // Try to extract numeric parts from the ID + // First check if it's a standard numeric ID (e.g., "1", "1.2", etc.) + const dotParts = numericPart.split("."); + const numericParts = dotParts.map((part) => { + const num = Number.parseInt(part, 10); + return Number.isNaN(num) ? null : num; + }); + + // If all parts are numeric, return them + if (numericParts.every((n) => n !== null)) { + return numericParts as number[]; + } + + // Otherwise, try to extract trailing number (e.g., "draft2" -> 2) + const trailingNumberMatch = numericPart.match(/(\d+)$/); + if (trailingNumberMatch) { + const [, num] = trailingNumberMatch; + return [Number.parseInt(num ?? "0", 10)]; + } + + // No numeric parts found, return 0 for consistent sorting + return [0]; +} + +/** + * Compare two task IDs numerically. + * Returns negative if a < b, positive if a > b, 0 if equal. + * + * Examples: + * - task-2 comes before task-10 + * - task-2 comes before task-2.1 + * - task-2.1 comes before task-2.2 + * - task-2.2 comes before task-2.10 + */ +export function compareTaskIds(a: string, b: string): number { + const aParts = parseTaskId(a); + const bParts = parseTaskId(b); + + // Compare each numeric part + const maxLength = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < maxLength; i++) { + const aNum = aParts[i] ?? 0; + const bNum = bParts[i] ?? 0; + + if (aNum !== bNum) { + return aNum - bNum; + } + } + + // All parts are equal + return 0; +} + +/** + * Sort an array of objects by their task ID property numerically. + * Returns a new sorted array without mutating the original. + */ +export function sortByTaskId<T extends { id: string }>(items: T[]): T[] { + return [...items].sort((a, b) => compareTaskIds(a.id, b.id)); +} + +/** + * Sort an array of tasks by their priority property. + * Priority order: high > medium > low > undefined + * Tasks with the same priority are sorted by task ID. + */ +export function sortByPriority<T extends { id: string; priority?: "high" | "medium" | "low" }>(items: T[]): T[] { + const priorityWeight = { + high: 3, + medium: 2, + low: 1, + }; + + return [...items].sort((a, b) => { + const aWeight = a.priority ? priorityWeight[a.priority] : 0; + const bWeight = b.priority ? priorityWeight[b.priority] : 0; + + // First sort by priority (higher weight = higher priority) + if (aWeight !== bWeight) { + return bWeight - aWeight; + } + + // If priorities are the same, sort by task ID + return compareTaskIds(a.id, b.id); + }); +} + +/** + * Sort an array of tasks by their ordinal property, then by task ID. + * Tasks with ordinal values come before tasks without. + * Tasks with the same ordinal (or both undefined) are sorted by task ID. + */ +export function sortByOrdinal<T extends { id: string; ordinal?: number }>(items: T[]): T[] { + return [...items].sort((a, b) => { + // Tasks with ordinal come before tasks without + if (a.ordinal !== undefined && b.ordinal === undefined) { + return -1; + } + if (a.ordinal === undefined && b.ordinal !== undefined) { + return 1; + } + + // Both have ordinals - sort by ordinal value + if (a.ordinal !== undefined && b.ordinal !== undefined) { + if (a.ordinal !== b.ordinal) { + return a.ordinal - b.ordinal; + } + } + + // Same ordinal (or both undefined) - sort by task ID + return compareTaskIds(a.id, b.id); + }); +} + +/** + * Sort an array of tasks considering ordinal first, then priority, then ID. + * This is the default sorting for the board view. + */ +export function sortByOrdinalAndPriority< + T extends { id: string; ordinal?: number; priority?: "high" | "medium" | "low" }, +>(items: T[]): T[] { + const priorityWeight = { + high: 3, + medium: 2, + low: 1, + }; + + return [...items].sort((a, b) => { + // Tasks with ordinal come before tasks without + if (a.ordinal !== undefined && b.ordinal === undefined) { + return -1; + } + if (a.ordinal === undefined && b.ordinal !== undefined) { + return 1; + } + + // Both have ordinals - sort by ordinal value + if (a.ordinal !== undefined && b.ordinal !== undefined) { + if (a.ordinal !== b.ordinal) { + return a.ordinal - b.ordinal; + } + } + + // Same ordinal (or both undefined) - sort by priority + const aWeight = a.priority ? priorityWeight[a.priority] : 0; + const bWeight = b.priority ? priorityWeight[b.priority] : 0; + + if (aWeight !== bWeight) { + return bWeight - aWeight; + } + + // Same priority - sort by task ID + return compareTaskIds(a.id, b.id); + }); +} + +/** + * Sort tasks by a specified field with fallback to task ID sorting. + * Supported fields: 'priority', 'id', 'ordinal' + */ +export function sortTasks<T extends { id: string; priority?: "high" | "medium" | "low"; ordinal?: number }>( + items: T[], + sortField: string, +): T[] { + switch (sortField?.toLowerCase()) { + case "priority": + return sortByPriority(items); + case "id": + return sortByTaskId(items); + case "ordinal": + return sortByOrdinal(items); + default: + // Default to ordinal + priority sorting for board view + return sortByOrdinalAndPriority(items); + } +} diff --git a/src/utils/task-watcher.ts b/src/utils/task-watcher.ts new file mode 100644 index 0000000..009f749 --- /dev/null +++ b/src/utils/task-watcher.ts @@ -0,0 +1,75 @@ +import { type FSWatcher, watch } from "node:fs"; +import { join } from "node:path"; +import type { Core } from "../core/backlog.ts"; +import type { Task } from "../types/index.ts"; + +export interface TaskWatcherCallbacks { + /** Called when a new task file is created */ + onTaskAdded?: (task: Task) => void | Promise<void>; + /** Called when an existing task file is modified */ + onTaskChanged?: (task: Task) => void | Promise<void>; + /** Called when a task file is removed */ + onTaskRemoved?: (taskId: string) => void | Promise<void>; +} + +/** + * Watch the backlog/tasks directory for changes and emit incremental updates. + * Uses node:fs.watch as implemented by Bun runtime. + */ +export function watchTasks(core: Core, callbacks: TaskWatcherCallbacks): { stop: () => void } { + const tasksDir = core.filesystem.tasksDir; + + const watcher: FSWatcher = watch(tasksDir, { recursive: false }, async (eventType, filename) => { + // Normalize filename to a string when available + let fileName: string | undefined; + if (typeof filename === "string") { + fileName = filename; + } else if (filename != null) { + fileName = String(filename); + } + if (!fileName || !fileName.startsWith("task-") || !fileName.endsWith(".md")) { + return; + } + + const [firstPart] = fileName.split(" "); + if (!firstPart) return; // defensive, satisfies noUncheckedIndexedAccess + const taskId: string = firstPart; + + if (eventType === "change") { + const task = await core.filesystem.loadTask(taskId); + if (task) { + await callbacks.onTaskChanged?.(task); + } + return; + } + + if (eventType === "rename") { + // "rename" can be create, delete, or rename. Check if file exists. + try { + const fullPath = join(tasksDir, fileName); + const exists = await Bun.file(fullPath).exists(); + + if (!exists) { + await callbacks.onTaskRemoved?.(taskId); + return; + } + + const task = await core.filesystem.loadTask(taskId); + if (task) { + // Treat as a change; handlers may add if not present + await callbacks.onTaskChanged?.(task); + } + } catch { + // Ignore transient errors + } + } + }); + + return { + stop() { + try { + watcher.close(); + } catch {} + }, + }; +} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..124d772 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,21 @@ +// This will be replaced at build time for compiled executables +declare const __EMBEDDED_VERSION__: string | undefined; + +/** + * Get the version from package.json or embedded version + * @returns The version string from package.json or embedded at build time + */ +export async function getVersion(): Promise<string> { + // If this is a compiled executable with embedded version, use that + if (typeof __EMBEDDED_VERSION__ !== "undefined") { + return String(__EMBEDDED_VERSION__); + } + + // In development, read from package.json + try { + const packageJson = await Bun.file("package.json").json(); + return packageJson.version || "0.0.0"; + } catch { + return "0.0.0"; + } +} diff --git a/src/web/App.tsx b/src/web/App.tsx new file mode 100644 index 0000000..f675999 --- /dev/null +++ b/src/web/App.tsx @@ -0,0 +1,348 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import BoardPage from './components/BoardPage'; +import DocumentationDetail from './components/DocumentationDetail'; +import DecisionDetail from './components/DecisionDetail'; +import TaskList from './components/TaskList'; +import DraftsList from './components/DraftsList'; +import Settings from './components/Settings'; +import Statistics from './components/Statistics'; +import TaskDetailsModal from './components/TaskDetailsModal'; +import InitializationScreen from './components/InitializationScreen'; +import { SuccessToast } from './components/SuccessToast'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { + type Decision, + type DecisionSearchResult, + type Document, + type DocumentSearchResult, + type SearchResult, + type Task, + type TaskSearchResult, +} from '../types'; +import { apiClient } from './lib/api'; +import { useHealthCheckContext } from './contexts/HealthCheckContext'; +import { getWebVersion } from './utils/version'; + +function App() { + const [showModal, setShowModal] = useState(false); + const [editingTask, setEditingTask] = useState<Task | null>(null); + const [isDraftMode, setIsDraftMode] = useState(false); + const [statuses, setStatuses] = useState<string[]>([]); + const [projectName, setProjectName] = useState<string>(''); + const [showSuccessToast, setShowSuccessToast] = useState(false); + const [taskConfirmation, setTaskConfirmation] = useState<{task: Task, isDraft: boolean} | null>(null); + + // Initialization state + const [isInitialized, setIsInitialized] = useState<boolean | null>(null); + + // Centralized data state + const [tasks, setTasks] = useState<Task[]>([]); + const [docs, setDocs] = useState<Document[]>([]); + const [decisions, setDecisions] = useState<Decision[]>([]); + const [isLoading, setIsLoading] = useState(true); + + const { isOnline } = useHealthCheckContext(); + const previousOnlineRef = useRef<boolean | null>(null); + const hasBeenRunningRef = useRef(false); + + // Set version data attribute on body + React.useEffect(() => { + getWebVersion().then(version => { + if (version) { + document.body.setAttribute('data-version', `Backlog.md - v${version}`); + } + }); + }, []); + + // Check initialization status on mount + React.useEffect(() => { + const checkInitStatus = async () => { + try { + const status = await apiClient.checkStatus(); + setIsInitialized(status.initialized); + } catch (error) { + // If we can't check status, assume not initialized + console.error('Failed to check initialization status:', error); + setIsInitialized(false); + } + }; + checkInitStatus(); + }, []); + + const handleInitialized = useCallback(() => { + setIsInitialized(true); + }, []); + + const applySearchResults = useCallback((results: SearchResult[]) => { + const taskResults = results.filter((result): result is TaskSearchResult => result.type === 'task'); + const documentResults = results.filter((result): result is DocumentSearchResult => result.type === 'document'); + const decisionResults = results.filter((result): result is DecisionSearchResult => result.type === 'decision'); + + setTasks(taskResults.map((result) => result.task)); + setDocs(documentResults.map((result) => result.document)); + setDecisions(decisionResults.map((result) => result.decision)); + }, []); + + const loadAllData = useCallback(async () => { + try { + setIsLoading(true); + const [statusesData, configData, searchResults] = await Promise.all([ + apiClient.fetchStatuses(), + apiClient.fetchConfig(), + apiClient.search(), + ]); + + setStatuses(statusesData); + setProjectName(configData.projectName); + applySearchResults(searchResults); + } catch (error) { + console.error('Failed to load data:', error); + } finally { + setIsLoading(false); + } + }, [applySearchResults]); + + React.useEffect(() => { + // Only load data when initialized + if (isInitialized === true) { + loadAllData(); + } + }, [loadAllData, isInitialized]); + + // Reload data when connection is restored + React.useEffect(() => { + if (isOnline && previousOnlineRef.current === false) { + // Connection restored, reload data + const loadData = async () => { + try { + const results = await apiClient.search(); + applySearchResults(results); + } catch (error) { + console.error('Failed to reload data:', error); + } + }; + loadData(); + } + }, [applySearchResults, isOnline]); + + // Update document title when project name changes + React.useEffect(() => { + if (projectName) { + document.title = `${projectName} - Task Management`; + } + }, [projectName]); + + // Mark that we've been running after initial load + useEffect(() => { + const timer = setTimeout(() => { + hasBeenRunningRef.current = true; + }, 2000); // Wait 2 seconds after page load + return () => clearTimeout(timer); + }, []); + + // Show success toast when connection is restored + useEffect(() => { + // Only show toast if: + // 1. We went from offline to online AND + // 2. We've been running for a while (not initial page load) + if (isOnline && previousOnlineRef.current === false && hasBeenRunningRef.current) { + setShowSuccessToast(true); + // Auto-dismiss after 4 seconds + const timer = setTimeout(() => { + setShowSuccessToast(false); + }, 4000); + return () => clearTimeout(timer); + } + + // Update the ref for next time + previousOnlineRef.current = isOnline; + }, [isOnline]); + + const handleNewTask = () => { + setEditingTask(null); + setIsDraftMode(false); + setShowModal(true); + }; + + const handleNewDraft = () => { + // Create a draft task (same as new task but with status 'Draft') + setEditingTask(null); + setIsDraftMode(true); + setShowModal(true); + }; + + const handleEditTask = (task: Task) => { + setEditingTask(task); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setEditingTask(null); + setIsDraftMode(false); + }; + + const refreshData = useCallback(async () => { + await loadAllData(); + }, [loadAllData]); + + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${protocol}//${window.location.host}`); + ws.onmessage = (event) => { + if (event.data === "tasks-updated") { + refreshData(); + } else if (event.data === "config-updated") { + // Reload statuses when config changes + loadAllData(); + } + }; + return () => ws.close(); + }, [refreshData, loadAllData]); + + const handleSubmitTask = async (taskData: Partial<Task>) => { + // Don't catch errors here - let TaskDetailsModal handle them + if (editingTask) { + await apiClient.updateTask(editingTask.id, taskData); + } else { + // Set status to 'Draft' if in draft mode + const finalTaskData = isDraftMode + ? { ...taskData, status: 'Draft' } + : taskData; + const createdTask = await apiClient.createTask(finalTaskData as Omit<Task, "id" | "createdDate">); + + // Show task creation confirmation + setTaskConfirmation({ task: createdTask, isDraft: isDraftMode }); + + // Auto-dismiss after 4 seconds + setTimeout(() => { + setTaskConfirmation(null); + }, 4000); + } + handleCloseModal(); + await refreshData(); + + // If we're on the drafts page and created a draft, trigger a refresh + if (isDraftMode && window.location.pathname === '/drafts') { + // Trigger refresh by updating a timestamp that DraftsList can watch + window.dispatchEvent(new Event('drafts-updated')); + } + }; + + const handleArchiveTask = async (taskId: string) => { + try { + await apiClient.archiveTask(taskId); + handleCloseModal(); + await refreshData(); + } catch (error) { + console.error('Failed to archive task:', error); + } + }; + + // Show loading state while checking initialization + if (isInitialized === null) { + return ( + <ThemeProvider> + <div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900"> + <div className="text-lg text-gray-600 dark:text-gray-300">Loading...</div> + </div> + </ThemeProvider> + ); + } + + // Show initialization screen if not initialized + if (isInitialized === false) { + return ( + <ThemeProvider> + <InitializationScreen onInitialized={handleInitialized} /> + </ThemeProvider> + ); + } + + return ( + <ThemeProvider> + <BrowserRouter> + <Routes> + <Route + path="/" + element={ + <Layout + projectName={projectName} + showSuccessToast={showSuccessToast} + onDismissToast={() => setShowSuccessToast(false)} + tasks={tasks} + docs={docs} + decisions={decisions} + isLoading={isLoading} + onRefreshData={refreshData} + /> + } + > + <Route + index + element={ + <BoardPage + onEditTask={handleEditTask} + onNewTask={handleNewTask} + tasks={tasks} + onRefreshData={refreshData} + statuses={statuses} + isLoading={isLoading} + /> + } + /> + <Route + path="tasks" + element={ + <TaskList + onEditTask={handleEditTask} + onNewTask={handleNewTask} + tasks={tasks} + availableStatuses={statuses} + onRefreshData={refreshData} + /> + } + /> + <Route path="drafts" element={<DraftsList onEditTask={handleEditTask} onNewDraft={handleNewDraft} />} /> + <Route path="documentation" element={<DocumentationDetail docs={docs} onRefreshData={refreshData} />} /> + <Route path="documentation/:id" element={<DocumentationDetail docs={docs} onRefreshData={refreshData} />} /> + <Route path="documentation/:id/:title" element={<DocumentationDetail docs={docs} onRefreshData={refreshData} />} /> + <Route path="decisions" element={<DecisionDetail decisions={decisions} onRefreshData={refreshData} />} /> + <Route path="decisions/:id" element={<DecisionDetail decisions={decisions} onRefreshData={refreshData} />} /> + <Route path="decisions/:id/:title" element={<DecisionDetail decisions={decisions} onRefreshData={refreshData} />} /> + <Route path="statistics" element={<Statistics tasks={tasks} isLoading={isLoading} onEditTask={handleEditTask} projectName={projectName} />} /> + <Route path="settings" element={<Settings />} /> + </Route> + </Routes> + + <TaskDetailsModal + task={editingTask || undefined} + isOpen={showModal} + onClose={handleCloseModal} + onSaved={refreshData} + onSubmit={handleSubmitTask} + onArchive={editingTask ? () => handleArchiveTask(editingTask.id) : undefined} + availableStatuses={isDraftMode ? ['Draft', ...statuses] : statuses} + isDraftMode={isDraftMode} + /> + + {/* Task Creation Confirmation Toast */} + {taskConfirmation && ( + <SuccessToast + message={`${taskConfirmation.isDraft ? 'Draft' : 'Task'} "${taskConfirmation.task.title}" created successfully! (${taskConfirmation.task.id.replace('task-', '')})`} + onDismiss={() => setTaskConfirmation(null)} + icon={ + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + } + /> + )} + </BrowserRouter> + </ThemeProvider> + ); +} + +export default App; diff --git a/src/web/components/AcceptanceCriteriaEditor.tsx b/src/web/components/AcceptanceCriteriaEditor.tsx new file mode 100644 index 0000000..f185698 --- /dev/null +++ b/src/web/components/AcceptanceCriteriaEditor.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from "react"; +import { type AcceptanceCriterion } from "../../types"; + +interface Props { + criteria: AcceptanceCriterion[]; + onChange: (criteria: AcceptanceCriterion[]) => void; +} + +const AcceptanceCriteriaEditor: React.FC<Props> = ({ criteria: initial, onChange }) => { + const [criteria, setCriteria] = useState<AcceptanceCriterion[]>(initial || []); + const [newCriterion, setNewCriterion] = useState(""); + + // Refs to auto-resize textareas + const itemRefs = useRef<Record<number, HTMLTextAreaElement | null>>({}); + const newRef = useRef<HTMLTextAreaElement | null>(null); + + useEffect(() => { + setCriteria(initial || []); + }, [initial]); + + // Auto-resize helper + const autoResize = (el: HTMLTextAreaElement | null) => { + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }; + + // Resize when criteria change (e.g., initial load or edits) + useEffect(() => { + Object.values(itemRefs.current).forEach((el) => autoResize(el)); + }, [criteria]); + + // Resize new criterion textarea when text changes + useEffect(() => { + autoResize(newRef.current); + }, [newCriterion]); + + const handleToggle = (index: number, checked: boolean) => { + const updated = criteria.map((c) => (c.index === index ? { ...c, checked } : c)); + setCriteria(updated); + onChange(updated); + }; + + const handleTextChange = (index: number, text: string) => { + const updated = criteria.map((c) => (c.index === index ? { ...c, text } : c)); + setCriteria(updated); + onChange(updated); + }; + + const handleRemove = (index: number) => { + const updated = criteria.filter((c) => c.index !== index).map((c, i) => ({ ...c, index: i + 1 })); + setCriteria(updated); + onChange(updated); + }; + + const handleAdd = () => { + const text = newCriterion.trim(); + if (!text) return; + const updated = [...criteria, { checked: false, text, index: criteria.length + 1 }]; + setCriteria(updated); + setNewCriterion(""); + onChange(updated); + }; + + return ( + <div className="space-y-2"> + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 transition-colors duration-200"> + Acceptance Criteria + </label> + <ul className="space-y-2"> + {criteria.map((c) => ( + <li key={c.index} className="flex items-center gap-2"> + <input + type="checkbox" + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + checked={c.checked} + onChange={(e) => handleToggle(c.index, e.target.checked)} + /> + <textarea + ref={(el) => { itemRefs.current[c.index] = el; }} + rows={1} + value={c.text} + onChange={(e) => handleTextChange(c.index, e.target.value)} + onInput={(e) => autoResize(e.currentTarget)} + className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 resize-none overflow-hidden leading-5" + /> + <button + type="button" + onClick={() => handleRemove(c.index)} + className="px-2 py-1 text-sm text-red-600 dark:text-red-400 hover:underline" + > + Remove + </button> + </li> + ))} + <li className="flex items-center gap-2"> + <textarea + ref={newRef} + rows={1} + value={newCriterion} + onChange={(e) => setNewCriterion(e.target.value)} + onInput={(e) => autoResize(e.currentTarget)} + placeholder="New criterion" + className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 resize-none overflow-hidden leading-5" + /> + <button + type="button" + onClick={handleAdd} + className="px-2 py-1 text-sm bg-blue-500 dark:bg-blue-600 text-white rounded-md hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-400 dark:focus:ring-blue-500 transition-colors duration-200" + > + Add + </button> + </li> + </ul> + </div> + ); +}; + +export default AcceptanceCriteriaEditor; diff --git a/src/web/components/Board.tsx b/src/web/components/Board.tsx new file mode 100644 index 0000000..1b9ddef --- /dev/null +++ b/src/web/components/Board.tsx @@ -0,0 +1,212 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { type Task } from '../../types'; +import { apiClient, type ReorderTaskPayload } from '../lib/api'; +import TaskColumn from './TaskColumn'; +import CleanupModal from './CleanupModal'; +import { SuccessToast } from './SuccessToast'; + +interface BoardProps { + onEditTask: (task: Task) => void; + onNewTask: () => void; + highlightTaskId?: string | null; + tasks: Task[]; + onRefreshData?: () => Promise<void>; + statuses: string[]; + isLoading: boolean; +} + +const Board: React.FC<BoardProps> = ({ + onEditTask, + onNewTask, + highlightTaskId, + tasks, + onRefreshData, + statuses, + isLoading, +}) => { + const [updateError, setUpdateError] = useState<string | null>(null); + const [dragSourceStatus, setDragSourceStatus] = useState<string | null>(null); + const [showCleanupModal, setShowCleanupModal] = useState(false); + const [cleanupSuccessMessage, setCleanupSuccessMessage] = useState<string | null>(null); + + // Handle highlighting a task (opening its edit popup) + useEffect(() => { + if (highlightTaskId && tasks.length > 0) { + const taskToHighlight = tasks.find(task => task.id === highlightTaskId); + if (taskToHighlight) { + // Use setTimeout to ensure the task is found and modal opens properly + setTimeout(() => { + onEditTask(taskToHighlight); + }, 100); + } + } + }, [highlightTaskId, tasks, onEditTask]); + + const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => { + try { + await apiClient.updateTask(taskId, updates); + // Refresh data to reflect the changes + if (onRefreshData) { + await onRefreshData(); + } + setUpdateError(null); + } catch (err) { + setUpdateError(err instanceof Error ? err.message : 'Failed to update task'); + } + }; + + const handleTaskReorder = async (payload: ReorderTaskPayload) => { + try { + await apiClient.reorderTask(payload); + // Refresh data to reflect the changes + if (onRefreshData) { + await onRefreshData(); + } + setUpdateError(null); + } catch (err) { + setUpdateError(err instanceof Error ? err.message : 'Failed to reorder task'); + } + }; + + const handleCleanupSuccess = async (movedCount: number) => { + setShowCleanupModal(false); + setCleanupSuccessMessage(`Successfully moved ${movedCount} task${movedCount !== 1 ? 's' : ''} to completed folder`); + + // Refresh data to reflect the changes + if (onRefreshData) { + await onRefreshData(); + } + + // Auto-dismiss after 4 seconds + setTimeout(() => { + setCleanupSuccessMessage(null); + }, 4000); + }; + + const tasksByStatus = useMemo(() => { + const grouped = new Map<string, Task[]>(); + for (const status of statuses) { + grouped.set(status, []); + } + + for (const task of tasks) { + const statusKey = task.status ?? ''; + const list = grouped.get(statusKey); + if (list) { + list.push(task); + } else if (statusKey) { + grouped.set(statusKey, [task]); + } + } + return grouped; + }, [statuses, tasks]); + + const getTasksByStatus = (status: string): Task[] => { + const filteredTasks = tasksByStatus.get(status) ?? tasks.filter(task => task.status === status); + + // Sort tasks based on ordinal first, then by priority/date + return filteredTasks.slice().sort((a, b) => { + // Tasks with ordinal come before tasks without + if (a.ordinal !== undefined && b.ordinal === undefined) { + return -1; + } + if (a.ordinal === undefined && b.ordinal !== undefined) { + return 1; + } + + // Both have ordinals - sort by ordinal value + if (a.ordinal !== undefined && b.ordinal !== undefined) { + if (a.ordinal !== b.ordinal) { + return a.ordinal - b.ordinal; + } + } + + // Same ordinal (or both undefined) - use existing date-based sorting + const isDoneStatus = status.toLowerCase().includes('done') || + status.toLowerCase().includes('complete'); + + if (isDoneStatus) { + // For "Done" tasks, sort by updatedDate (descending) - newest first + const aDate = a.updatedDate || a.createdDate; + const bDate = b.updatedDate || b.createdDate; + return bDate.localeCompare(aDate); + } else { + // For other statuses, sort by createdDate (ascending) - oldest first + return a.createdDate.localeCompare(b.createdDate); + } + }); + }; + + if (isLoading && statuses.length === 0) { + return ( + <div className="flex items-center justify-center py-8"> + <div className="text-lg text-gray-600 dark:text-gray-300 transition-colors duration-200">Loading tasks...</div> + </div> + ); + } + + // Dynamic layout using flexbox: + // - Columns are flex items with equal growth (flex-1) to divide space evenly + // - A minimum width keeps columns readable; beyond available space, container scrolls horizontally + // - Works uniformly for any number of columns without per-count conditionals + + return ( + <div className="w-full"> + {updateError && ( + <div className="mb-4 rounded-md bg-red-100 px-4 py-3 text-sm text-red-700 dark:bg-red-900/40 dark:text-red-200 transition-colors duration-200"> + {updateError} + </div> + )} + <div className="flex items-center justify-between mb-6"> + <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 transition-colors duration-200">Kanban Board</h2> + <button + className="inline-flex items-center px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400 dark:focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200 cursor-pointer" + onClick={onNewTask} + > + + New Task + </button> + </div> + <div className="overflow-x-auto pb-2"> + <div className="flex flex-row flex-nowrap gap-4 w-full"> + {statuses.map((status) => ( + <div key={status} className="flex-1 min-w-[16rem]"> + <TaskColumn + title={status} + tasks={getTasksByStatus(status)} + onTaskUpdate={handleTaskUpdate} + onEditTask={onEditTask} + onTaskReorder={handleTaskReorder} + dragSourceStatus={dragSourceStatus} + onDragStart={() => setDragSourceStatus(status)} + onDragEnd={() => setDragSourceStatus(null)} + onCleanup={status.toLowerCase() === 'done' ? () => setShowCleanupModal(true) : undefined} + /> + </div> + ))} + </div> + </div> + + {/* Cleanup Modal */} + <CleanupModal + isOpen={showCleanupModal} + onClose={() => setShowCleanupModal(false)} + onSuccess={handleCleanupSuccess} + /> + + {/* Cleanup Success Toast */} + {cleanupSuccessMessage && ( + <SuccessToast + message={cleanupSuccessMessage} + onDismiss={() => setCleanupSuccessMessage(null)} + icon={ + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + } + /> + )} + </div> + ); +}; + +export default Board; diff --git a/src/web/components/BoardPage.tsx b/src/web/components/BoardPage.tsx new file mode 100644 index 0000000..d0b7dfb --- /dev/null +++ b/src/web/components/BoardPage.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import Board from './Board'; +import { type Task } from '../../types'; + +interface BoardPageProps { + onEditTask: (task: Task) => void; + onNewTask: () => void; + tasks: Task[]; + onRefreshData?: () => Promise<void>; + statuses: string[]; + isLoading: boolean; +} + +export default function BoardPage({ onEditTask, onNewTask, tasks, onRefreshData, statuses, isLoading }: BoardPageProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const [highlightTaskId, setHighlightTaskId] = useState<string | null>(null); + + useEffect(() => { + const highlight = searchParams.get('highlight'); + if (highlight) { + setHighlightTaskId(highlight); + // Clear the highlight parameter after setting it + setSearchParams(params => { + params.delete('highlight'); + return params; + }, { replace: true }); + } + }, [searchParams, setSearchParams]); + + // Clear highlight after it's been used + const handleEditTask = (task: Task) => { + setHighlightTaskId(null); // Clear highlight so popup doesn't reopen + onEditTask(task); + }; + + return ( + <div className="container mx-auto px-4 py-8 transition-colors duration-200"> + <Board + onEditTask={handleEditTask} + onNewTask={onNewTask} + highlightTaskId={highlightTaskId} + tasks={tasks} + onRefreshData={onRefreshData} + statuses={statuses} + isLoading={isLoading} + /> + </div> + ); +} diff --git a/src/web/components/ChipInput.tsx b/src/web/components/ChipInput.tsx new file mode 100644 index 0000000..b5d477c --- /dev/null +++ b/src/web/components/ChipInput.tsx @@ -0,0 +1,100 @@ +import React, { useState, type KeyboardEvent } from 'react'; + +interface ChipInputProps { + value: string[]; + onChange: (values: string[]) => void; + placeholder?: string; + label: string; + name: string; + disabled?: boolean; +} + +const ChipInput: React.FC<ChipInputProps> = ({ value, onChange, placeholder, label, name, disabled }) => { + const [inputValue, setInputValue] = useState(''); + const inputId = `chip-input-${name}`; + + const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { + if (disabled) return; + if ((e.key === 'Enter' || e.key === ',') && inputValue.trim()) { + e.preventDefault(); + const newValue = inputValue.trim(); + if (!value.includes(newValue)) { + onChange([...value, newValue]); + } + setInputValue(''); + } else if (e.key === 'Backspace' && !inputValue && value.length > 0) { + // Remove last chip when backspace is pressed on empty input + onChange(value.slice(0, -1)); + } + }; + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + if (disabled) return; + const newValue = e.target.value; + // Check if user typed a comma + if (newValue.endsWith(',')) { + const chipValue = newValue.slice(0, -1).trim(); + if (chipValue && !value.includes(chipValue)) { + onChange([...value, chipValue]); + setInputValue(''); + } else { + setInputValue(''); + } + } else { + setInputValue(newValue); + } + }; + + const removeChip = (index: number) => { + if (disabled) return; + onChange(value.filter((_, i) => i !== index)); + }; + + return ( + <div className="w-full"> + <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 transition-colors duration-200"> + {label} + </label> + <div className={`relative w-full min-h-10 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 rounded-md focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-400 focus-within:border-transparent transition-colors duration-200 pr-2 ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}> + <div className="flex flex-wrap gap-2 items-center w-full"> + {value.map((item, index) => ( + <span + key={index} + className="inline-flex items-center gap-1 px-2 py-0.5 text-sm bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 rounded-md flex-shrink-0 min-w-0 max-w-full transition-colors duration-200" + > + <span className="truncate max-w-[16rem] sm:max-w-[20rem] md:max-w-[24rem]">{item}</span> + {!disabled && ( + <button + type="button" + onClick={() => removeChip(index)} + className="hover:bg-blue-200 dark:hover:bg-blue-800 rounded-sm p-0.5 transition-colors duration-200 cursor-pointer" + aria-label={`Remove ${item}`} + > + <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> + <path + fillRule="evenodd" + d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" + clipRule="evenodd" + /> + </svg> + </button> + )} + </span> + ))} + <input + id={inputId} + type="text" + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? placeholder : ''} + className="flex-1 min-w-[2ch] outline-none text-sm bg-transparent text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400" + disabled={disabled} + /> + </div> + </div> + </div> + ); +}; + +export default ChipInput; diff --git a/src/web/components/CleanupModal.tsx b/src/web/components/CleanupModal.tsx new file mode 100644 index 0000000..495ce28 --- /dev/null +++ b/src/web/components/CleanupModal.tsx @@ -0,0 +1,230 @@ +import React, { useState } from 'react'; +import Modal from './Modal'; +import { apiClient } from '../lib/api'; + +interface CleanupModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (movedCount: number) => void; +} + +interface TaskPreview { + id: string; + title: string; + updatedDate?: string; + createdDate: string; +} + +const AGE_OPTIONS = [ + { label: "1 day", value: 1 }, + { label: "1 week", value: 7 }, + { label: "2 weeks", value: 14 }, + { label: "3 weeks", value: 21 }, + { label: "1 month", value: 30 }, + { label: "3 months", value: 90 }, + { label: "1 year", value: 365 }, +]; + +const CleanupModal: React.FC<CleanupModalProps> = ({ isOpen, onClose, onSuccess }) => { + const [selectedAge, setSelectedAge] = useState<number | null>(null); + const [previewTasks, setPreviewTasks] = useState<TaskPreview[]>([]); + const [previewCount, setPreviewCount] = useState(0); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); + const [error, setError] = useState<string | null>(null); + const [showConfirmation, setShowConfirmation] = useState(false); + + const handleAgeSelect = async (age: number) => { + setSelectedAge(age); + setError(null); + setIsLoadingPreview(true); + + try { + const preview = await apiClient.getCleanupPreview(age); + setPreviewTasks(preview.tasks); + setPreviewCount(preview.count); + setShowConfirmation(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load preview'); + setPreviewTasks([]); + setPreviewCount(0); + } finally { + setIsLoadingPreview(false); + } + }; + + const handleExecuteCleanup = async () => { + if (selectedAge === null) return; + + setIsExecuting(true); + setError(null); + + try { + const result = await apiClient.executeCleanup(selectedAge); + + if (result.success) { + onSuccess(result.movedCount); + handleClose(); + } else { + setError(result.message || 'Cleanup failed'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to execute cleanup'); + } finally { + setIsExecuting(false); + } + }; + + const handleClose = () => { + setSelectedAge(null); + setPreviewTasks([]); + setPreviewCount(0); + setError(null); + setShowConfirmation(false); + onClose(); + }; + + const formatDate = (dateStr?: string) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( + <Modal isOpen={isOpen} onClose={handleClose} title="Clean Up Completed Tasks" maxWidthClass="max-w-3xl"> + <div className="space-y-6"> + {/* Age Selector */} + <div> + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> + Move tasks to completed folder if they are older than: + </label> + <div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> + {AGE_OPTIONS.map(option => ( + <button + key={option.value} + onClick={() => handleAgeSelect(option.value)} + disabled={isLoadingPreview || isExecuting} + className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${ + selectedAge === option.value + ? 'bg-blue-500 dark:bg-blue-600 text-white' + : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' + } disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer`} + > + {option.label} + </button> + ))} + </div> + <p className="mt-2 text-xs text-gray-500 dark:text-gray-400"> + Tasks will be moved to the backlog/completed/ folder for archival purposes + </p> + </div> + + {/* Error Message */} + {error && ( + <div className="rounded-md bg-red-100 dark:bg-red-900/40 p-3"> + <p className="text-sm text-red-700 dark:text-red-200">{error}</p> + </div> + )} + + {/* Loading Preview */} + {isLoadingPreview && ( + <div className="text-center py-4"> + <div className="text-gray-600 dark:text-gray-400">Loading preview...</div> + </div> + )} + + {/* Preview Section */} + {!isLoadingPreview && selectedAge !== null && !showConfirmation && ( + <div> + {previewCount === 0 ? ( + <div className="text-center py-8 text-gray-500 dark:text-gray-400"> + No tasks found that are older than {AGE_OPTIONS.find(o => o.value === selectedAge)?.label}. + </div> + ) : ( + <> + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> + Found {previewCount} task{previewCount !== 1 ? 's' : ''} to clean up: + </h3> + <div className="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md"> + <ul className="divide-y divide-gray-200 dark:divide-gray-700"> + {previewTasks.slice(0, 10).map(task => ( + <li key={task.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-200"> + <div className="flex justify-between items-start"> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> + {task.title} + </p> + <p className="text-xs text-gray-500 dark:text-gray-400"> + {task.id} β€’ {formatDate(task.updatedDate || task.createdDate)} + </p> + </div> + </div> + </li> + ))} + {previewCount > 10 && ( + <li className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 italic"> + ... and {previewCount - 10} more + </li> + )} + </ul> + </div> + </> + )} + </div> + )} + + {/* Confirmation Section */} + {showConfirmation && previewCount > 0 && ( + <div className="rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 p-4"> + <h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2"> + Confirm Cleanup + </h3> + <p className="text-sm text-amber-700 dark:text-amber-300"> + Are you sure you want to move {previewCount} task{previewCount !== 1 ? 's' : ''} to the completed folder? + These tasks will be archived in backlog/completed/ and removed from the board. + </p> + </div> + )} + + {/* Action Buttons */} + <div className="flex justify-end gap-3"> + <button + onClick={handleClose} + disabled={isExecuting} + className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors duration-200 cursor-pointer" + > + Cancel + </button> + + {selectedAge !== null && previewCount > 0 && ( + <> + {!showConfirmation ? ( + <button + onClick={() => setShowConfirmation(true)} + disabled={isLoadingPreview || isExecuting} + className="px-4 py-2 text-sm font-medium text-white bg-blue-500 dark:bg-blue-600 rounded-md hover:bg-blue-600 dark:hover:bg-blue-700 disabled:opacity-50 transition-colors duration-200 cursor-pointer" + > + Continue + </button> + ) : ( + <button + onClick={handleExecuteCleanup} + disabled={isExecuting} + className="px-4 py-2 text-sm font-medium text-white bg-red-500 dark:bg-red-600 rounded-md hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 transition-colors duration-200 cursor-pointer" + > + {isExecuting ? 'Moving Tasks...' : `Move ${previewCount} Task${previewCount !== 1 ? 's' : ''}`} + </button> + )} + </> + )} + </div> + </div> + </Modal> + ); +}; + +export default CleanupModal; \ No newline at end of file diff --git a/src/web/components/DecisionDetail.tsx b/src/web/components/DecisionDetail.tsx new file mode 100644 index 0000000..727504e --- /dev/null +++ b/src/web/components/DecisionDetail.tsx @@ -0,0 +1,372 @@ +import React, { useState, useEffect, memo } from 'react'; +import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { apiClient } from '../lib/api'; +import MDEditor from '@uiw/react-md-editor'; +import MermaidMarkdown from './MermaidMarkdown'; +import { type Decision } from '../../types'; +import ErrorBoundary from '../components/ErrorBoundary'; +import { SuccessToast } from './SuccessToast'; +import { useTheme } from '../contexts/ThemeContext'; +import { sanitizeUrlTitle } from '../utils/urlHelpers'; + +// Utility function for ID transformations +const stripIdPrefix = (id: string): string => { + if (id.startsWith('decision-')) return id.replace('decision-', ''); + return id; +}; + +// Custom MDEditor wrapper for proper height handling +const MarkdownEditor = memo(function MarkdownEditor({ + value, + onChange, + isEditing +}: { + value: string; + onChange?: (val: string | undefined) => void; + isEditing: boolean; + isReadonly?: boolean; +}) { + const { theme } = useTheme(); + if (!isEditing) { + // Preview mode - just show the rendered markdown without editor UI + return ( + <div className="prose prose-sm !max-w-none w-full p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" data-color-mode={theme}> + <MermaidMarkdown source={value} /> + </div> + ); + } + + // Edit mode - show full editor that fills the available space + return ( + <div className="h-full w-full flex flex-col"> + <div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800"> + <MDEditor + value={value} + onChange={onChange} + preview="edit" + height="100%" + hideToolbar={false} + data-color-mode={theme} + textareaProps={{ + placeholder: 'Write your decision documentation here...', + style: { + fontSize: '14px', + resize: 'none' + } + }} + /> + </div> + </div> + ); +}); + +// Utility function to add decision prefix for API calls +const addDecisionPrefix = (id: string): string => { + return id.startsWith('decision-') ? id : `decision-${id}`; +}; + +interface DecisionDetailProps { + decisions: Decision[]; + onRefreshData: () => Promise<void>; +} + +export default function DecisionDetail({ decisions, onRefreshData }: DecisionDetailProps) { + const { id, title } = useParams<{ id: string; title: string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const [decision, setDecision] = useState<Decision | null>(null); + const [content, setContent] = useState<string>(''); + const [originalContent, setOriginalContent] = useState<string>(''); + const [decisionTitle, setDecisionTitle] = useState<string>(''); + const [originalDecisionTitle, setOriginalDecisionTitle] = useState<string>(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + + const [isNewDecision, setIsNewDecision] = useState(false); + const [showSaveSuccess, setShowSaveSuccess] = useState(false); + + useEffect(() => { + if (id === 'new') { + // Handle new decision creation + setIsNewDecision(true); + setIsEditing(true); + setIsLoading(false); + setDecisionTitle(''); + setOriginalDecisionTitle(''); + setContent(''); + setOriginalContent(''); + } else if (id) { + setIsNewDecision(false); + setIsEditing(false); // Ensure we start in preview mode for existing decisions + loadDecisionContent(); + } + }, [id, decisions]); + + // Check for edit query parameter to start in edit mode + useEffect(() => { + if (searchParams.get('edit') === 'true') { + setIsEditing(true); + // Remove the edit parameter from URL + setSearchParams(params => { + params.delete('edit'); + return params; + }); + } + }, [searchParams, setSearchParams]); + + const loadDecisionContent = async () => { + if (!id) return; + + try { + setIsLoading(true); + // Find decision from props + const prefixedId = addDecisionPrefix(id); + const decision = decisions.find(d => d.id === prefixedId); + + // Always try to fetch the decision from API, whether we found it in decisions or not + // This ensures deep linking works even before the parent component loads the decisions array + try { + const fullDecision = await apiClient.fetchDecision(prefixedId); + setContent(fullDecision.rawContent || ''); + setOriginalContent(fullDecision.rawContent || ''); + setDecisionTitle(fullDecision.title || ''); + setOriginalDecisionTitle(fullDecision.title || ''); + // Update decision state with full data + setDecision(fullDecision); + } catch (fetchError) { + // If fetch fails and we don't have the decision in props, show error + if (!decision) { + console.error('Failed to load decision:', fetchError); + } else { + // We have basic info from props even if fetch failed + setDecision(decision); + setDecisionTitle(decision.title || ''); + setOriginalDecisionTitle(decision.title || ''); + } + } + } catch (error) { + console.error('Failed to load decision:', error); + } finally { + setIsLoading(false); + } + }; + + const handleSave = async () => { + if (!decisionTitle.trim()) { + console.error('Decision title is required'); + return; + } + + try { + setIsSaving(true); + + if (isNewDecision) { + // Create new decision + const decision = await apiClient.createDecision(decisionTitle); + // Refresh data and navigate to the new decision + await onRefreshData(); + // Show success toast + setShowSaveSuccess(true); + setTimeout(() => setShowSaveSuccess(false), 4000); + // Exit edit mode and navigate to the new decision + setIsEditing(false); + setIsNewDecision(false); + const newId = stripIdPrefix(decision.id); + navigate(`/decisions/${newId}/${sanitizeUrlTitle(decisionTitle)}`); + } else { + // Update existing decision + if (!id) return; + await apiClient.updateDecision(addDecisionPrefix(id), content); + // Refresh data from parent + await onRefreshData(); + // Show success toast + setShowSaveSuccess(true); + setTimeout(() => setShowSaveSuccess(false), 4000); + // Exit edit mode and navigate to decision detail page (this will load in preview mode) + setIsEditing(false); + navigate(`/decisions/${id}/${sanitizeUrlTitle(decisionTitle)}`); + } + } catch (error) { + console.error('Failed to save decision:', error); + } finally { + setIsSaving(false); + } + }; + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleCancelEdit = () => { + if (isNewDecision) { + // Navigate back for new decisions + navigate('/decisions'); + } else { + // Revert changes for existing decisions + setContent(originalContent); + setDecisionTitle(originalDecisionTitle); + setIsEditing(false); + } + }; + + const hasChanges = content !== originalContent || decisionTitle !== originalDecisionTitle; + + const getStatusColor = (status: string) => { + const colors = { + 'proposed': 'bg-yellow-50 text-yellow-700 border-yellow-200', + 'accepted': 'bg-green-50 text-green-700 border-green-200', + 'rejected': 'bg-red-50 text-red-700 border-red-200', + 'superseded': 'bg-gray-50 text-gray-700 border-gray-200', + } as const; + return colors[status.toLowerCase() as keyof typeof colors] || 'bg-gray-50 text-gray-700 border-gray-200'; + }; + + if (!id) { + return ( + <div className="flex-1 flex items-center justify-center p-8"> + <div className="text-center"> + <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" /> + </svg> + <h3 className="mt-2 text-sm font-medium text-gray-900">No decision selected</h3> + <p className="mt-1 text-sm text-gray-500">Select a decision from the sidebar to view its content.</p> + </div> + </div> + ); + } + + if (isLoading) { + return ( + <div className="flex-1 flex items-center justify-center"> + <div className="text-gray-500">Loading...</div> + </div> + ); + } + + return ( + <ErrorBoundary> + <div className="h-full bg-white dark:bg-gray-900 flex flex-col transition-colors duration-200"> + {/* Header Section - Confluence/Linear Style */} + <div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200"> + <div className="max-w-4xl mx-auto px-8 py-6"> + <div className="flex items-start justify-between mb-6"> + <div className="flex-1"> + {isEditing ? ( + <input + type="text" + value={decisionTitle} + onChange={(e) => setDecisionTitle(e.target.value)} + className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2 w-full bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-200" + placeholder="Decision title" + /> + ) : ( + <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2 transition-colors duration-200"> + {decisionTitle || decision?.title || (title ? decodeURIComponent(title) : `Decision ${id}`)} + </h1> + )} + <div className="flex items-center space-x-6 text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200"> + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a.997.997 0 01-1.414 0l-7-7A1.997 1.997 0 013 12V7a4 4 0 014-4z" /> + </svg> + <span>ID: {decision?.id}</span> + </div> + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> + </svg> + <span>Decision</span> + </div> + {decision?.date && ( + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> + </svg> + <span>Date: {decision.date}</span> + </div> + )} + {decision?.status && ( + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + <span + className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border ${getStatusColor(decision.status)}`} + > + {decision.status.charAt(0).toUpperCase() + decision.status.slice(1)} + </span> + </div> + )} + </div> + </div> + <div className="flex items-center space-x-3 ml-6"> + {/* Temporarily hidden - decisions editing not ready */} + {false ? ( + <button + onClick={handleEdit} + className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> + </svg> + Edit + </button> + ) : null} + {isEditing && ( + <div className="flex items-center space-x-2"> + <button + onClick={handleCancelEdit} + className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + > + Cancel + </button> + <button + onClick={handleSave} + disabled={!hasChanges || isSaving} + className={`inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 ${ + hasChanges && !isSaving + ? 'bg-blue-600 dark:bg-blue-600 text-white hover:bg-blue-700 dark:hover:bg-blue-700 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer' + : 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed' + }`} + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> + </svg> + {isSaving ? 'Saving...' : 'Save'} + </button> + </div> + )} + </div> + </div> + </div> + </div> + + {/* Content Section */} + <div className="flex-1 bg-gray-50 dark:bg-gray-800 transition-colors duration-200 flex flex-col"> + <div className="flex-1 p-8 flex flex-col min-h-0"> + <MarkdownEditor + value={content} + onChange={(val) => setContent(val || '')} + isEditing={isEditing} + /> + </div> + </div> + </div> + + {/* Save Success Toast */} + {showSaveSuccess && ( + <SuccessToast + message={`Decision "${decisionTitle}" saved successfully!`} + onDismiss={() => setShowSaveSuccess(false)} + icon={ + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + } + /> + )} + </ErrorBoundary> + ); +} diff --git a/src/web/components/DependencyInput.tsx b/src/web/components/DependencyInput.tsx new file mode 100644 index 0000000..43af016 --- /dev/null +++ b/src/web/components/DependencyInput.tsx @@ -0,0 +1,179 @@ +import React, { useState, useRef, useEffect, type KeyboardEvent } from 'react'; +import { type Task } from '../../types'; + +interface DependencyInputProps { + value: string[]; + onChange: (values: string[]) => void; + availableTasks: Task[]; + currentTaskId?: string; + label?: string; // optional label; render only if provided + disabled?: boolean; +} + +const DependencyInput: React.FC<DependencyInputProps> = ({ value, onChange, availableTasks, currentTaskId, label = 'Dependencies', disabled }) => { + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState<Task[]>([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const textareaRef = useRef<HTMLTextAreaElement>(null); + const inputId = 'dependency-input'; + + // Get task display text + const getTaskDisplay = (taskId: string) => { + const task = availableTasks.find(t => t.id === taskId); + return task ? `${task.id} - ${task.title}` : taskId; + }; + + // Filter tasks based on input + useEffect(() => { + if (inputValue.trim()) { + const filtered = availableTasks.filter(task => + task.id !== currentTaskId && // Don't suggest current task + !value.includes(task.id) && // Don't suggest already added tasks + (task.id.toLowerCase().includes(inputValue.toLowerCase()) || + task.title.toLowerCase().includes(inputValue.toLowerCase())) + ); + setSuggestions(filtered); + setSelectedIndex(0); + } else { + setSuggestions([]); + } + }, [inputValue, availableTasks, value, currentTaskId]); + + // Auto-resize textarea + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; + } + }, [value]); + + const addDependency = (taskId: string) => { + if (disabled) return; + if (!value.includes(taskId)) { + onChange([...value, taskId]); + setInputValue(''); + setSuggestions([]); + if (textareaRef.current) { + textareaRef.current.focus(); + } + } + }; + + const removeDependency = (index: number) => { + if (disabled) return; + onChange(value.filter((_, i) => i !== index)); + }; + + const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { + if (disabled) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => (prev + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => (prev - 1 + suggestions.length) % suggestions.length); + } else if ((e.key === 'Enter' || e.key === ',') && inputValue.trim()) { + e.preventDefault(); + if (suggestions.length > 0 && suggestions[selectedIndex]) { + addDependency(suggestions[selectedIndex].id); + } + } else if (e.key === 'Backspace' && !inputValue && value.length > 0) { + // Remove last dependency when backspace on empty input + onChange(value.slice(0, -1)); + } else if (e.key === 'Escape') { + setSuggestions([]); + setInputValue(''); + } + }; + + const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + if (disabled) return; + const newValue = e.target.value; + // Check if user typed a comma + if (newValue.endsWith(',')) { + const searchValue = newValue.slice(0, -1).trim(); + if (searchValue && suggestions.length > 0 && suggestions[selectedIndex]) { + addDependency(suggestions[selectedIndex].id); + } + } else { + setInputValue(newValue); + } + }; + + return ( + <div> + {label ? ( + <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 transition-colors duration-200"> + {label} + </label> + ) : null} + <div className="relative w-full"> + <div className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 rounded-md focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-400 focus-within:border-transparent transition-colors duration-200 max-h-60 overflow-auto pr-2 ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}> + {/* Display selected dependencies */} + {value.length > 0 && ( + <div className="flex flex-wrap gap-2 mb-2"> + {value.map((taskId, index) => ( + <span + key={index} + className="inline-flex items-center gap-1 px-2 py-0.5 text-sm bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 rounded-md transition-colors duration-200 min-w-0 max-w-full" + > + <span className="truncate max-w-[16rem] sm:max-w-[20rem] md:max-w-[24rem]">{getTaskDisplay(taskId)}</span> + {!disabled && ( + <button + type="button" + onClick={() => removeDependency(index)} + className="hover:bg-blue-200 dark:hover:bg-blue-800 rounded-sm p-0.5 transition-colors duration-200 cursor-pointer" + aria-label={`Remove ${taskId}`} + > + <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> + <path + fillRule="evenodd" + d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" + clipRule="evenodd" + /> + </svg> + </button> + )} + </span> + ))} + </div> + )} + + {/* Input field */} + <textarea + ref={textareaRef} + id={inputId} + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? "Type task ID or title, then press Enter or comma" : "Add more dependencies..."} + className="w-full outline-none text-sm bg-transparent resize-none text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400" + rows={1} + disabled={disabled} + /> + </div> + + {/* Suggestions dropdown */} + {suggestions.length > 0 && ( + <div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-64 overflow-auto overscroll-contain transition-colors duration-200"> + {suggestions.map((task, index) => ( + <button + key={task.id} + type="button" + onClick={() => addDependency(task.id)} + className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 cursor-pointer ${ + index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : '' + }`} + > + <div className="font-medium text-gray-900 dark:text-white">{task.id}</div> + <div className="text-gray-600 dark:text-gray-300 break-words whitespace-normal">{task.title}</div> + </button> + ))} + </div> + )} + </div> + </div> + ); +}; + +export default DependencyInput; diff --git a/src/web/components/DocumentationDetail.tsx b/src/web/components/DocumentationDetail.tsx new file mode 100644 index 0000000..e809fd2 --- /dev/null +++ b/src/web/components/DocumentationDetail.tsx @@ -0,0 +1,405 @@ +import React, {useState, useEffect, memo, useCallback} from 'react'; +import {useParams, useNavigate, useSearchParams} from 'react-router-dom'; +import {apiClient} from '../lib/api'; +import MDEditor from '@uiw/react-md-editor'; +import MermaidMarkdown from './MermaidMarkdown'; +import {type Document} from '../../types'; +import ErrorBoundary from '../components/ErrorBoundary'; +import {SuccessToast} from './SuccessToast'; +import { useTheme } from '../contexts/ThemeContext'; +import { sanitizeUrlTitle } from '../utils/urlHelpers'; + +// Custom MDEditor wrapper for proper height handling +const MarkdownEditor = memo(function MarkdownEditor({ + value, + onChange, + isEditing +}: { + value: string; + onChange?: (val: string | undefined) => void; + isEditing: boolean; + isReadonly?: boolean; +}) { + const { theme } = useTheme(); + if (!isEditing) { + // Preview mode - just show the rendered markdown without editor UI + return ( + <div + className="prose prose-sm !max-w-none w-full p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" + data-color-mode={theme}> + <MermaidMarkdown source={value} /> + </div> + ); + } + + // Edit mode - show full editor that fills the available space + return ( + <div className="h-full w-full flex flex-col"> + <div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800"> + <MDEditor + value={value} + onChange={onChange} + preview="edit" + height="100%" + hideToolbar={false} + data-color-mode={theme} + textareaProps={{ + placeholder: 'Write your documentation here...', + style: { + fontSize: '14px', + resize: 'none' + } + }} + /> + </div> + </div> + ); +}); + +// Utility function to add doc prefix for API calls +const addDocPrefix = (id: string): string => { + return id.startsWith('doc-') ? id : `doc-${id}`; +}; + +interface DocumentationDetailProps { + docs: Document[]; + onRefreshData: () => Promise<void>; +} + +export default function DocumentationDetail({docs, onRefreshData}: DocumentationDetailProps) { + const {id, title} = useParams<{ id: string; title: string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const [document, setDocument] = useState<Document | null>(null); + const [content, setContent] = useState<string>(''); + const [originalContent, setOriginalContent] = useState<string>(''); + const [docTitle, setDocTitle] = useState<string>(''); + const [originalDocTitle, setOriginalDocTitle] = useState<string>(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [, setError] = useState<Error | null>(null); + const [saveError, setSaveError] = useState<Error | null>(null); + const [isNewDocument, setIsNewDocument] = useState(false); + const [showSaveSuccess, setShowSaveSuccess] = useState(false); + + useEffect(() => { + if (id === 'new') { + // Handle new document creation + setIsNewDocument(true); + setIsEditing(true); + setIsLoading(false); + setDocTitle(''); + setOriginalDocTitle(''); + setContent(''); + setOriginalContent(''); + } else if (id) { + setIsNewDocument(false); + setIsEditing(false); // Ensure we start in preview mode for existing documents + loadDocContent(); + } + }, [id, docs]); + + // Check for edit query parameter to start in edit mode + useEffect(() => { + if (searchParams.get('edit') === 'true') { + setIsEditing(true); + // Remove the edit parameter from URL + setSearchParams(params => { + params.delete('edit'); + return params; + }); + } + }, [searchParams, setSearchParams]); + + const loadDocContent = useCallback(async () => { + if (!id) return; + + try { + setIsLoading(true); + setError(null); + // Find document from props + const prefixedId = addDocPrefix(id); + const doc = docs.find(d => d.id === prefixedId); + + // Always try to fetch the document from API, whether we found it in docs or not + // This ensures deep linking works even before the parent component loads the docs array + try { + const fullDoc = await apiClient.fetchDoc(prefixedId); + setContent(fullDoc.rawContent || ''); + setOriginalContent(fullDoc.rawContent || ''); + setDocTitle(fullDoc.title || ''); + setOriginalDocTitle(fullDoc.title || ''); + // Update document state with full data + setDocument(fullDoc); + } catch (fetchError) { + // If fetch fails and we don't have the doc in props, show error + if (!doc) { + setError(new Error(`Document with ID "${prefixedId}" not found`)); + console.error('Failed to load document:', fetchError); + } else { + // We have basic info from props even if fetch failed + setDocument(doc); + setDocTitle(doc.title || ''); + setOriginalDocTitle(doc.title || ''); + } + } + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to load document'); + setError(error); + console.error('Failed to load document:', error); + } finally { + setIsLoading(false); + } + }, [id, docs]); + + const handleSave = useCallback(async () => { + if (!docTitle.trim()) { + setSaveError(new Error('Document title is required')); + return; + } + + try { + setIsSaving(true); + setSaveError(null); + const normalizedTitle = docTitle.trim(); + + if (isNewDocument) { + // Create new document + const result = await apiClient.createDoc(normalizedTitle, content); + // Refresh data and navigate to the new document + await onRefreshData(); + // Show success toast + setShowSaveSuccess(true); + setTimeout(() => setShowSaveSuccess(false), 4000); + // Exit edit mode and navigate to the new document + setIsEditing(false); + setIsNewDocument(false); + setDocTitle(normalizedTitle); + setOriginalDocTitle(normalizedTitle); + // Use the returned document ID for navigation + const documentId = result.id.replace('doc-', ''); // Remove prefix for URL + navigate(`/documentation/${documentId}/${sanitizeUrlTitle(normalizedTitle)}`); + } else { + // Update existing document + if (!id) return; + + // Check if title has changed + const titleChanged = normalizedTitle !== originalDocTitle; + + // Pass title only if it has changed + await apiClient.updateDoc( + addDocPrefix(id), + content, + titleChanged ? normalizedTitle : undefined + ); + + // Update original title to the new value + if (titleChanged) { + setDocTitle(normalizedTitle); + setOriginalDocTitle(normalizedTitle); + } + + // Refresh data from parent + await onRefreshData(); + // Show success toast + setShowSaveSuccess(true); + setTimeout(() => setShowSaveSuccess(false), 4000); + // Exit edit mode and navigate to document detail page (this will load in preview mode) + setIsEditing(false); + navigate(`/documentation/${id}/${sanitizeUrlTitle(normalizedTitle)}`); + } + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to save document'); + setSaveError(error); + console.error('Failed to save document:', error); + } finally { + setIsSaving(false); + } + }, [id, docTitle, content, isNewDocument, onRefreshData, navigate, loadDocContent]); + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleCancelEdit = () => { + if (isNewDocument) { + // Navigate back for new documents + navigate('/documentation'); + } else { + // Revert changes for existing documents + setContent(originalContent); + setDocTitle(originalDocTitle); + setIsEditing(false); + } + }; + + const hasChanges = content !== originalContent || docTitle !== originalDocTitle; + + if (!id) { + return ( + <div className="flex-1 flex items-center justify-center p-8"> + <div className="text-center"> + <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" + viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> + </svg> + <h3 className="mt-2 text-sm font-medium text-gray-900">No document selected</h3> + <p className="mt-1 text-sm text-gray-500">Select a document from the sidebar to view its + content.</p> + </div> + </div> + ); + } + + if (isLoading) { + return ( + <div className="flex-1 flex items-center justify-center"> + <div className="text-gray-500">Loading...</div> + </div> + ); + } + + return ( + <ErrorBoundary> + <div className="h-full bg-white dark:bg-gray-900 flex flex-col transition-colors duration-200"> + {/* Header Section - Confluence/Linear Style */} + <div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200"> + <div className="max-w-4xl mx-auto px-8 py-6"> + <div className="flex items-start justify-between mb-6"> + <div className="flex-1"> + {isEditing ? ( + <input + type="text" + value={docTitle} + onChange={(e) => setDocTitle(e.target.value)} + className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2 w-full bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-200" + placeholder="Document title" + /> + ) : ( + <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2 transition-colors duration-200"> + {docTitle || document?.title || (title ? decodeURIComponent(title) : `Document ${id}`)} + </h1> + )} + <div className="flex items-center space-x-6 text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200"> + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a.997.997 0 01-1.414 0l-7-7A1.997 1.997 0 013 12V7a4 4 0 014-4z"/> + </svg> + <span>ID: {document?.id || `doc-${id}`}</span> + </div> + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/> + </svg> + <span>Documentation</span> + </div> + {document?.createdDate && ( + <div className="flex items-center space-x-2"> + <svg className="w-4 h-4" fill="none" stroke="currentColor" + viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/> + </svg> + <span>Created: {document.createdDate}</span> + </div> + )} + </div> + </div> + <div className="flex items-center space-x-3 ml-6"> + {!isEditing ? ( + <button + onClick={handleEdit} + className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200" + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" + viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> + </svg> + Edit + </button> + ) : ( + <div className="flex items-center space-x-2"> + <button + onClick={handleCancelEdit} + className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + > + Cancel + </button> + <button + onClick={handleSave} + disabled={!hasChanges || isSaving} + className={`inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 ${ + hasChanges && !isSaving + ? 'bg-blue-600 dark:bg-blue-600 text-white hover:bg-blue-700 dark:hover:bg-blue-700 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer' + : 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed' + }`} + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" + viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M5 13l4 4L19 7"/> + </svg> + {isSaving ? 'Saving...' : 'Save'} + </button> + </div> + )} + </div> + </div> + </div> + </div> + + {/* Content Section */} + <div className="flex-1 bg-gray-50 dark:bg-gray-800 transition-colors duration-200 flex flex-col"> + <div className="flex-1 p-8 flex flex-col min-h-0"> + <MarkdownEditor + value={content} + onChange={(val) => setContent(val || '')} + isEditing={isEditing} + /> + </div> + </div> + + {/* Save Error Alert */} + {saveError && ( + <div className="border-t border-red-200 bg-red-50 px-8 py-3"> + <div className="flex items-center space-x-3"> + <svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"/> + </svg> + <span className="text-sm text-red-700">Failed to save: {saveError.message}</span> + <button + onClick={() => setSaveError(null)} + className="ml-auto text-red-700 hover:text-red-900" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M6 18L18 6M6 6l12 12"/> + </svg> + </button> + </div> + </div> + )} + </div> + + {/* Save Success Toast */} + {showSaveSuccess && ( + <SuccessToast + message={`Document "${docTitle}" saved successfully!`} + onDismiss={() => setShowSaveSuccess(false)} + icon={ + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> + </svg> + } + /> + )} + </ErrorBoundary> + ); +} diff --git a/src/web/components/DraftsList.tsx b/src/web/components/DraftsList.tsx new file mode 100644 index 0000000..70871c2 --- /dev/null +++ b/src/web/components/DraftsList.tsx @@ -0,0 +1,196 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { apiClient } from '../lib/api'; +import { type Task } from '../../types'; + +interface DraftsListProps { + onEditTask: (task: Task) => void; + onNewDraft: () => void; +} + +const DraftsList: React.FC<DraftsListProps> = ({ onEditTask, onNewDraft }) => { + const [drafts, setDrafts] = useState<Task[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + loadDrafts(); + + // Listen for draft updates + const handleDraftsUpdated = () => { + loadDrafts(); + }; + + window.addEventListener('drafts-updated', handleDraftsUpdated); + return () => { + window.removeEventListener('drafts-updated', handleDraftsUpdated); + }; + }, []); + + const loadDrafts = async () => { + try { + setLoading(true); + const response = await fetch('/api/drafts'); + if (!response.ok) { + throw new Error(`Failed to load drafts: ${response.statusText}`); + } + const draftsData = await response.json(); + // Sort drafts by ID descending (newest first) - same as TaskList + const sortedDrafts = [...draftsData].sort((a, b) => { + // Extract numeric part from task IDs (task-1, task-2, etc.) + const idA = parseInt(a.id.replace('task-', ''), 10); + const idB = parseInt(b.id.replace('task-', ''), 10); + return idB - idA; // Highest ID first (newest) + }); + setDrafts(sortedDrafts); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load drafts'); + } finally { + setLoading(false); + } + }; + + const handlePromoteDraft = async (draftId: string) => { + try { + const response = await fetch(`/api/drafts/${draftId}/promote`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`Failed to promote draft: ${response.statusText}`); + } + + // Reload drafts after successful promotion + await loadDrafts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to promote draft'); + } + }; + + const getPriorityColor = (priority?: string) => { + switch (priority?.toLowerCase()) { + case 'high': + return 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200'; + case 'medium': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-200'; + case 'low': + return 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + } + }; + + if (loading) { + return ( + <div className="flex-1 flex items-center justify-center"> + <div className="text-gray-500 dark:text-gray-400">Loading drafts...</div> + </div> + ); + } + + if (error) { + return ( + <div className="flex-1 flex items-center justify-center"> + <div className="text-red-600 dark:text-red-400">Error: {error}</div> + <button + onClick={loadDrafts} + className="ml-4 inline-flex items-center px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400 transition-colors cursor-pointer" + > + Retry + </button> + </div> + ); + } + + return ( + <div className="container mx-auto px-4 py-8 transition-colors duration-200"> + <div className="flex items-center justify-between mb-6"> + <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Draft Tasks</h1> + <div className="flex items-center space-x-4"> + <div className="text-sm text-gray-600 dark:text-gray-300"> + {drafts.length} draft{drafts.length !== 1 ? 's' : ''} + </div> + <button + className="inline-flex items-center px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + onClick={onNewDraft} + > + + New Draft + </button> + </div> + </div> + + {drafts.length === 0 ? ( + <div className="text-center py-12"> + <svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> + </svg> + <h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No drafts</h3> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Draft tasks will appear here before they're promoted to the main backlog.</p> + </div> + ) : ( + <div className="space-y-4"> + {drafts.map((draft) => ( + <div + key={draft.id} + className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200" + > + <div className="flex items-start justify-between"> + <div className="flex-1 cursor-pointer" onClick={() => onEditTask(draft)}> + <div className="flex items-center space-x-3 mb-2"> + <h3 className="text-lg font-medium text-gray-900 dark:text-white">{draft.title}</h3> + {draft.priority && ( + <span className={`px-2 py-1 text-xs font-medium rounded-circle ${getPriorityColor(draft.priority)}`}> + {draft.priority} + </span> + )} + </div> + <div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400 mb-2"> + <span>{draft.id}</span> + <span>Created: {new Date(draft.createdDate).toLocaleDateString()}</span> + {draft.updatedDate && ( + <span>Updated: {new Date(draft.updatedDate).toLocaleDateString()}</span> + )} + </div> + {draft.assignee && draft.assignee.length > 0 && ( + <div className="flex items-center space-x-2 mb-2"> + <span className="text-sm text-gray-500 dark:text-gray-400">Assigned to:</span> + <div className="flex flex-wrap gap-1"> + {draft.assignee.map((person) => ( + <span key={person} className="px-2 py-1 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200 rounded-circle"> + {person} + </span> + ))} + </div> + </div> + )} + {draft.labels && draft.labels.length > 0 && ( + <div className="flex flex-wrap gap-1"> + {draft.labels.map((label) => ( + <span key={label} className="px-2 py-1 text-xs bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded-circle"> + {label} + </span> + ))} + </div> + )} + </div> + <div className="ml-4"> + <button + onClick={(e) => { + e.stopPropagation(); + handlePromoteDraft(draft.id); + }} + className="inline-flex items-center px-3 py-1.5 bg-green-500 text-white text-sm font-medium rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-400 dark:focus:ring-offset-gray-800 transition-colors duration-200" + > + Promote to Task + </button> + </div> + </div> + </div> + ))} + </div> + )} + </div> + ); +}; + +export default DraftsList; diff --git a/src/web/components/ErrorBoundary.tsx b/src/web/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b87cc61 --- /dev/null +++ b/src/web/components/ErrorBoundary.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; + errorInfo?: React.ErrorInfo; +} + +interface ErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +const DefaultErrorFallback: React.FC<{ error?: Error; resetError: () => void }> = ({ error, resetError }) => ( + <div className="flex flex-col items-center justify-center min-h-96 p-8 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 transition-colors duration-200"> + <div className="text-center max-w-md"> + <svg className="mx-auto h-12 w-12 text-red-500 dark:text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z" /> + </svg> + <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Something went wrong</h3> + <p className="text-sm text-gray-600 dark:text-gray-300 mb-4"> + An unexpected error occurred. Please try refreshing the page. + </p> + {error && process.env.NODE_ENV === 'development' && ( + <details className="mt-4 text-left"> + <summary className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">Technical Details</summary> + <pre className="mt-2 text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 p-2 rounded overflow-auto transition-colors duration-200"> + {error.message} + </pre> + </details> + )} + <button + onClick={resetError} + className="mt-4 inline-flex items-center px-4 py-2 bg-red-600 dark:bg-red-700 text-white text-sm font-medium rounded-lg hover:bg-red-700 dark:hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors duration-200 cursor-pointer" + > + Try Again + </button> + </div> + </div> +); + +export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { + hasError: true, + error, + }; + } + + override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + this.setState({ + error, + errorInfo, + }); + + // Log error to console in development + if (process.env.NODE_ENV === 'development') { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + // Call optional error handler + this.props.onError?.(error, errorInfo); + } + + resetError = () => { + this.setState({ + hasError: false, + error: undefined, + errorInfo: undefined, + }); + }; + + override render() { + if (this.state.hasError) { + const FallbackComponent = this.props.fallback || DefaultErrorFallback; + return <FallbackComponent error={this.state.error} resetError={this.resetError} />; + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/web/components/HealthIndicator.tsx b/src/web/components/HealthIndicator.tsx new file mode 100644 index 0000000..6f51daf --- /dev/null +++ b/src/web/components/HealthIndicator.tsx @@ -0,0 +1,38 @@ +import { useHealthCheckContext } from "../contexts/HealthCheckContext"; +import { SuccessToast } from "./SuccessToast"; + +export function HealthIndicator() { + const { isOnline, retry } = useHealthCheckContext(); + + // Show offline banner when connection is lost + if (!isOnline) { + return ( + <div className="fixed top-0 left-0 right-0 bg-red-500 dark:bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between shadow-lg z-50 animate-slide-in-down transition-colors duration-200"> + <div className="flex items-center gap-3"> + <div className="w-2 h-2 bg-white rounded-circle animate-pulse" /> + <span className="font-medium"> + Server disconnected + </span> + </div> + <button + onClick={retry} + className="px-3 py-1.5 bg-red-600 dark:bg-red-700 hover:bg-red-700 dark:hover:bg-red-800 rounded text-xs font-medium transition-colors duration-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-red-300 dark:focus:ring-red-400" + > + Retry + </button> + </div> + ); + } + + return null; +} + +// Success toast component for when connection is restored +export function HealthSuccessToast({ onDismiss }: { onDismiss: () => void }) { + return ( + <SuccessToast + message="Connection restored!" + onDismiss={onDismiss} + /> + ); +} \ No newline at end of file diff --git a/src/web/components/InitializationScreen.tsx b/src/web/components/InitializationScreen.tsx new file mode 100644 index 0000000..c798fe0 --- /dev/null +++ b/src/web/components/InitializationScreen.tsx @@ -0,0 +1,816 @@ +import React, { useState } from "react"; +import { DEFAULT_INIT_CONFIG } from "../../constants/index.ts"; +import { apiClient } from "../lib/api"; + +type IntegrationMode = "mcp" | "cli" | "none"; +type McpClient = "claude" | "codex" | "gemini" | "guide"; +type AgentFile = "CLAUDE.md" | "AGENTS.md" | "GEMINI.md" | ".github/copilot-instructions.md"; + +interface AdvancedConfig { + checkActiveBranches: boolean; + remoteOperations: boolean; + activeBranchDays: number; + bypassGitHooks: boolean; + autoCommit: boolean; + zeroPaddedIds: number | null; + defaultEditor: string; + defaultPort: number; + autoOpenBrowser: boolean; +} + +interface InitializationScreenProps { + onInitialized: () => void; +} + +type WizardStep = "projectName" | "integrationMode" | "mcpClients" | "agentFiles" | "advancedConfig" | "summary"; + +const InitializationScreen: React.FC<InitializationScreenProps> = ({ onInitialized }) => { + // Wizard state + const [currentStep, setCurrentStep] = useState<WizardStep>("projectName"); + + // Form data + const [projectName, setProjectName] = useState(""); + const [integrationMode, setIntegrationMode] = useState<IntegrationMode | null>(null); + const [selectedMcpClients, setSelectedMcpClients] = useState<McpClient[]>([]); + const [selectedAgentFiles, setSelectedAgentFiles] = useState<AgentFile[]>([]); + const [installClaudeAgent, setInstallClaudeAgent] = useState(false); + const [showAdvancedConfig, setShowAdvancedConfig] = useState(false); + const [advancedConfig, setAdvancedConfig] = useState<AdvancedConfig>({ + checkActiveBranches: DEFAULT_INIT_CONFIG.checkActiveBranches, + remoteOperations: DEFAULT_INIT_CONFIG.remoteOperations, + activeBranchDays: DEFAULT_INIT_CONFIG.activeBranchDays, + bypassGitHooks: DEFAULT_INIT_CONFIG.bypassGitHooks, + autoCommit: DEFAULT_INIT_CONFIG.autoCommit, + zeroPaddedIds: DEFAULT_INIT_CONFIG.zeroPaddedIds ?? null, + defaultEditor: DEFAULT_INIT_CONFIG.defaultEditor ?? "", + defaultPort: DEFAULT_INIT_CONFIG.defaultPort, + autoOpenBrowser: DEFAULT_INIT_CONFIG.autoOpenBrowser, + }); + + // UI state + const [isInitializing, setIsInitializing] = useState(false); + const [error, setError] = useState<string | null>(null); + const [mcpSetupResults, setMcpSetupResults] = useState<Record<string, string>>({}); + + const handleNext = () => { + setError(null); + switch (currentStep) { + case "projectName": + if (!projectName.trim()) { + setError("Project name is required"); + return; + } + setCurrentStep("integrationMode"); + break; + case "integrationMode": + if (!integrationMode) { + setError("Please select an integration mode"); + return; + } + if (integrationMode === "mcp") { + setCurrentStep("mcpClients"); + } else if (integrationMode === "cli") { + setCurrentStep("agentFiles"); + } else { + setCurrentStep("advancedConfig"); + } + break; + case "mcpClients": + setCurrentStep("advancedConfig"); + break; + case "agentFiles": + setCurrentStep("advancedConfig"); + break; + case "advancedConfig": + setCurrentStep("summary"); + break; + } + }; + + const handleBack = () => { + setError(null); + switch (currentStep) { + case "integrationMode": + setCurrentStep("projectName"); + break; + case "mcpClients": + setCurrentStep("integrationMode"); + break; + case "agentFiles": + setCurrentStep("integrationMode"); + break; + case "advancedConfig": + if (integrationMode === "mcp") { + setCurrentStep("mcpClients"); + } else if (integrationMode === "cli") { + setCurrentStep("agentFiles"); + } else { + setCurrentStep("integrationMode"); + } + break; + case "summary": + setCurrentStep("advancedConfig"); + break; + } + }; + + const handleInitialize = async () => { + setIsInitializing(true); + setError(null); + setMcpSetupResults({}); + + try { + await apiClient.initializeProject({ + projectName: projectName.trim(), + integrationMode: integrationMode || "none", + mcpClients: integrationMode === "mcp" ? selectedMcpClients : undefined, + agentInstructions: integrationMode === "cli" ? selectedAgentFiles : undefined, + installClaudeAgent: integrationMode === "cli" ? installClaudeAgent : undefined, + advancedConfig: showAdvancedConfig + ? { + ...advancedConfig, + zeroPaddedIds: advancedConfig.zeroPaddedIds || undefined, + defaultEditor: advancedConfig.defaultEditor || undefined, + } + : undefined, + }); + onInitialized(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to initialize project"); + setIsInitializing(false); + } + }; + + const toggleMcpClient = (client: McpClient) => { + setSelectedMcpClients((prev) => + prev.includes(client) ? prev.filter((c) => c !== client) : [...prev, client], + ); + }; + + const toggleAgentFile = (file: AgentFile) => { + setSelectedAgentFiles((prev) => + prev.includes(file) ? prev.filter((f) => f !== file) : [...prev, file], + ); + }; + + const renderStepIndicator = () => { + const steps = ["Project", "Integration", "Setup", "Config", "Initialize"]; + const stepMap: Record<WizardStep, number> = { + projectName: 0, + integrationMode: 1, + mcpClients: 2, + agentFiles: 2, + advancedConfig: 3, + summary: 4, + }; + const currentIndex = stepMap[currentStep]; + + return ( + <div className="flex justify-center mb-8"> + {steps.map((step, index) => ( + <div key={step} className="flex items-center"> + <div + className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${ + index <= currentIndex + ? "bg-blue-500 dark:bg-blue-600 text-white" + : "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400" + }`} + > + {index + 1} + </div> + {index < steps.length - 1 && ( + <div + className={`w-12 h-1 mx-1 ${ + index < currentIndex + ? "bg-blue-500 dark:bg-blue-600" + : "bg-gray-200 dark:bg-gray-700" + }`} + /> + )} + </div> + ))} + </div> + ); + }; + + const renderProjectNameStep = () => ( + <div> + <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Project Name</h2> + <p className="text-gray-600 dark:text-gray-400 mb-6"> + Enter a name for your project. This will be displayed in the UI and used for identification. + </p> + <input + type="text" + value={projectName} + onChange={(e) => setProjectName(e.target.value)} + placeholder="My Awesome Project" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 transition-colors duration-200" + autoFocus + /> + </div> + ); + + const renderIntegrationModeStep = () => ( + <div> + <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">AI Integration Mode</h2> + <p className="text-gray-600 dark:text-gray-400 mb-6"> + How would you like your AI tools to connect to Backlog.md? + </p> + <div className="space-y-3"> + <label + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-colors ${ + integrationMode === "mcp" + ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" + : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50" + }`} + > + <input + type="radio" + name="integrationMode" + value="mcp" + checked={integrationMode === "mcp"} + onChange={() => setIntegrationMode("mcp")} + className="mt-1 mr-3" + /> + <div> + <div className="font-medium text-gray-900 dark:text-gray-100"> + MCP Connector (Recommended) + </div> + <div className="text-sm text-gray-500 dark:text-gray-400"> + For Claude Code, Codex, Gemini CLI, Cursor, etc. Agents learn the Backlog.md workflow through + MCP tools, resources, and prompts. + </div> + </div> + </label> + + <label + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-colors ${ + integrationMode === "cli" + ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" + : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50" + }`} + > + <input + type="radio" + name="integrationMode" + value="cli" + checked={integrationMode === "cli"} + onChange={() => setIntegrationMode("cli")} + className="mt-1 mr-3" + /> + <div> + <div className="font-medium text-gray-900 dark:text-gray-100"> + CLI Commands (Broader Compatibility) + </div> + <div className="text-sm text-gray-500 dark:text-gray-400"> + Agents will use Backlog.md by invoking CLI commands directly. Creates instruction files for + various AI tools. + </div> + </div> + </label> + + <label + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-colors ${ + integrationMode === "none" + ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" + : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50" + }`} + > + <input + type="radio" + name="integrationMode" + value="none" + checked={integrationMode === "none"} + onChange={() => setIntegrationMode("none")} + className="mt-1 mr-3" + /> + <div> + <div className="font-medium text-gray-900 dark:text-gray-100">Skip for Now</div> + <div className="text-sm text-gray-500 dark:text-gray-400"> + Continue without setting up AI integration. You can configure this later. + </div> + </div> + </label> + </div> + </div> + ); + + const renderMcpClientsStep = () => ( + <div> + <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">MCP Client Setup</h2> + <p className="text-gray-600 dark:text-gray-400 mb-6"> + Select the AI tools you want to configure for MCP integration. The setup will run automatically. + </p> + <div className="space-y-3"> + {[ + { id: "claude" as McpClient, label: "Claude Code", description: "Anthropic's Claude Code editor" }, + { id: "codex" as McpClient, label: "OpenAI Codex", description: "OpenAI's Codex CLI" }, + { id: "gemini" as McpClient, label: "Gemini CLI", description: "Google's Gemini Code Assist CLI" }, + { + id: "guide" as McpClient, + label: "Manual Setup Guide", + description: "Opens documentation for manual configuration", + }, + ].map((client) => ( + <label + key={client.id} + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-colors ${ + selectedMcpClients.includes(client.id) + ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" + : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50" + }`} + > + <input + type="checkbox" + checked={selectedMcpClients.includes(client.id)} + onChange={() => toggleMcpClient(client.id)} + className="mt-1 mr-3" + /> + <div> + <div className="font-medium text-gray-900 dark:text-gray-100">{client.label}</div> + <div className="text-sm text-gray-500 dark:text-gray-400">{client.description}</div> + </div> + </label> + ))} + </div> + {selectedMcpClients.length === 0 && ( + <p className="mt-4 text-sm text-amber-600 dark:text-amber-400"> + πŸ’‘ Select at least one option to configure MCP integration, or go back to choose a different mode. + </p> + )} + </div> + ); + + const renderAgentFilesStep = () => ( + <div> + <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Agent Instruction Files</h2> + <p className="text-gray-600 dark:text-gray-400 mb-6"> + Select which instruction files to create for CLI-based AI tools. + </p> + <div className="space-y-3"> + {[ + { id: "CLAUDE.md" as AgentFile, label: "CLAUDE.md", description: "Claude Code instructions" }, + { + id: "AGENTS.md" as AgentFile, + label: "AGENTS.md", + description: "Codex, Cursor, Zed, Warp, Aider, RooCode, etc.", + }, + { id: "GEMINI.md" as AgentFile, label: "GEMINI.md", description: "Google Gemini Code Assist CLI" }, + { + id: ".github/copilot-instructions.md" as AgentFile, + label: "Copilot Instructions", + description: "GitHub Copilot", + }, + ].map((file) => ( + <label + key={file.id} + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-colors ${ + selectedAgentFiles.includes(file.id) + ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" + : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50" + }`} + > + <input + type="checkbox" + checked={selectedAgentFiles.includes(file.id)} + onChange={() => toggleAgentFile(file.id)} + className="mt-1 mr-3" + /> + <div> + <div className="font-medium text-gray-900 dark:text-gray-100">{file.label}</div> + <div className="text-sm text-gray-500 dark:text-gray-400">{file.description}</div> + </div> + </label> + ))} + </div> + + <div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> + <label className="flex items-start cursor-pointer"> + <input + type="checkbox" + checked={installClaudeAgent} + onChange={(e) => setInstallClaudeAgent(e.target.checked)} + className="mt-1 mr-3" + /> + <div> + <div className="font-medium text-gray-900 dark:text-gray-100"> + Install Claude Code Backlog.md Agent + </div> + <div className="text-sm text-gray-500 dark:text-gray-400"> + Adds configuration under .claude/agents/ for enhanced Claude Code integration + </div> + </div> + </label> + </div> + </div> + ); + + const renderAdvancedConfigStep = () => ( + <div> + <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Advanced Settings</h2> + + <label className="flex items-center mb-6 cursor-pointer"> + <input + type="checkbox" + checked={showAdvancedConfig} + onChange={(e) => setShowAdvancedConfig(e.target.checked)} + className="mr-3" + /> + <span className="text-gray-700 dark:text-gray-300">Configure advanced settings now</span> + </label> + + {showAdvancedConfig && ( + <div className="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6"> + {/* Branch Settings */} + <div> + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Branch Settings</h3> + <div className="space-y-3"> + <label className="flex items-center cursor-pointer"> + <input + type="checkbox" + checked={advancedConfig.checkActiveBranches} + onChange={(e) => + setAdvancedConfig((prev) => ({ + ...prev, + checkActiveBranches: e.target.checked, + remoteOperations: e.target.checked ? prev.remoteOperations : false, + })) + } + className="mr-3" + /> + <div> + <span className="text-gray-900 dark:text-gray-100">Check task states across branches</span> + <p className="text-xs text-gray-500 dark:text-gray-400"> + Ensures accurate task tracking across branches + </p> + </div> + </label> + + {advancedConfig.checkActiveBranches && ( + <> + <label className="flex items-center cursor-pointer ml-6"> + <input + type="checkbox" + checked={advancedConfig.remoteOperations} + onChange={(e) => + setAdvancedConfig((prev) => ({ ...prev, remoteOperations: e.target.checked })) + } + className="mr-3" + /> + <div> + <span className="text-gray-900 dark:text-gray-100">Include remote branches</span> + <p className="text-xs text-gray-500 dark:text-gray-400"> + Required for accessing tasks from remote repos + </p> + </div> + </label> + + <div className="ml-6"> + <label className="block text-sm text-gray-700 dark:text-gray-300 mb-1"> + Active branch days + </label> + <input + type="number" + value={advancedConfig.activeBranchDays} + onChange={(e) => + setAdvancedConfig((prev) => ({ + ...prev, + activeBranchDays: Number.parseInt(e.target.value) || 30, + })) + } + min={1} + max={365} + className="w-24 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" + /> + </div> + </> + )} + </div> + </div> + + {/* Git Settings */} + <div> + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Git Settings</h3> + <div className="space-y-3"> + <label className="flex items-center cursor-pointer"> + <input + type="checkbox" + checked={advancedConfig.autoCommit} + onChange={(e) => + setAdvancedConfig((prev) => ({ ...prev, autoCommit: e.target.checked })) + } + className="mr-3" + /> + <div> + <span className="text-gray-900 dark:text-gray-100">Auto-commit changes</span> + <p className="text-xs text-gray-500 dark:text-gray-400"> + Creates commits automatically after CLI changes + </p> + </div> + </label> + + <label className="flex items-center cursor-pointer"> + <input + type="checkbox" + checked={advancedConfig.bypassGitHooks} + onChange={(e) => + setAdvancedConfig((prev) => ({ ...prev, bypassGitHooks: e.target.checked })) + } + className="mr-3" + /> + <div> + <span className="text-gray-900 dark:text-gray-100">Bypass git hooks</span> + <p className="text-xs text-gray-500 dark:text-gray-400"> + Use --no-verify flag to skip pre-commit hooks + </p> + </div> + </label> + </div> + </div> + + {/* ID Formatting */} + <div> + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">ID Formatting</h3> + <label className="flex items-center cursor-pointer"> + <input + type="checkbox" + checked={advancedConfig.zeroPaddedIds !== null} + onChange={(e) => + setAdvancedConfig((prev) => ({ + ...prev, + zeroPaddedIds: e.target.checked ? 3 : null, + })) + } + className="mr-3" + /> + <div> + <span className="text-gray-900 dark:text-gray-100">Zero-padded IDs</span> + <p className="text-xs text-gray-500 dark:text-gray-400"> + Example: task-001 instead of task-1 + </p> + </div> + </label> + {advancedConfig.zeroPaddedIds !== null && ( + <div className="ml-6 mt-2"> + <label className="block text-sm text-gray-700 dark:text-gray-300 mb-1"> + Number of digits + </label> + <input + type="number" + value={advancedConfig.zeroPaddedIds} + onChange={(e) => + setAdvancedConfig((prev) => ({ + ...prev, + zeroPaddedIds: Number.parseInt(e.target.value) || 3, + })) + } + min={1} + max={10} + className="w-24 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" + /> + </div> + )} + </div> + + {/* Editor */} + <div> + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Editor</h3> + <input + type="text" + value={advancedConfig.defaultEditor} + onChange={(e) => + setAdvancedConfig((prev) => ({ ...prev, defaultEditor: e.target.value })) + } + placeholder="e.g., code --wait, vim, nano" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" + /> + <p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> + Leave blank to use system default + </p> + </div> + + {/* Web UI Settings */} + <div> + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Web UI</h3> + <div className="space-y-3"> + <div> + <label className="block text-sm text-gray-700 dark:text-gray-300 mb-1"> + Default port + </label> + <input + type="number" + value={advancedConfig.defaultPort} + onChange={(e) => + setAdvancedConfig((prev) => ({ + ...prev, + defaultPort: Number.parseInt(e.target.value) || 6420, + })) + } + min={1} + max={65535} + className="w-32 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" + /> + </div> + + <label className="flex items-center cursor-pointer"> + <input + type="checkbox" + checked={advancedConfig.autoOpenBrowser} + onChange={(e) => + setAdvancedConfig((prev) => ({ ...prev, autoOpenBrowser: e.target.checked })) + } + className="mr-3" + /> + <span className="text-gray-900 dark:text-gray-100">Auto-open browser</span> + </label> + </div> + </div> + </div> + )} + </div> + ); + + const renderSummaryStep = () => ( + <div> + <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Ready to Initialize</h2> + <p className="text-gray-600 dark:text-gray-400 mb-6">Review your configuration before initializing:</p> + + <div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3 text-sm"> + <div className="flex justify-between"> + <span className="text-gray-600 dark:text-gray-400">Project Name:</span> + <span className="font-medium text-gray-900 dark:text-gray-100">{projectName}</span> + </div> + <div className="flex justify-between"> + <span className="text-gray-600 dark:text-gray-400">Integration Mode:</span> + <span className="font-medium text-gray-900 dark:text-gray-100"> + {integrationMode === "mcp" + ? "MCP Connector" + : integrationMode === "cli" + ? "CLI Commands" + : "None"} + </span> + </div> + {integrationMode === "mcp" && selectedMcpClients.length > 0 && ( + <div className="flex justify-between"> + <span className="text-gray-600 dark:text-gray-400">MCP Clients:</span> + <span className="font-medium text-gray-900 dark:text-gray-100"> + {selectedMcpClients.join(", ")} + </span> + </div> + )} + {integrationMode === "cli" && selectedAgentFiles.length > 0 && ( + <div className="flex justify-between"> + <span className="text-gray-600 dark:text-gray-400">Agent Files:</span> + <span className="font-medium text-gray-900 dark:text-gray-100"> + {selectedAgentFiles.length} files + </span> + </div> + )} + {integrationMode === "cli" && installClaudeAgent && ( + <div className="flex justify-between"> + <span className="text-gray-600 dark:text-gray-400">Claude Agent:</span> + <span className="font-medium text-green-600 dark:text-green-400">Will be installed</span> + </div> + )} + <div className="flex justify-between"> + <span className="text-gray-600 dark:text-gray-400">Advanced Config:</span> + <span className="font-medium text-gray-900 dark:text-gray-100"> + {showAdvancedConfig ? "Customized" : "Defaults"} + </span> + </div> + </div> + + {Object.keys(mcpSetupResults).length > 0 && ( + <div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> + <h3 className="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Setup Progress:</h3> + {Object.entries(mcpSetupResults).map(([client, result]) => ( + <div key={client} className="text-sm text-blue-700 dark:text-blue-400"> + {client}: {result} + </div> + ))} + </div> + )} + </div> + ); + + const renderCurrentStep = () => { + switch (currentStep) { + case "projectName": + return renderProjectNameStep(); + case "integrationMode": + return renderIntegrationModeStep(); + case "mcpClients": + return renderMcpClientsStep(); + case "agentFiles": + return renderAgentFilesStep(); + case "advancedConfig": + return renderAdvancedConfigStep(); + case "summary": + return renderSummaryStep(); + } + }; + + const canProceed = () => { + switch (currentStep) { + case "projectName": + return projectName.trim().length > 0; + case "integrationMode": + return integrationMode !== null; + case "mcpClients": + return true; // Can proceed with no selection + case "agentFiles": + return true; // Can proceed with no selection + case "advancedConfig": + return true; + case "summary": + return true; + } + }; + + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 transition-colors duration-200 p-4"> + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-2xl w-full"> + {/* Header */} + <div className="text-center mb-6"> + <div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-full mb-3"> + <svg + className="w-6 h-6 text-blue-600 dark:text-blue-400" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" + /> + </svg> + </div> + <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Initialize Backlog.md</h1> + </div> + + {/* Step Indicator */} + {renderStepIndicator()} + + {/* Error Message */} + {error && ( + <div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg"> + <p className="text-sm text-red-700 dark:text-red-400">{error}</p> + </div> + )} + + {/* Current Step Content */} + <div className="mb-8">{renderCurrentStep()}</div> + + {/* Navigation Buttons */} + <div className="flex justify-between"> + <button + type="button" + onClick={handleBack} + disabled={currentStep === "projectName" || isInitializing} + className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer" + > + Back + </button> + + {currentStep === "summary" ? ( + <button + type="button" + onClick={handleInitialize} + disabled={isInitializing} + className="px-6 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer font-medium" + > + {isInitializing ? ( + <span className="flex items-center"> + <svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"> + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + /> + </svg> + Initializing... + </span> + ) : ( + "Initialize Project" + )} + </button> + ) : ( + <button + type="button" + onClick={handleNext} + disabled={!canProceed()} + className="px-6 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer font-medium" + > + Next + </button> + )} + </div> + </div> + </div> + ); +}; + +export default InitializationScreen; diff --git a/src/web/components/Layout.tsx b/src/web/components/Layout.tsx new file mode 100644 index 0000000..37989fa --- /dev/null +++ b/src/web/components/Layout.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import SideNavigation from './SideNavigation'; +import Navigation from './Navigation'; +import { HealthIndicator, HealthSuccessToast } from './HealthIndicator'; +import { type Task, type Document, type Decision } from '../../types'; + +interface LayoutProps { + projectName: string; + showSuccessToast: boolean; + onDismissToast: () => void; + tasks: Task[]; + docs: Document[]; + decisions: Decision[]; + isLoading: boolean; + onRefreshData: () => Promise<void>; +} + +export default function Layout({ + projectName, + showSuccessToast, + onDismissToast, + tasks, + docs, + decisions, + isLoading, + onRefreshData +}: LayoutProps) { + return ( + <div className="h-screen bg-gray-50 dark:bg-gray-900 flex transition-colors duration-200"> + <HealthIndicator /> + <SideNavigation + tasks={tasks} + docs={docs} + decisions={decisions} + isLoading={isLoading} + onRefreshData={onRefreshData} + /> + <div className="flex-1 flex flex-col min-h-0"> + <Navigation projectName={projectName} /> + <main className="flex-1 overflow-auto"> + <Outlet context={{ tasks, docs, decisions, isLoading, onRefreshData }} /> + </main> + </div> + {showSuccessToast && ( + <HealthSuccessToast onDismiss={onDismissToast} /> + )} + </div> + ); +} \ No newline at end of file diff --git a/src/web/components/LoadingSpinner.tsx b/src/web/components/LoadingSpinner.tsx new file mode 100644 index 0000000..d804502 --- /dev/null +++ b/src/web/components/LoadingSpinner.tsx @@ -0,0 +1,78 @@ +import React, { memo } from 'react'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + text?: string; + className?: string; +} +const LoadingSpinner = memo(function LoadingSpinner({ + size = 'md', + text = 'Loading...', + className = '' + }: LoadingSpinnerProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + return ( + <div className={`flex items-center justify-center ${className}`}> + <div className="flex flex-col items-center space-y-3"> + <div className={`animate-spin rounded-circle border-2 border-gray-300 dark:border-gray-600 border-t-blue-600 dark:border-t-blue-400 transition-colors duration-200 ${sizeClasses[size]}`} /> + {text && ( + <p className="text-sm text-gray-600 dark:text-gray-300 font-medium transition-colors duration-200">{text}</p> + )} + </div> + </div> + ); +}); + +export default LoadingSpinner; + +interface SidebarSkeletonProps { + isCollapsed?: boolean; +} + +export const SidebarSkeleton = memo(function SidebarSkeleton({ isCollapsed = false }: SidebarSkeletonProps) { + if (isCollapsed) { + return ( + <div className="px-2 py-2 space-y-2 animate-pulse"> + {Array.from({ length: 4 }, (_, i) => ( + <div key={i} className="w-full h-10 bg-gray-200 dark:bg-gray-700 rounded-lg transition-colors duration-200" /> + ))} + </div> + ); + } + + return ( + <div className="px-4 py-4 space-y-4 animate-pulse"> + {/* Search skeleton */} + <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg transition-colors duration-200" /> + + {/* Navigation items */} + {Array.from({ length: 3 }, (_, i) => ( + <div key={i} className="flex items-center space-x-3 px-3 py-2"> + <div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded transition-colors duration-200" /> + <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1 transition-colors duration-200" /> + </div> + ))} + + {/* Section headers */} + {Array.from({ length: 2 }, (_, sectionIndex) => ( + <div key={sectionIndex} className="space-y-2"> + <div className="flex items-center space-x-3 mb-3"> + <div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded transition-colors duration-200" /> + <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 transition-colors duration-200" /> + </div> + {Array.from({ length: 3 }, (_, itemIndex) => ( + <div key={itemIndex} className="flex items-center space-x-3 px-3 py-2"> + <div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded transition-colors duration-200" /> + <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded flex-1 transition-colors duration-200" /> + </div> + ))} + </div> + ))} + </div> + ); +}); \ No newline at end of file diff --git a/src/web/components/MermaidMarkdown.tsx b/src/web/components/MermaidMarkdown.tsx new file mode 100644 index 0000000..958da90 --- /dev/null +++ b/src/web/components/MermaidMarkdown.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef } from "react"; +import MDEditor from "@uiw/react-md-editor"; +import { renderMermaidIn } from "../utils/mermaid"; + +interface Props { + source: string; +} + +export default function MermaidMarkdown({ source }: Props) { + const ref = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!ref.current) return; + + // Render mermaid diagrams after the markdown has been rendered + // Use requestAnimationFrame to ensure MDEditor has finished rendering + const frameId = requestAnimationFrame(() => { + if (ref.current) { + void renderMermaidIn(ref.current); + } + }); + + return () => cancelAnimationFrame(frameId); + }, [source]); + + return ( + <div ref={ref} className="wmde-markdown"> + <MDEditor.Markdown source={source} /> + </div> + ); +} diff --git a/src/web/components/Modal.tsx b/src/web/components/Modal.tsx new file mode 100644 index 0000000..59b748c --- /dev/null +++ b/src/web/components/Modal.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + maxWidthClass?: string; // e.g., "max-w-4xl" + disableEscapeClose?: boolean; // when true, Escape won't close the modal (child can handle it) + actions?: React.ReactNode; // optional actions rendered in header before close +} + +const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, maxWidthClass = "max-w-2xl", disableEscapeClose, actions }) => { + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !disableEscapeClose) { + onClose(); + } + }; + + if (isOpen) { + if (!disableEscapeClose) { + document.addEventListener('keydown', handleEscape); + } + document.body.style.overflow = 'hidden'; + } + + return () => { + if (!disableEscapeClose) { + document.removeEventListener('keydown', handleEscape); + } + document.body.style.overflow = 'unset'; + }; + }, [isOpen, onClose, disableEscapeClose]); + + if (!isOpen) return null; + + return ( + <div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50 p-4"> + <div className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl ${maxWidthClass} w-full max-h-[94vh] overflow-y-auto transition-colors duration-200`} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="modal-title"> + <div className="sticky top-0 z-10 flex items-center justify-between px-6 pt-4 pb-3 border-b border-gray-200 dark:border-gray-700 bg-white/95 dark:bg-gray-800/95 backdrop-blur supports-[backdrop-filter]:bg-white/75 supports-[backdrop-filter]:dark:bg-gray-800/75"> + <h2 id="modal-title" className="text-base font-semibold text-gray-900 dark:text-gray-100">{title}</h2> + <div className="flex items-center gap-2"> + {actions} + <button + onClick={onClose} + className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded p-1 transition-colors duration-200 text-2xl leading-none w-8 h-8 flex items-center justify-center cursor-pointer" + aria-label="Close modal" + > + Γ— + </button> + </div> + </div> + <div className="px-6 pt-4 pb-6"> + {children} + </div> + </div> + </div> + ); +}; + +export default Modal; diff --git a/src/web/components/Navigation.tsx b/src/web/components/Navigation.tsx new file mode 100644 index 0000000..262b338 --- /dev/null +++ b/src/web/components/Navigation.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import ThemeToggle from './ThemeToggle'; + +interface NavigationProps { + projectName: string; +} + +const Navigation: React.FC<NavigationProps> = ({projectName}) => { + return ( + <nav className="px-8 h-18 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 transition-colors duration-200"> + <div className="h-full flex items-center justify-between"> + <div className="flex items-center gap-2"> + <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">{projectName || 'Loading...'}</h1> + <span className="text-sm text-gray-500 dark:text-gray-400">powered by</span> + <a + href="https://backlog.md" + target="_blank" + rel="noopener noreferrer" + className="text-sm text-stone-600 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-300 hover:underline transition-colors duration-200" + > + Backlog.md + </a> + </div> + <ThemeToggle /> + </div> + </nav> + ); +}; + +export default Navigation; \ No newline at end of file diff --git a/src/web/components/Settings.tsx b/src/web/components/Settings.tsx new file mode 100644 index 0000000..44614c5 --- /dev/null +++ b/src/web/components/Settings.tsx @@ -0,0 +1,400 @@ +import React, { useState, useEffect } from 'react'; +import { apiClient } from '../lib/api'; +import { SuccessToast } from './SuccessToast'; +import type { BacklogConfig } from '../../types'; + +const Settings: React.FC = () => { + const [config, setConfig] = useState<BacklogConfig | null>(null); + const [originalConfig, setOriginalConfig] = useState<BacklogConfig | null>(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState<string | null>(null); + const [showSuccess, setShowSuccess] = useState(false); + const [statuses, setStatuses] = useState<string[]>([]); + const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); + + useEffect(() => { + loadConfig(); + loadStatuses(); + }, []); + + const loadConfig = async () => { + try { + setLoading(true); + const data = await apiClient.fetchConfig(); + setConfig(data); + setOriginalConfig(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load configuration'); + } finally { + setLoading(false); + } + }; + + const loadStatuses = async () => { + try { + const data = await apiClient.fetchStatuses(); + setStatuses(data); + } catch (err) { + console.error('Failed to load statuses:', err); + } + }; + + const handleInputChange = (field: keyof BacklogConfig, value: any) => { + if (!config) return; + + setConfig({ + ...config, + [field]: value + }); + + // Clear validation error for this field + if (validationErrors[field]) { + setValidationErrors({ + ...validationErrors, + [field]: '' + }); + } + }; + + const validateConfig = (): boolean => { + const errors: Record<string, string> = {}; + + if (!config) return false; + + // Validate project name + if (!config.projectName.trim()) { + errors.projectName = 'Project name is required'; + } + + // Validate port number + if (config.defaultPort && (config.defaultPort < 1 || config.defaultPort > 65535)) { + errors.defaultPort = 'Port must be between 1 and 65535'; + } + + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSave = async () => { + if (!config || !validateConfig()) return; + + try { + setSaving(true); + await apiClient.updateConfig(config); + setOriginalConfig(config); + setShowSuccess(true); + setTimeout(() => setShowSuccess(false), 3000); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save configuration'); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + setConfig(originalConfig); + setValidationErrors({}); + }; + + const hasUnsavedChanges = JSON.stringify(config) !== JSON.stringify(originalConfig); + + if (loading) { + return ( + <div className="container mx-auto px-4 py-8"> + <div className="flex items-center justify-center py-12"> + <div className="text-lg text-gray-600 dark:text-gray-300">Loading settings...</div> + </div> + </div> + ); + } + + if (!config) { + return ( + <div className="container mx-auto px-4 py-8"> + <div className="flex items-center justify-center py-12"> + <div className="text-red-600 dark:text-red-400">Failed to load configuration</div> + </div> + </div> + ); + } + + return ( + <div className="container mx-auto px-4 py-8 transition-colors duration-200"> + <div className="max-w-4xl mx-auto"> + <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-8">Settings</h1> + + {error && ( + <div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg"> + <p className="text-sm text-red-700 dark:text-red-400">{error}</p> + </div> + )} + + <div className="space-y-8"> + {/* Project Settings */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> + <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Project Settings</h2> + <div className="space-y-4"> + <div> + <label htmlFor="projectName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Project Name + </label> + <input + id="projectName" + type="text" + value={config.projectName} + onChange={(e) => handleInputChange('projectName', e.target.value)} + className={`w-full px-3 py-2 border rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200 ${ + validationErrors.projectName + ? 'border-red-500 dark:border-red-400' + : 'border-gray-300 dark:border-gray-600' + }`} + /> + {validationErrors.projectName && ( + <p className="mt-1 text-sm text-red-600 dark:text-red-400">{validationErrors.projectName}</p> + )} + </div> + + <div> + <label htmlFor="dateFormat" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Date Format + </label> + <select + id="dateFormat" + value={config.dateFormat} + onChange={(e) => handleInputChange('dateFormat', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + > + <option value="yyyy-mm-dd">yyyy-mm-dd</option> + <option value="dd/mm/yyyy">dd/mm/yyyy</option> + <option value="mm/dd/yyyy">mm/dd/yyyy</option> + </select> + </div> + </div> + </div> + + {/* Workflow Settings */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> + <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Workflow Settings</h2> + <div className="space-y-4"> + <div> + <label className="flex items-center justify-between"> + <div> + <span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto Commit</span> + <p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> + Automatically commit changes to Git after task operations + </p> + </div> + <div className="relative inline-flex items-center cursor-pointer"> + <input + type="checkbox" + checked={config.autoCommit} + onChange={(e) => handleInputChange('autoCommit', e.target.checked)} + className="sr-only peer" + /> + <div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-circle peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-circle after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500"></div> + </div> + </label> + </div> + + <div> + <label className="flex items-center justify-between"> + <div> + <span className="text-sm font-medium text-gray-700 dark:text-gray-300">Remote Operations</span> + <p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> + Fetch tasks information from remote branches + </p> + </div> + <div className="relative inline-flex items-center cursor-pointer"> + <input + type="checkbox" + checked={config.remoteOperations} + onChange={(e) => handleInputChange('remoteOperations', e.target.checked)} + className="sr-only peer" + /> + <div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-circle peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-circle after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500"></div> + </div> + </label> + </div> + + <div> + <label htmlFor="defaultStatus" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Default Status + </label> + <select + id="defaultStatus" + value={config.defaultStatus} + onChange={(e) => handleInputChange('defaultStatus', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + > + {statuses.map(status => ( + <option key={status} value={status}>{status}</option> + ))} + </select> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> + Default status for new tasks + </p> + </div> + + <div> + <label htmlFor="defaultEditor" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Default Editor + </label> + <input + id="defaultEditor" + type="text" + value={config.defaultEditor} + onChange={(e) => handleInputChange('defaultEditor', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + placeholder="e.g., vim, nano, code" + /> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> + Editor command to use for editing tasks (overrides EDITOR environment variable) + </p> + </div> + </div> + </div> + + {/* Web UI Settings */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> + <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Web UI Settings</h2> + <div className="space-y-4"> + <div> + <label htmlFor="defaultPort" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Default Port + </label> + <input + id="defaultPort" + type="number" + min="1" + max="65535" + value={config.defaultPort || 6420} + onChange={(e) => handleInputChange('defaultPort', parseInt(e.target.value) || 6420)} + className={`w-full px-3 py-2 border rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200 ${ + validationErrors.defaultPort + ? 'border-red-500 dark:border-red-400' + : 'border-gray-300 dark:border-gray-600' + }`} + /> + {validationErrors.defaultPort && ( + <p className="mt-1 text-sm text-red-600 dark:text-red-400">{validationErrors.defaultPort}</p> + )} + </div> + + <div> + <label className="flex items-center justify-between"> + <div> + <span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto Open Browser</span> + <p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> + Automatically open browser when starting web UI + </p> + </div> + <div className="relative inline-flex items-center cursor-pointer"> + <input + type="checkbox" + checked={config.autoOpenBrowser} + onChange={(e) => handleInputChange('autoOpenBrowser', e.target.checked)} + className="sr-only peer" + /> + <div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-circle peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-circle after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500"></div> + </div> + </label> + </div> + </div> + </div> + + {/* Advanced Settings */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> + <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Advanced Settings</h2> + <div className="space-y-4"> + <div> + <label htmlFor="maxColumnWidth" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Max Column Width + </label> + <input + id="maxColumnWidth" + type="number" + min="20" + max="200" + value={config.maxColumnWidth} + onChange={(e) => handleInputChange('maxColumnWidth', parseInt(e.target.value) || 80)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + /> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> + Maximum width for text columns in CLI output + </p> + </div> + + <div> + <label htmlFor="taskResolutionStrategy" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Task Resolution Strategy + </label> + <select + id="taskResolutionStrategy" + value={config.taskResolutionStrategy} + onChange={(e) => handleInputChange('taskResolutionStrategy', e.target.value as 'most_recent' | 'most_progressed')} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + > + <option value="most_recent">Most Recent</option> + <option value="most_progressed">Most Progressed</option> + </select> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> + Strategy for resolving conflicts when tasks exist in multiple branches + </p> + </div> + + <div> + <label htmlFor="zeroPaddedIds" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> + Zero-Padded IDs + </label> + <input + id="zeroPaddedIds" + type="number" + min="0" + max="10" + value={config.zeroPaddedIds || 0} + onChange={(e) => handleInputChange('zeroPaddedIds', parseInt(e.target.value) || 0)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + /> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> + Number of digits for ID padding (0 = disabled, 3 = task-001, 4 = task-0001) + </p> + </div> + </div> + </div> + + {/* Save/Cancel Buttons */} + <div className="flex items-center justify-end space-x-4"> + <button + onClick={handleCancel} + disabled={!hasUnsavedChanges || saving} + className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer" + > + Cancel + </button> + <button + onClick={handleSave} + disabled={!hasUnsavedChanges || saving} + className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer" + > + {saving ? 'Saving...' : 'Save Changes'} + </button> + </div> + </div> + </div> + + {/* Success Toast */} + {showSuccess && ( + <SuccessToast + message="Settings saved successfully!" + onDismiss={() => setShowSuccess(false)} + /> + )} + </div> + ); +}; + +export default Settings; \ No newline at end of file diff --git a/src/web/components/SideNavigation.tsx b/src/web/components/SideNavigation.tsx new file mode 100644 index 0000000..7dcabaa --- /dev/null +++ b/src/web/components/SideNavigation.tsx @@ -0,0 +1,820 @@ +import React, { useState, useEffect, useMemo, useCallback, memo } from 'react'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { Tooltip } from 'react-tooltip'; +import { + type Decision, + type DecisionSearchResult, + type Document, + type DocumentSearchResult, + type SearchResult, + type Task, + type TaskSearchResult, +} from '../../types'; +import ErrorBoundary from './ErrorBoundary'; +import { SidebarSkeleton } from './LoadingSpinner'; +import { sanitizeUrlTitle } from '../utils/urlHelpers'; +import { getWebVersion } from '../utils/version'; +import { apiClient } from '../lib/api'; + +// Utility functions for ID transformations +const stripIdPrefix = (id: string): string => { + if (id.startsWith('doc-')) return id.replace('doc-', ''); + if (id.startsWith('decision-')) return id.replace('decision-', ''); + if (id.startsWith('task-')) return id.replace('task-', ''); + return id; +}; + +// Icon components for better semantics and performance +const Icons = { + Tasks: () => ( + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /> + </svg> + ), + Board: () => ( + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> + </svg> + ), + List: () => ( + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /> + </svg> + ), + Draft: () => ( + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> + </svg> + ), + Document: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> + </svg> + ), + DocumentPage: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> + </svg> + ), + DocumentCode: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> + </svg> + ), + DocumentBook: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> + </svg> + ), + DocumentChart: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> + </svg> + ), + DocumentSettings: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> + </svg> + ), + DocumentInfo: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + ), + Decision: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> + </svg> + ), + DecisionPage: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + ), + DecisionArchitecture: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> + </svg> + ), + DecisionTech: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> + </svg> + ), + DecisionProcess: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> + </svg> + ), + DecisionBusiness: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H8a2 2 0 01-2-2V8a2 2 0 012-2V6" /> + </svg> + ), + Search: () => ( + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + ), + ChevronLeft: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> + </svg> + ), + ChevronRight: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> + </svg> + ), + ChevronDown: () => ( + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> + </svg> + ), + Statistics: () => ( + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /> + </svg> + ), +}; + +interface SideNavigationProps { + tasks: Task[]; + docs: Document[]; + decisions: Decision[]; + isLoading: boolean; + error?: Error | null; + onRetry?: () => void; + onRefreshData: () => Promise<void>; +} + +const SideNavigation = memo(function SideNavigation({ + tasks, + docs, + decisions, + isLoading, + error, + onRetry +}: SideNavigationProps) { + const [isCollapsed, setIsCollapsed] = useState(() => { + const saved = localStorage.getItem('sideNavCollapsed'); + return saved ? JSON.parse(saved) : false; + }); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState<SearchResult[]>([]); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState<string | null>(null); + const [searchInputRef, setSearchInputRef] = useState<HTMLInputElement | null>(null); + const [isDocsCollapsed, setIsDocsCollapsed] = useState(() => { + const saved = localStorage.getItem('docsCollapsed'); + if (saved !== null) { + return JSON.parse(saved); + } + // Auto-collapse if more than 6 documents + return docs.length > 6; + }); + const [isDecisionsCollapsed, setIsDecisionsCollapsed] = useState(() => { + const saved = localStorage.getItem('decisionsCollapsed'); + if (saved !== null) { + return JSON.parse(saved); + } + // Auto-collapse if more than 6 decisions + return decisions.length > 6; + }); + const [version, setVersion] = useState<string>(''); + const location = useLocation(); + const navigate = useNavigate(); + + // Create handlers - just navigate to new pages + const handleCreateDocument = useCallback(() => { + navigate('/documentation/new'); + }, [navigate]); + + useCallback(() => { + navigate('/decisions/new'); + }, [navigate]); + + useEffect(() => { + localStorage.setItem('sideNavCollapsed', JSON.stringify(isCollapsed)); + }, [isCollapsed]); + + // Fetch version on mount + useEffect(() => { + getWebVersion().then(setVersion).catch(() => setVersion('')); + }, []); + + // Save docs collapse state to localStorage + useEffect(() => { + localStorage.setItem('docsCollapsed', JSON.stringify(isDocsCollapsed)); + }, [isDocsCollapsed]); + + // Save decisions collapse state to localStorage + useEffect(() => { + localStorage.setItem('decisionsCollapsed', JSON.stringify(isDecisionsCollapsed)); + }, [isDecisionsCollapsed]); + + // Auto-collapse when data loads/changes if no saved preference exists + useEffect(() => { + const savedDocsCollapsed = localStorage.getItem('docsCollapsed'); + if (savedDocsCollapsed === null && docs.length > 6) { + setIsDocsCollapsed(true); + } + }, [docs.length]); + + useEffect(() => { + const savedDecisionsCollapsed = localStorage.getItem('decisionsCollapsed'); + if (savedDecisionsCollapsed === null && decisions.length > 6) { + setIsDecisionsCollapsed(true); + } + }, [decisions.length]); + + // Add keyboard shortcut for search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (isCollapsed) { + // Expand sidebar first, then focus will happen on next render + setIsCollapsed(false); + } else if (searchInputRef) { + searchInputRef.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [searchInputRef, isCollapsed]); + + // Auto-focus search input when sidebar expands + useEffect(() => { + if (!isCollapsed && searchInputRef) { + // Small delay to ensure the input is rendered + const timer = setTimeout(() => { + searchInputRef.focus(); + }, 100); + return () => clearTimeout(timer); + } + }, [isCollapsed, searchInputRef]); + + location.pathname.startsWith('/documentation'); + location.pathname.startsWith('/decisions'); + + + // Perform unified search via centralized API (debounced) + useEffect(() => { + const query = searchQuery.trim(); + if (query === '') { + setSearchResults([]); + setSearchError(null); + setIsSearching(false); + return; + } + + let cancelled = false; + setIsSearching(true); + setSearchError(null); + const timeout = setTimeout(async () => { + try { + const results = await apiClient.search({ query, limit: 15 }); + if (!cancelled) { + setSearchResults(results); + } + } catch (err) { + console.error('Sidebar search failed:', err); + if (!cancelled) { + setSearchResults([]); + setSearchError('Search failed'); + } + } finally { + if (!cancelled) { + setIsSearching(false); + } + } + }, 200); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [searchQuery]); + + const unifiedSearchResults = useMemo(() => { + if (!searchQuery.trim()) { + return []; + } + const filtered = searchResults + .filter((result) => result.score === null || result.score <= 0.45) + .sort((a, b) => { + const scoreA = a.score ?? Number.POSITIVE_INFINITY; + const scoreB = b.score ?? Number.POSITIVE_INFINITY; + return scoreA - scoreB; + }); + + return filtered.slice(0, 5); + }, [searchQuery, searchResults]); + + // Always show full lists in their sections, search results are separate + const filteredDocs = docs; + const filteredDecisions = decisions; + + const toggleCollapse = useCallback(() => { + setIsCollapsed((prev: any) => !prev); + }, []); + + return ( + <ErrorBoundary> + <div className={`relative bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 flex flex-col min-h-full z-10 ${isCollapsed ? 'w-16' : 'w-80 min-w-80'}`}> + {/* Search Bar */} + <div className={`${isCollapsed ? 'px-2' : 'px-4'} border-b border-gray-200 dark:border-gray-700 h-18 flex items-center relative`}> + {/* Collapse Toggle Button - Always positioned on the border */} + <button + onClick={toggleCollapse} + className="absolute -right-3 top-1/2 transform -translate-y-1/2 z-10 flex items-center justify-center w-6 h-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-circle shadow-sm hover:shadow-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-all duration-200 cursor-pointer" + aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} + title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} + > + {isCollapsed ? <Icons.ChevronRight /> : <Icons.ChevronLeft />} + </button> + + {!isCollapsed ? ( + <div className="flex items-center w-full"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 dark:text-gray-500"> + <Icons.Search /> + </div> + <input + ref={setSearchInputRef} + type="text" + placeholder="Search (⌘K)..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="w-full pl-10 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200" + /> + {searchQuery && ( + <button + onClick={() => setSearchQuery('')} + className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 cursor-pointer transition-colors duration-200" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + )} + </div> + </div> + ) : ( + <div className="flex items-center justify-center"> + <button + onClick={() => setIsCollapsed(false)} + className="flex items-center justify-center p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors duration-200 cursor-pointer" + title="Search (⌘K)" + > + <Icons.Search /> + </button> + </div> + )} + </div> + + {/* Unified Search Results */} + {!isCollapsed && searchQuery.trim() && unifiedSearchResults.length > 0 && ( + <div className="p-4 border-b border-gray-200 dark:border-gray-700"> + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Search Results</h3> + {isSearching && ( + <span className="text-xs text-gray-500 dark:text-gray-400">Searching…</span> + )} + </div> + <div className="space-y-1"> + {unifiedSearchResults.map((result, index) => { + const item = result.type === 'task' + ? (result as TaskSearchResult).task + : result.type === 'document' + ? (result as DocumentSearchResult).document + : (result as DecisionSearchResult).decision; + const getResultLink = () => { + if (result.type === 'document') { + return `/documentation/${stripIdPrefix(item.id)}/${sanitizeUrlTitle(item.title)}`; + } + if (result.type === 'decision') { + return `/decisions/${stripIdPrefix(item.id)}/${sanitizeUrlTitle(item.title)}`; + } + return `/?highlight=${encodeURIComponent(item.id)}`; + }; + + const getResultIcon = () => { + if (result.type === 'document') return <span className="text-green-500"><Icons.DocumentPage /></span>; + if (result.type === 'decision') return <span className="text-stone-500"><Icons.DecisionPage /></span>; + return <span className="text-purple-500"><Icons.Tasks /></span>; + }; + + return ( + <NavLink + key={`${result.type}-${item.id}-${index}`} + to={getResultLink()} + className="flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-900 dark:text-gray-100" + > + {getResultIcon()} + <div className="flex-1 min-w-0"> + <div className="font-medium truncate"> + {item.title} + </div> + <div className="text-xs text-gray-500 dark:text-gray-400 truncate"> + {result.type.charAt(0).toUpperCase() + result.type.slice(1)} β€’ {item.id} + </div> + </div> + {result.score !== null && ( + <div className="text-xs text-gray-400 dark:text-gray-500"> + {`${Math.round((1 - result.score) * 100)}%`} + </div> + )} + </NavLink> + ); + })} + </div> + </div> + )} + + {!isCollapsed && searchQuery.trim() && unifiedSearchResults.length === 0 && !isSearching && !searchError && ( + <div className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700"> + No matching results + </div> + )} + + {!isCollapsed && searchQuery.trim() && searchError && ( + <div className="px-4 py-2 text-sm text-red-600 dark:text-red-400 border-b border-gray-200 dark:border-gray-700"> + {searchError} + </div> + )} + + + <nav className="flex-1 overflow-y-auto"> + {/* Loading Indicator - only show when expanded since collapsed nav is static */} + {isLoading && !isCollapsed && ( + <SidebarSkeleton isCollapsed={false} /> + )} + + {/* Error State */} + {error && !isLoading && !isCollapsed && ( + <div className="px-4 py-4"> + <div className="text-center p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"> + <p className="text-sm text-red-700 dark:text-red-400 mb-2">Failed to load navigation</p> + {onRetry && ( + <button + onClick={onRetry} + className="text-xs px-3 py-1 bg-red-600 dark:bg-red-700 text-white rounded hover:bg-red-700 dark:hover:bg-red-600 transition-colors duration-200 cursor-pointer" + > + Retry + </button> + )} + </div> + </div> + )} + + {/* Tasks Section - Hidden in collapsed state and when loading */} + {!isCollapsed && !isLoading && ( + <div className="px-4 py-4"> + <div className="flex items-center space-x-3 text-gray-700 dark:text-gray-300"> + <span className="text-gray-500 dark:text-gray-400"><Icons.Tasks /></span> + <span className="text-sm font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400 whitespace-nowrap">Tasks ({tasks.length})</span> + </div> + </div> + )} + + {/* Navigation items only show when expanded and not loading */} + {!isCollapsed && !isLoading && ( + <div className="px-4 space-y-1"> + {/* Board Navigation */} + <NavLink + to="/" + className={({ isActive }) => + `flex items-center px-3 py-2 rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <Icons.Board /> + <span className="ml-3 text-sm font-medium">Kanban Board</span> + </NavLink> + + {/* Tasks Navigation */} + <NavLink + to="/tasks" + className={({ isActive }) => + `flex items-center px-3 py-2 rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <Icons.List /> + <span className="ml-3 text-sm font-medium">All Tasks</span> + </NavLink> + + {/* Drafts Navigation */} + <NavLink + to="/drafts" + className={({ isActive }) => + `flex items-center px-3 py-2 rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <Icons.Draft /> + <span className="ml-3 text-sm font-medium">Drafts</span> + </NavLink> + + {/* Statistics Navigation */} + <NavLink + to="/statistics" + className={({ isActive }) => + `flex items-center px-3 py-2 rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <Icons.Statistics /> + <span className="ml-3 text-sm font-medium">Statistics</span> + </NavLink> + </div> + )} + + {!isCollapsed && !isLoading && ( + <> + {/* Divider between Tasks and Documents */} + <div className="mx-4 my-2 border-t border-gray-200 dark:border-gray-700"></div> + + {/* Documents Section */} + <div className="px-4 py-4"> + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center space-x-3"> + <button + onClick={() => setIsDocsCollapsed(!isDocsCollapsed)} + className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors duration-200 cursor-pointer" + title={isDocsCollapsed ? "Expand documents" : "Collapse documents"} + > + {isDocsCollapsed ? <Icons.ChevronRight /> : <Icons.ChevronDown />} + </button> + <span className="text-gray-500 dark:text-gray-400"><Icons.Document /></span> + <span className="text-sm font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400 whitespace-nowrap">Documents ({docs.length})</span> + </div> + <button + onClick={handleCreateDocument} + className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer" + title="Create new document" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> + <circle cx="12" cy="12" r="10" /> + </svg> + </button> + </div> + + {/* Document List */} + {!isDocsCollapsed && ( + <div className="space-y-1"> + {filteredDocs.length === 0 ? ( + <p className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">No documents</p> + ) : ( + filteredDocs.map((doc) => ( + <NavLink + key={doc.id} + to={`/documentation/${stripIdPrefix(doc.id)}/${sanitizeUrlTitle(doc.title)}`} + className={({ isActive }) => + `flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <span className="text-gray-400 dark:text-gray-500"><Icons.DocumentPage /></span> + <span className="truncate">{doc.title}</span> + </NavLink> + )) + )} + </div> + )} + </div> + + {/* Divider between Documents and Decisions */} + <div className="mx-4 my-2 border-t border-gray-200 dark:border-gray-700"></div> + + {/* Decisions Section */} + <div className="px-4 py-4"> + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center space-x-3"> + <button + onClick={() => setIsDecisionsCollapsed(!isDecisionsCollapsed)} + className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors duration-200 cursor-pointer" + title={isDecisionsCollapsed ? "Expand decisions" : "Collapse decisions"} + > + {isDecisionsCollapsed ? <Icons.ChevronRight /> : <Icons.ChevronDown />} + </button> + <span className="text-gray-500 dark:text-gray-400"><Icons.Decision /></span> + <span className="text-sm font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400 whitespace-nowrap">Decisions ({decisions.length})</span> + </div> + {/* Temporarily hidden - decisions editing not ready */} + {/*{false && (*/} + {/* <button*/} + {/* onClick={handleCreateDecision}*/} + {/* className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors cursor-pointer"*/} + {/* title="Create new decision"*/} + {/* >*/} + {/* <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">*/} + {/* <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />*/} + {/* <circle cx="12" cy="12" r="10" />*/} + {/* </svg>*/} + {/* </button>*/} + {/*)}*/} + </div> + + {/* Decision List */} + {!isDecisionsCollapsed && ( + <div className="space-y-1"> + {filteredDecisions.length === 0 ? ( + <p className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">No decisions</p> + ) : ( + filteredDecisions.map((decision) => ( + <NavLink + key={decision.id} + to={`/decisions/${stripIdPrefix(decision.id)}/${sanitizeUrlTitle(decision.title)}`} + className={({ isActive }) => + `flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <span className="text-gray-400 dark:text-gray-500"><Icons.DecisionPage /></span> + <span className="truncate">{decision.title}</span> + </NavLink> + )) + )} + </div> + )} + </div> + </> + )} + + {isCollapsed && ( + <div className="px-2 py-2 space-y-2"> + <NavLink + to="/" + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="Kanban Board" + className={({ isActive }) => + `flex items-center justify-center p-3 rounded-md transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-700 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.Board /> + </div> + </NavLink> + <NavLink + to="/tasks" + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="All Tasks" + className={({ isActive }) => + `flex items-center justify-center p-3 rounded-md transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-700 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.List /> + </div> + </NavLink> + {/* Drafts Navigation */} + <NavLink + to="/drafts" + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="Drafts" + className={({ isActive }) => + `flex items-center justify-center p-3 rounded-md transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-700 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.Draft /> + </div> + </NavLink> + {/* Statistics Navigation */} + <NavLink + to="/statistics" + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="Statistics" + className={({ isActive }) => + `flex items-center justify-center p-3 rounded-md transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-700 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.Statistics /> + </div> + </NavLink> + <button + onClick={() => { + setIsCollapsed(false); + setIsDocsCollapsed(false); + }} + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="Documentation" + className={`flex items-center justify-center p-3 rounded-md transition-colors duration-200 cursor-pointer w-full ${ + location.pathname.startsWith('/documentation') + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-700 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }`} + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.Document /> + </div> + </button> + <button + onClick={() => { + setIsCollapsed(false); + setIsDecisionsCollapsed(false); + }} + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="Decisions" + className={`flex items-center justify-center p-3 rounded-md transition-colors duration-200 cursor-pointer w-full ${ + location.pathname.startsWith('/decisions') + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-700 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }`} + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.Decision /> + </div> + </button> + </div> + )} + </nav> + + {/* Settings Button - Bottom Left */} + <div className={`border-t border-gray-200 dark:border-gray-700 ${isCollapsed ? 'px-2 py-2' : 'px-4 py-4'}`}> + {!isCollapsed ? ( + <NavLink + to="/settings" + className={({ isActive }) => + `flex items-center px-3 py-2 rounded-lg transition-colors duration-200 ${ + isActive + ? 'bg-blue-50 dark:bg-blue-600/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <Icons.DocumentSettings /> + <span className="ml-3 text-sm font-medium">Settings</span> + {version && ( + <span className="ml-auto text-xs text-gray-500 dark:text-gray-400">Backlog.md - v{version}</span> + )} + </NavLink> + ) : ( + <NavLink + to="/settings" + data-tooltip-id="sidebar-tooltip" + data-tooltip-content="Settings" + className={({ isActive }) => + `flex items-center justify-center p-3 rounded-md transition-colors duration-200 ${ + isActive + ? 'bg-stone-50 dark:bg-stone-900/30 text-stone-700 dark:text-stone-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100' + }` + } + > + <div className="w-6 h-6 flex items-center justify-center"> + <Icons.DocumentSettings /> + </div> + </NavLink> + )} + </div> + + <Tooltip id="sidebar-tooltip" place="right" /> + </div> + </ErrorBoundary> + ); +}); + +export default SideNavigation; diff --git a/src/web/components/Statistics.tsx b/src/web/components/Statistics.tsx new file mode 100644 index 0000000..003bfd8 --- /dev/null +++ b/src/web/components/Statistics.tsx @@ -0,0 +1,543 @@ +import React, { useState, useEffect } from 'react'; +import { apiClient } from '../lib/api'; +import type { TaskStatistics } from '../../core/statistics'; +import type { Task } from '../../types'; +import LoadingSpinner from './LoadingSpinner'; + +interface StatisticsData extends Omit<TaskStatistics, 'statusCounts' | 'priorityCounts'> { + statusCounts: Record<string, number>; + priorityCounts: Record<string, number>; +} + +interface StatisticsProps { + tasks?: Task[]; + isLoading?: boolean; + onEditTask?: (task: Task) => void; + projectName?: string; +} + +const Statistics: React.FC<StatisticsProps> = ({ tasks, isLoading: externalLoading, onEditTask, projectName }) => { + const [statistics, setStatistics] = useState<StatisticsData | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [loadingMessage, setLoadingMessage] = useState('Building statistics...'); + + useEffect(() => { + let isMounted = true; + let messageInterval: NodeJS.Timeout | undefined; + + const fetchStatistics = async () => { + if (!isMounted) return; + + try { + setLoading(true); + setError(null); + + // Loading messages that reflect actual backend operations + const loadingMessages = [ + 'Building statistics...', + 'Loading local tasks...', + 'Loading completed tasks...', + 'Merging tasks...', + 'Checking task states across branches...', + 'Loading drafts...', + 'Calculating statistics...' + ]; + + // Start with first message + if (isMounted) setLoadingMessage(loadingMessages[0] || ''); + + // Cycle through loading messages at a readable pace + let messageIndex = 0; + messageInterval = setInterval(() => { + if (!isMounted || messageIndex >= loadingMessages.length - 1) { + clearInterval(messageInterval); + return; + } + messageIndex++; + setLoadingMessage(loadingMessages[messageIndex] || ''); + }, 800); // 800ms so users can actually read each message + + // Fetch data (this happens in parallel with message cycling) + const data = await apiClient.fetchStatistics(); + + // Stop the message cycling once data arrives + if (messageInterval) { + clearInterval(messageInterval); + } + + if (isMounted) { + setStatistics(data); + } + } catch (err) { + if (isMounted) { + console.error('Failed to fetch statistics:', err); + setError('Failed to load statistics'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchStatistics(); + + return () => { + isMounted = false; + if (messageInterval) { + clearInterval(messageInterval); + } + }; + }, []); + + if (loading || externalLoading) { + return ( + <div className="flex flex-col justify-center items-center h-64 space-y-4"> + <LoadingSpinner size="lg" text="" /> + <div className="text-center"> + <p className="text-lg font-medium text-gray-900 dark:text-gray-100"> + {loading ? loadingMessage : 'Loading statistics...'} + </p> + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + This might take a while... + </p> + </div> + </div> + ); + } + + if (error) { + return ( + <div className="p-8 text-center"> + <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6"> + <p className="text-red-600 dark:text-red-400 font-medium">Error loading statistics</p> + <p className="text-red-500 dark:text-red-300 text-sm mt-1">{error}</p> + </div> + </div> + ); + } + + if (!statistics) { + return ( + <div className="p-8 text-center"> + <p className="text-gray-500 dark:text-gray-400">No statistics available</p> + </div> + ); + } + + const TaskPreview = ({ task, showDate, onClick }: { task: Task; showDate: 'created' | 'updated'; onClick?: () => void }) => { + const formatDate = (dateStr: string) => { + const hasTime = dateStr.includes(" ") || dateStr.includes("T"); + const date = new Date(dateStr.replace(" ", "T") + (hasTime ? ":00Z" : "T00:00:00Z")); + + if (hasTime) { + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } else { + return date.toLocaleDateString(); + } + }; + + const displayDate = showDate === 'created' ? task.createdDate : task.updatedDate || task.createdDate; + + return ( + <div + key={task.id} + className={`flex items-center space-x-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg transition-colors duration-200 ${ + onClick ? 'hover:bg-gray-100 dark:hover:bg-gray-600/50 cursor-pointer' : '' + }`} + onClick={onClick} + > + <StatusIcon status={task.status} /> + <div className="flex-1 min-w-0"> + <p className="font-medium text-gray-900 dark:text-gray-100 truncate">{task.title}</p> + <p className="text-sm text-gray-500 dark:text-gray-400"> + {task.id} β€’ {showDate === 'created' ? 'Created' : 'Updated'} {formatDate(displayDate)} + </p> + </div> + </div> + ); + }; + + const StatusIcon = ({ status }: { status: string }) => { + switch (status.toLowerCase()) { + case 'to do': + return ( + <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + ); + case 'in progress': + return ( + <svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + ); + case 'done': + return ( + <svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 24 24"> + <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + ); + default: + return ( + <svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> + </svg> + ); + } + }; + + const PriorityIcon = ({ priority }: { priority: string }) => { + switch (priority.toLowerCase()) { + case 'high': + return ( + <svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 24 24"> + <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> + </svg> + ); + case 'medium': + return ( + <svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 24 24"> + <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> + </svg> + ); + case 'low': + return ( + <svg className="w-4 h-4 text-blue-500" fill="currentColor" viewBox="0 0 24 24"> + <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> + </svg> + ); + default: + return ( + <svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" /> + </svg> + ); + } + }; + + const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case 'to do': return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; + case 'in progress': return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200'; + case 'done': return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'; + default: return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority.toLowerCase()) { + case 'high': return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200'; + case 'medium': return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200'; + case 'low': return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200'; + case 'none': return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; + default: return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; + } + }; + + return ( + <div className="max-w-7xl mx-auto p-6 space-y-8"> + {/* Header */} + <div className="text-center"> + <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2"> + {projectName ? `${projectName} Statistics` : 'Project Statistics'} + </h1> + <p className="text-gray-600 dark:text-gray-400"> + Overview of your project's task metrics and activity + </p> + </div> + + {/* Key Metrics Cards */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> + {/* Total Tasks */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <div className="flex items-center"> + <div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> + <svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /> + </svg> + </div> + <div className="ml-4"> + <p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{statistics.totalTasks}</p> + <p className="text-gray-600 dark:text-gray-400 text-sm">Total Tasks</p> + </div> + </div> + </div> + + {/* Completed Tasks */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <div className="flex items-center"> + <div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg"> + <svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 24 24"> + <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + </div> + <div className="ml-4"> + <p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{statistics.completedTasks}</p> + <p className="text-gray-600 dark:text-gray-400 text-sm">Completed</p> + </div> + </div> + </div> + + {/* Completion Percentage */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <div className="flex items-center"> + <div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> + <svg className="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> + </svg> + </div> + <div className="ml-4"> + <p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{statistics.completionPercentage}%</p> + <p className="text-gray-600 dark:text-gray-400 text-sm">Completion</p> + </div> + </div> + </div> + + {/* Drafts */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <div className="flex items-center"> + <div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg"> + <svg className="w-6 h-6 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> + </svg> + </div> + <div className="ml-4"> + <p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{statistics.draftCount}</p> + <p className="text-gray-600 dark:text-gray-400 text-sm">Drafts</p> + </div> + </div> + </div> + </div> + + {/* Progress Bar */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Overall Progress</h3> + <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-circle h-4 mb-2"> + <div + className="bg-gradient-to-r from-blue-500 to-green-500 h-4 rounded-circle transition-all duration-300" + style={{ width: `${statistics.completionPercentage}%` }} + ></div> + </div> + <div className="flex justify-between text-sm text-gray-600 dark:text-gray-400"> + <span>{statistics.completedTasks} completed</span> + <span>{statistics.totalTasks - statistics.completedTasks} remaining</span> + </div> + </div> + + {/* Status and Priority Distribution */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> + {/* Status Distribution */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Status Distribution</h3> + <div className="space-y-4"> + {Object.entries(statistics.statusCounts) + .filter(([, count]) => count > 0) + .map(([status, count]) => ( + <div key={status} className="flex items-center justify-between"> + <div className="flex items-center space-x-3"> + <StatusIcon status={status} /> + <span className={`px-3 py-1 rounded-circle text-sm font-medium ${getStatusColor(status)}`}> + {status} + </span> + </div> + <div className="flex items-center space-x-3"> + <div className="text-right"> + <div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{count}</div> + <div className="text-xs text-gray-500 dark:text-gray-400"> + {Math.round((count / statistics.totalTasks) * 100)}% + </div> + </div> + <div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-circle h-2"> + <div + className="bg-blue-500 h-2 rounded-circle transition-all duration-300" + style={{ width: `${(count / statistics.totalTasks) * 100}%` }} + ></div> + </div> + </div> + </div> + ))} + </div> + </div> + + {/* Priority Distribution */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Priority Distribution</h3> + <div className="space-y-4"> + {Object.entries(statistics.priorityCounts) + .filter(([, count]) => count > 0) + .map(([priority, count]) => ( + <div key={priority} className="flex items-center justify-between"> + <div className="flex items-center space-x-3"> + <PriorityIcon priority={priority} /> + <span className={`px-3 py-1 rounded-circle text-sm font-medium ${getPriorityColor(priority)}`}> + {priority === 'none' ? 'No Priority' : priority.charAt(0).toUpperCase() + priority.slice(1)} + </span> + </div> + <div className="flex items-center space-x-3"> + <div className="text-right"> + <div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{count}</div> + <div className="text-xs text-gray-500 dark:text-gray-400"> + {Math.round((count / statistics.totalTasks) * 100)}% + </div> + </div> + <div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-circle h-2"> + <div + className="bg-yellow-500 h-2 rounded-circle transition-all duration-300" + style={{ width: `${(count / statistics.totalTasks) * 100}%` }} + ></div> + </div> + </div> + </div> + ))} + </div> + </div> + </div> + + {/* Recent Activity */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> + {/* Recently Created */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Recently Created</h3> + {statistics.recentActivity.created.length > 0 ? ( + <div className="space-y-3"> + {statistics.recentActivity.created.map((task) => ( + <TaskPreview + task={task} + showDate="created" + onClick={onEditTask ? () => onEditTask(task) : undefined} + /> + ))} + </div> + ) : ( + <p className="text-gray-500 dark:text-gray-400 text-sm">No recently created tasks</p> + )} + </div> + + {/* Recently Updated */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> + <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Recently Updated</h3> + {statistics.recentActivity.updated.length > 0 ? ( + <div className="space-y-3"> + {statistics.recentActivity.updated.map((task) => ( + <TaskPreview + task={task} + showDate="updated" + onClick={onEditTask ? () => onEditTask(task) : undefined} + /> + ))} + </div> + ) : ( + <p className="text-gray-500 dark:text-gray-400 text-sm">No recently updated tasks</p> + )} + </div> + </div> + + {/* Project Health - Completely redesigned as a summary row */} + <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Project Health</h3> + + <div className="flex items-center space-x-4 text-sm"> + <div className="flex items-center space-x-1"> + <span className="text-gray-600 dark:text-gray-400">Avg age:</span> + <span className="font-medium text-gray-900 dark:text-gray-100">{statistics.projectHealth.averageTaskAge}d</span> + </div> + + {statistics.projectHealth.staleTasks.length > 0 && ( + <div className="flex items-center space-x-1"> + <div className="w-2 h-2 bg-yellow-500 rounded-circle"></div> + <span className="font-medium text-yellow-700 dark:text-yellow-400">{statistics.projectHealth.staleTasks.length} stale</span> + </div> + )} + + {statistics.projectHealth.blockedTasks.length > 0 && ( + <div className="flex items-center space-x-1"> + <div className="w-2 h-2 bg-red-500 rounded-circle"></div> + <span className="font-medium text-red-700 dark:text-red-400">{statistics.projectHealth.blockedTasks.length} blocked</span> + </div> + )} + + {statistics.projectHealth.staleTasks.length === 0 && statistics.projectHealth.blockedTasks.length === 0 && ( + <div className="flex items-center space-x-1"> + <div className="w-2 h-2 bg-green-500 rounded-circle"></div> + <span className="font-medium text-green-700 dark:text-green-400">All good!</span> + </div> + )} + </div> + </div> + + {/* Expandable task lists - only show if there are issues */} + {(statistics.projectHealth.staleTasks.length > 0 || statistics.projectHealth.blockedTasks.length > 0) && ( + <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + {/* Stale Tasks */} + {statistics.projectHealth.staleTasks.length > 0 && ( + <div> + <h4 className="font-medium text-yellow-700 dark:text-yellow-400 mb-3 text-sm"> + Stale Tasks (>30 days) + </h4> + <p className="text-xs text-gray-600 dark:text-gray-400 mb-3"> + Tasks that haven't been updated in over 30 days and may need attention or archiving + </p> + <div className="space-y-2"> + {statistics.projectHealth.staleTasks.slice(0, 3).map((task) => ( + <TaskPreview + key={task.id} + task={task} + showDate="updated" + onClick={onEditTask ? () => onEditTask(task) : undefined} + /> + ))} + {statistics.projectHealth.staleTasks.length > 3 && ( + <p className="text-xs text-gray-500 dark:text-gray-400 px-3"> + +{statistics.projectHealth.staleTasks.length - 3} more stale tasks + </p> + )} + </div> + </div> + )} + + {/* Blocked Tasks */} + {statistics.projectHealth.blockedTasks.length > 0 && ( + <div> + <h4 className="font-medium text-red-700 dark:text-red-400 mb-3 text-sm"> + Blocked Tasks + </h4> + <p className="text-xs text-gray-600 dark:text-gray-400 mb-3"> + Tasks that cannot progress because their dependencies are not yet completed + </p> + <div className="space-y-2"> + {statistics.projectHealth.blockedTasks.slice(0, 3).map((task) => ( + <TaskPreview + key={task.id} + task={task} + showDate="created" + onClick={onEditTask ? () => onEditTask(task) : undefined} + /> + ))} + {statistics.projectHealth.blockedTasks.length > 3 && ( + <p className="text-xs text-gray-500 dark:text-gray-400 px-3"> + +{statistics.projectHealth.blockedTasks.length - 3} more blocked tasks + </p> + )} + </div> + </div> + )} + </div> + </div> + )} + </div> + + </div> + ); +}; + +export default Statistics; \ No newline at end of file diff --git a/src/web/components/SuccessToast.tsx b/src/web/components/SuccessToast.tsx new file mode 100644 index 0000000..315285c --- /dev/null +++ b/src/web/components/SuccessToast.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +interface SuccessToastProps { + message: string; + onDismiss: () => void; + icon?: React.ReactNode; +} + +export function SuccessToast({ message, onDismiss, icon }: SuccessToastProps) { + return ( + <div className="fixed top-4 right-4 bg-green-500 dark:bg-green-600 text-white px-6 py-4 rounded-lg shadow-xl flex items-center gap-3 animate-slide-in-right z-50 border border-green-400 dark:border-green-500 transition-colors duration-200"> + {icon || <div className="w-2 h-2 bg-white rounded-circle" />} + <span className="font-medium">{message}</span> + <button + onClick={onDismiss} + className="ml-2 text-green-200 dark:text-green-300 hover:text-white transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-300 dark:focus:ring-green-400 rounded p-1 cursor-pointer" + aria-label="Dismiss" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + ); +} \ No newline at end of file diff --git a/src/web/components/TaskCard.tsx b/src/web/components/TaskCard.tsx new file mode 100644 index 0000000..d8af540 --- /dev/null +++ b/src/web/components/TaskCard.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { type Task } from '../../types'; + +interface TaskCardProps { + task: Task; + onUpdate: (taskId: string, updates: Partial<Task>) => void; + onEdit: (task: Task) => void; + onDragStart?: () => void; + onDragEnd?: () => void; + status?: string; +} + +const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEnd, status }) => { + const [isDragging, setIsDragging] = React.useState(false); + const [showBranchTooltip, setShowBranchTooltip] = React.useState(false); + + // Check if task is from another branch (read-only) + const isFromOtherBranch = Boolean(task.branch); + + const handleDragStart = (e: React.DragEvent) => { + // Prevent dragging cross-branch tasks + if (isFromOtherBranch) { + e.preventDefault(); + setShowBranchTooltip(true); + setTimeout(() => setShowBranchTooltip(false), 3000); + return; + } + + e.dataTransfer.setData('text/plain', task.id); + if (status) { + e.dataTransfer.setData('text/status', status); + } + e.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + onDragStart?.(); + }; + + const handleDragEnd = () => { + setIsDragging(false); + onDragEnd?.(); + }; + + const getPriorityClass = (priority?: string) => { + switch (priority) { + case 'high': return 'border-l-4 border-l-red-500 dark:border-l-red-400'; + case 'medium': return 'border-l-4 border-l-yellow-500 dark:border-l-yellow-400'; + case 'low': return 'border-l-4 border-l-green-500 dark:border-l-green-400'; + default: return 'border-l-4 border-l-gray-300 dark:border-l-gray-600'; + } + }; + + const formatDate = (dateStr: string) => { + // Handle both date-only and datetime formats + const hasTime = dateStr.includes(" ") || dateStr.includes("T"); + const date = new Date(dateStr.replace(" ", "T") + (hasTime ? ":00Z" : "T00:00:00Z")); + + if (hasTime) { + // Show date and time for datetime values + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } else { + // Show only date for date-only values + return date.toLocaleDateString(); + } + }; + + const truncateText = (text: string, maxLength: number = 120): string => { + if (!text || text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + '...'; + }; + + return ( + <div className="relative"> + {/* Branch tooltip when trying to drag cross-branch task */} + {showBranchTooltip && isFromOtherBranch && ( + <div className="absolute -top-12 left-1/2 transform -translate-x-1/2 z-50 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-md shadow-lg whitespace-nowrap"> + <div className="flex items-center gap-1"> + <svg className="w-3.5 h-3.5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> + </svg> + Switch to <span className="font-semibold text-amber-300">{task.branch}</span> branch to move this task + </div> + <div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-1/2 rotate-45 w-2 h-2 bg-gray-900 dark:bg-gray-700"></div> + </div> + )} + + <div + className={`bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md p-3 mb-2 transition-all duration-200 ${ + isFromOtherBranch + ? 'opacity-75 cursor-not-allowed border-dashed' + : 'cursor-pointer hover:shadow-md dark:hover:shadow-lg hover:border-stone-500 dark:hover:border-stone-400' + } ${getPriorityClass(task.priority)} ${ + isDragging ? 'opacity-50 transform rotate-2 scale-105' : '' + }`} + draggable={!isFromOtherBranch} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onClick={() => onEdit(task)} + > + {/* Cross-branch indicator banner */} + {isFromOtherBranch && ( + <div className="flex items-center gap-1.5 mb-2 px-2 py-1 -mx-1 -mt-1 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-700 rounded-t text-xs text-amber-700 dark:text-amber-300"> + <svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> + </svg> + <span className="truncate"> + From <span className="font-semibold">{task.branch}</span> branch + </span> + </div> + )} + + <div className="mb-2"> + <h4 className={`font-semibold text-sm line-clamp-2 transition-colors duration-200 ${ + isFromOtherBranch + ? 'text-gray-600 dark:text-gray-400' + : 'text-gray-900 dark:text-gray-100' + }`}> + {task.title} + </h4> + <span className="text-xs text-gray-500 dark:text-gray-400 transition-colors duration-200">{task.id}</span> + </div> + + {task.description?.trim() && ( + <p className="text-sm text-gray-600 dark:text-gray-300 mb-3 line-clamp-3 transition-colors duration-200"> + {truncateText(task.description.trim())} + </p> + )} + + {task.labels.length > 0 && ( + <div className="flex flex-wrap gap-1 mb-2"> + {task.labels.map(label => ( + <span + key={label} + className="inline-block px-2 py-1 text-xs bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors duration-200" + > + {label} + </span> + ))} + </div> + )} + + {task.assignee.length > 0 && ( + <div className="flex items-center gap-1 mb-2"> + <span className="text-xs text-gray-500 dark:text-gray-400 transition-colors duration-200">Assignee:</span> + <span className="text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"> + {task.assignee.join(', ')} + </span> + </div> + )} + + {task.dependencies.length > 0 && ( + <div className="flex items-center gap-1 mb-2"> + <span className="text-xs text-gray-500 dark:text-gray-400 transition-colors duration-200">Depends on:</span> + <span className="text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"> + {task.dependencies.join(', ')} + </span> + </div> + )} + + <div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-3 pt-2 border-t border-gray-100 dark:border-gray-600 transition-colors duration-200"> + <span>Created: {formatDate(task.createdDate)}</span> + {task.priority && ( + <span className={`font-medium transition-colors duration-200 ${ + task.priority === 'high' ? 'text-red-600 dark:text-red-400' : + task.priority === 'medium' ? 'text-yellow-600 dark:text-yellow-400' : + 'text-green-600 dark:text-green-400' + }`}> + {task.priority} + </span> + )} + </div> + </div> + </div> + ); +}; + +export default TaskCard; diff --git a/src/web/components/TaskColumn.tsx b/src/web/components/TaskColumn.tsx new file mode 100644 index 0000000..7e43f26 --- /dev/null +++ b/src/web/components/TaskColumn.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { type Task } from '../../types'; +import type { ReorderTaskPayload } from '../lib/api'; +import TaskCard from './TaskCard'; + +interface TaskColumnProps { + title: string; + tasks: Task[]; + onTaskUpdate: (taskId: string, updates: Partial<Task>) => void; + onEditTask: (task: Task) => void; + onTaskReorder?: (payload: ReorderTaskPayload) => void; + dragSourceStatus?: string | null; + onDragStart?: () => void; + onDragEnd?: () => void; + onCleanup?: () => void; +} + +const TaskColumn: React.FC<TaskColumnProps> = ({ + title, + tasks, + onTaskUpdate, + onEditTask, + onTaskReorder, + dragSourceStatus, + onDragStart, + onDragEnd, + onCleanup +}) => { + const [isDragOver, setIsDragOver] = React.useState(false); + const [draggedTaskId, setDraggedTaskId] = React.useState<string | null>(null); + const [dropPosition, setDropPosition] = React.useState<{ index: number; position: 'before' | 'after' } | null>(null); + const getStatusBadgeClass = (status: string) => { + const statusLower = status.toLowerCase(); + if (statusLower.includes('done') || statusLower.includes('complete')) { + return 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 transition-colors duration-200'; + } + if (statusLower.includes('progress') || statusLower.includes('doing')) { + return 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 transition-colors duration-200'; + } + if (statusLower.includes('blocked') || statusLower.includes('stuck')) { + return 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 transition-colors duration-200'; + } + return 'bg-stone-100 dark:bg-stone-900 text-stone-800 dark:text-stone-200 transition-colors duration-200'; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + setDropPosition(null); + + const droppedTaskId = e.dataTransfer.getData('text/plain'); + const sourceStatus = e.dataTransfer.getData('text/status'); + + if (!droppedTaskId) return; + + if (!onTaskReorder) { + return; + } + + const columnWithoutDropped = tasks.filter((task) => task.id !== droppedTaskId); + + let insertIndex = columnWithoutDropped.length; + if (dropPosition) { + const { index, position } = dropPosition; + const baseIndex = position === 'before' ? index : index + 1; + let count = 0; + for (let i = 0; i < Math.min(baseIndex, tasks.length); i += 1) { + if (tasks[i]?.id === droppedTaskId) { + continue; + } + count += 1; + } + insertIndex = count; + } + + const orderedTaskIds = columnWithoutDropped.map((task) => task.id); + orderedTaskIds.splice(insertIndex, 0, droppedTaskId); + + const isSameColumn = sourceStatus === title; + const isOrderUnchanged = + isSameColumn && + orderedTaskIds.length === tasks.length && + orderedTaskIds.every((taskId, idx) => taskId === tasks[idx]?.id); + + if (isOrderUnchanged) { + return; + } + + onTaskReorder({ taskId: droppedTaskId, targetStatus: title, orderedTaskIds }); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + // Only set to false if we're leaving the column entirely + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + setDropPosition(null); + } + }; + + const handleDragOverColumn = (e: React.DragEvent) => { + e.preventDefault(); + // Clear drop position if dragging in empty space + const target = e.target as HTMLElement; + if (target === e.currentTarget || target.classList.contains('space-y-3')) { + setDropPosition(null); + } + }; + + return ( + <div + className={`rounded-lg p-4 min-h-96 transition-colors duration-200 ${ + isDragOver && dragSourceStatus !== title + ? 'bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-600 border-dashed' + : 'bg-white border border-gray-200 shadow-sm dark:bg-gray-800 dark:border-gray-700' + }`} + onDrop={handleDrop} + onDragOver={handleDragOverColumn} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + > + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-2"> + <h3 className="font-semibold text-gray-900 dark:text-gray-100 transition-colors duration-200">{title}</h3> + <span className={`px-2 py-1 text-xs font-medium rounded-circle ${getStatusBadgeClass(title)}`}> + {tasks.length} + </span> + </div> + </div> + + <div className="space-y-3"> + {tasks.map((task, index) => ( + <div + key={task.id} + className="relative" + onDragOver={(e) => { + if (!onTaskReorder || !draggedTaskId || draggedTaskId === task.id) return; + + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + const height = rect.height; + + // Determine if we're in the top or bottom half + if (y < height / 2) { + setDropPosition({ index, position: 'before' }); + } else { + setDropPosition({ index, position: 'after' }); + } + }} + > + {/* Drop indicator for before this task */} + {dropPosition?.index === index && dropPosition.position === 'before' && ( + <div className="h-1 bg-blue-500 rounded-full mb-2 animate-pulse" /> + )} + + <TaskCard + task={task} + onUpdate={onTaskUpdate} + onEdit={onEditTask} + onDragStart={() => { + setDraggedTaskId(task.id); + onDragStart?.(); + }} + onDragEnd={() => { + setDraggedTaskId(null); + setDropPosition(null); + onDragEnd?.(); + }} + status={title} + /> + + {/* Drop indicator for after this task */} + {dropPosition?.index === index && dropPosition.position === 'after' && ( + <div className="h-1 bg-blue-500 rounded-full mt-2 animate-pulse" /> + )} + </div> + ))} + + {/* Drop zone indicator - only show in different columns */} + {isDragOver && dragSourceStatus !== title && ( + <div className="border-2 border-green-400 dark:border-green-500 border-dashed rounded-md bg-green-50 dark:bg-green-900/20 p-4 text-center transition-colors duration-200"> + <div className="text-green-600 dark:text-green-400 text-sm font-medium transition-colors duration-200"> + Drop task here to change status + </div> + </div> + )} + + {tasks.length === 0 && !isDragOver && ( + <div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm transition-colors duration-200"> + {dragSourceStatus && dragSourceStatus !== title + ? `Drop here to move to ${title}` + : `No tasks in ${title}`} + </div> + )} + + {/* Cleanup button for Done column */} + {onCleanup && title.toLowerCase() === 'done' && tasks.length > 0 && ( + <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> + <button + onClick={onCleanup} + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-200 cursor-pointer" + title="Clean up old completed tasks" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> + </svg> + Clean Up Old Tasks + </button> + </div> + )} + </div> + </div> + ); +}; + +export default TaskColumn; diff --git a/src/web/components/TaskDetailsModal.tsx b/src/web/components/TaskDetailsModal.tsx new file mode 100644 index 0000000..9f3a1b6 --- /dev/null +++ b/src/web/components/TaskDetailsModal.tsx @@ -0,0 +1,605 @@ +import React, { useEffect, useMemo, useState } from "react"; +import type { AcceptanceCriterion, Task } from "../../types"; +import Modal from "./Modal"; +import { apiClient } from "../lib/api"; +import { useTheme } from "../contexts/ThemeContext"; +import MDEditor from "@uiw/react-md-editor"; +import AcceptanceCriteriaEditor from "./AcceptanceCriteriaEditor"; +import MermaidMarkdown from './MermaidMarkdown'; +import ChipInput from "./ChipInput"; +import DependencyInput from "./DependencyInput"; + +interface Props { + task?: Task; // Optional for create mode + isOpen: boolean; + onClose: () => void; + onSaved?: () => Promise<void> | void; // refresh callback + onSubmit?: (taskData: Partial<Task>) => Promise<void>; // For creating new tasks + onArchive?: () => void; // For archiving tasks + availableStatuses?: string[]; // Available statuses for new tasks + isDraftMode?: boolean; // Whether creating a draft +} + +type Mode = "preview" | "edit" | "create"; + +const SectionHeader: React.FC<{ title: string; right?: React.ReactNode }> = ({ title, right }) => ( + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 tracking-tight transition-colors duration-200"> + {title} + </h3> + {right ? <div className="ml-2 text-xs text-gray-500 dark:text-gray-400">{right}</div> : null} + </div> +); + +export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSaved, onSubmit, onArchive, availableStatuses, isDraftMode }) => { + const { theme } = useTheme(); + const isCreateMode = !task; + const isFromOtherBranch = Boolean(task?.branch); + const [mode, setMode] = useState<Mode>(isCreateMode ? "create" : "preview"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState<string | null>(null); + + // Title field for create mode + const [title, setTitle] = useState(task?.title || ""); + + // Editable fields (edit mode) + const [description, setDescription] = useState(task?.description || ""); + const [plan, setPlan] = useState(task?.implementationPlan || ""); + const [notes, setNotes] = useState(task?.implementationNotes || ""); + const [criteria, setCriteria] = useState<AcceptanceCriterion[]>(task?.acceptanceCriteriaItems || []); + + // Sidebar metadata (inline edit) + const [status, setStatus] = useState(task?.status || (isDraftMode ? "Draft" : (availableStatuses?.[0] || "To Do"))); + const [assignee, setAssignee] = useState<string[]>(task?.assignee || []); + const [labels, setLabels] = useState<string[]>(task?.labels || []); + const [priority, setPriority] = useState<string>(task?.priority || ""); + const [dependencies, setDependencies] = useState<string[]>(task?.dependencies || []); + const [availableTasks, setAvailableTasks] = useState<Task[]>([]); + + // Keep a baseline for dirty-check + const baseline = useMemo(() => ({ + title: task?.title || "", + description: task?.description || "", + plan: task?.implementationPlan || "", + notes: task?.implementationNotes || "", + criteria: JSON.stringify(task?.acceptanceCriteriaItems || []), + }), [task]); + + const isDirty = useMemo(() => { + return ( + title !== baseline.title || + description !== baseline.description || + plan !== baseline.plan || + notes !== baseline.notes || + JSON.stringify(criteria) !== baseline.criteria + ); + }, [title, description, plan, notes, criteria, baseline]); + + // Intercept Escape to cancel edit (not close modal) when in edit mode + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (mode === "edit" && (e.key === "Escape")) { + e.preventDefault(); + e.stopPropagation(); + handleCancelEdit(); + } + if (mode === "edit" && ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s")) { + e.preventDefault(); + e.stopPropagation(); + void handleSave(); + } + if (mode === "preview" && (e.key.toLowerCase() === "e") && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + setMode("edit"); + } + if (mode === "preview" && isDoneStatus && (e.key.toLowerCase() === "c") && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + void handleComplete(); + } + }; + window.addEventListener("keydown", onKey, { capture: true }); + return () => window.removeEventListener("keydown", onKey, { capture: true } as any); + }, [mode, description, plan, notes, criteria]); + + // Reset local state when task changes or modal opens + useEffect(() => { + setTitle(task?.title || ""); + setDescription(task?.description || ""); + setPlan(task?.implementationPlan || ""); + setNotes(task?.implementationNotes || ""); + setCriteria(task?.acceptanceCriteriaItems || []); + setStatus(task?.status || (isDraftMode ? "Draft" : (availableStatuses?.[0] || "To Do"))); + setAssignee(task?.assignee || []); + setLabels(task?.labels || []); + setPriority(task?.priority || ""); + setDependencies(task?.dependencies || []); + setMode(isCreateMode ? "create" : "preview"); + setError(null); + // Preload tasks for dependency picker + apiClient.fetchTasks().then(setAvailableTasks).catch(() => setAvailableTasks([])); + }, [task, isOpen, isCreateMode, isDraftMode, availableStatuses]); + + const handleCancelEdit = () => { + if (isDirty) { + const confirmDiscard = window.confirm("Discard unsaved changes?"); + if (!confirmDiscard) return; + } + if (isCreateMode) { + // In create mode, close the modal on cancel + onClose(); + } else { + setTitle(task?.title || ""); + setDescription(task?.description || ""); + setPlan(task?.implementationPlan || ""); + setNotes(task?.implementationNotes || ""); + setCriteria(task?.acceptanceCriteriaItems || []); + setMode("preview"); + } + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + + // Validation for create mode + if (isCreateMode && !title.trim()) { + setError("Title is required"); + setSaving(false); + return; + } + + try { + const taskData: Partial<Task> = { + title: title.trim(), + description, + implementationPlan: plan, + implementationNotes: notes, + acceptanceCriteriaItems: criteria, + status, + assignee, + labels, + priority: (priority === "" ? undefined : priority) as "high" | "medium" | "low" | undefined, + dependencies, + }; + + if (isCreateMode && onSubmit) { + // Create new task + await onSubmit(taskData); + // Only close if successful (no error thrown) + onClose(); + } else if (task) { + // Update existing task + await apiClient.updateTask(task.id, taskData); + setMode("preview"); + if (onSaved) await onSaved(); + } + } catch (err) { + // Extract and display the error message from API response + let errorMessage = 'Failed to save task'; + + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'object' && err !== null && 'error' in err) { + errorMessage = String((err as any).error); + } else if (typeof err === 'string') { + errorMessage = err; + } + + setError(errorMessage); + } finally { + setSaving(false); + } + }; + + const handleToggleCriterion = async (index: number, checked: boolean) => { + if (!task) return; // Can't toggle in create mode + if (isFromOtherBranch) return; // Can't toggle for cross-branch tasks + // Optimistic update + const next = (criteria || []).map((c) => (c.index === index ? { ...c, checked } : c)); + setCriteria(next); + try { + await apiClient.updateTask(task.id, { acceptanceCriteriaItems: next }); + if (onSaved) await onSaved(); + } catch (err) { + // rollback + setCriteria(criteria); + console.error("Failed to update criterion", err); + } + }; + + const handleInlineMetaUpdate = async (updates: Partial<Task>) => { + // Don't allow updates for cross-branch tasks + if (isFromOtherBranch) return; + + // Optimistic UI + if (updates.status !== undefined) setStatus(String(updates.status)); + if (updates.assignee !== undefined) setAssignee(updates.assignee as string[]); + if (updates.labels !== undefined) setLabels(updates.labels as string[]); + if (updates.priority !== undefined) setPriority(String(updates.priority)); + if (updates.dependencies !== undefined) setDependencies(updates.dependencies as string[]); + + // Only update server if editing existing task + if (task) { + try { + await apiClient.updateTask(task.id, updates); + if (onSaved) await onSaved(); + } catch (err) { + console.error("Failed to update task metadata", err); + // No rollback for simplicity; caller can refresh + } + } + }; + + // labels handled via ChipInput; no textarea parsing + + const handleComplete = async () => { + if (!task) return; + if (!window.confirm("Complete this task? It will be moved to the completed archive.")) return; + try { + await apiClient.completeTask(task.id); + if (onSaved) await onSaved(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleArchive = async () => { + if (!task || !onArchive) return; + if (!window.confirm(`Are you sure you want to archive "${task.title}"? This will move the task to the archive folder.`)) return; + onArchive(); + onClose(); + }; + + const checkedCount = (criteria || []).filter((c) => c.checked).length; + const totalCount = (criteria || []).length; + const isDoneStatus = (status || "").toLowerCase().includes("done"); + + const displayId = useMemo(() => task?.id?.replace(/^task-/i, "TASK-") || "", [task?.id]); + + return ( + <Modal + isOpen={isOpen} + onClose={() => { + // When in edit mode, confirm closing if dirty + if (mode === "edit" && isDirty) { + if (!window.confirm("Discard unsaved changes and close?")) return; + } + onClose(); + }} + title={isCreateMode ? (isDraftMode ? "Create New Draft" : "Create New Task") : `${displayId} β€” ${task.title}`} + maxWidthClass="max-w-5xl" + disableEscapeClose={mode === "edit" || mode === "create"} + actions={ + <div className="flex items-center gap-2"> + {isDoneStatus && mode === "preview" && !isCreateMode && !isFromOtherBranch && ( + <button + onClick={handleComplete} + className="inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium text-white bg-emerald-600 dark:bg-emerald-700 hover:bg-emerald-700 dark:hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + title="Mark as completed (archive from board)" + > + Mark as completed + </button> + )} + {mode === "preview" && !isCreateMode && !isFromOtherBranch ? ( + <button + onClick={() => setMode("edit")} + className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + title="Edit" + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> + </svg> + Edit + </button> + ) : (mode === "edit" || mode === "create") ? ( + <div className="flex items-center gap-2"> + <button + onClick={handleCancelEdit} + className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + title="Cancel" + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + Cancel + </button> + <button + onClick={() => void handleSave()} + disabled={saving} + className="inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium text-white bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" + title="Save" + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> + </svg> + {saving ? "Saving…" : (isCreateMode ? "Create" : "Save")} + </button> + </div> + ) : null} + </div> + } + > + {error && ( + <div className="mb-3 text-sm text-red-600 dark:text-red-400">{error}</div> + )} + + {/* Cross-branch task indicator */} + {isFromOtherBranch && ( + <div className="mb-4 flex items-center gap-2 px-4 py-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded-lg text-amber-800 dark:text-amber-200"> + <svg className="w-5 h-5 flex-shrink-0 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> + </svg> + <div className="flex-1"> + <span className="font-medium">Read-only:</span> This task exists in the <span className="font-semibold">{task?.branch}</span> branch. Switch to that branch to edit it. + </div> + </div> + )} + + <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> + {/* Main content */} + <div className="md:col-span-2 space-y-6"> + {/* Title field for create mode */} + {isCreateMode && ( + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> + <SectionHeader title="Title" /> + <input + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Enter task title" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-200" + /> + </div> + )} + {/* Description */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> + <SectionHeader title="Description" /> + {mode === "preview" ? ( + description ? ( + <div className="prose prose-sm !max-w-none wmde-markdown" data-color-mode={theme}> + <MermaidMarkdown source={description} /> + </div> + ) : ( + <div className="text-sm text-gray-500 dark:text-gray-400">No description</div> + ) + ) : ( + <div className="border border-gray-200 dark:border-gray-700 rounded-md"> + <MDEditor + value={description} + onChange={(val) => setDescription(val || "")} + preview="edit" + height={320} + data-color-mode={theme} + /> + </div> + )} + </div> + + {/* Acceptance Criteria */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> + <SectionHeader + title={`Acceptance Criteria ${totalCount ? `(${checkedCount}/${totalCount})` : ""}`} + right={mode === "preview" ? ( + <span>Toggle to update</span> + ) : null} + /> + {mode === "preview" ? ( + <ul className="space-y-2"> + {(criteria || []).map((c) => ( + <li key={c.index} className="flex items-start gap-2 rounded-md px-2 py-1"> + <input + type="checkbox" + checked={c.checked} + onChange={(e) => void handleToggleCriterion(c.index, e.target.checked)} + className="mt-0.5 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + <div className="text-sm text-gray-800 dark:text-gray-100">{c.text}</div> + </li> + ))} + {totalCount === 0 && ( + <li className="text-sm text-gray-500 dark:text-gray-400">No acceptance criteria</li> + )} + </ul> + ) : ( + <AcceptanceCriteriaEditor criteria={criteria} onChange={setCriteria} /> + )} + </div> + + {/* Implementation Plan */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> + <SectionHeader title="Implementation Plan" /> + {mode === "preview" ? ( + plan ? ( + <div className="prose prose-sm !max-w-none wmde-markdown" data-color-mode={theme}> + <MermaidMarkdown source={plan} /> + </div> + ) : ( + <div className="text-sm text-gray-500 dark:text-gray-400">No plan</div> + ) + ) : ( + <div className="border border-gray-200 dark:border-gray-700 rounded-md"> + <MDEditor + value={plan} + onChange={(val) => setPlan(val || "")} + preview="edit" + height={280} + data-color-mode={theme} + /> + </div> + )} + </div> + + {/* Implementation Notes */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> + <SectionHeader title="Implementation Notes" /> + {mode === "preview" ? ( + notes ? ( + <div className="prose prose-sm !max-w-none wmde-markdown" data-color-mode={theme}> + <MermaidMarkdown source={notes} /> + </div> + ) : ( + <div className="text-sm text-gray-500 dark:text-gray-400">No notes</div> + ) + ) : ( + <div className="border border-gray-200 dark:border-gray-700 rounded-md"> + <MDEditor + value={notes} + onChange={(val) => setNotes(val || "")} + preview="edit" + height={280} + data-color-mode={theme} + /> + </div> + )} + </div> + </div> + + {/* Sidebar */} + <div className="md:col-span-1 space-y-4"> + {/* Dates */} + {task && ( + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 text-xs text-gray-600 dark:text-gray-300 space-y-1"> + <div><span className="font-semibold text-gray-800 dark:text-gray-100">Created:</span> <span className="text-gray-700 dark:text-gray-200">{task.createdDate}</span></div> + {task.updatedDate && ( + <div><span className="font-semibold text-gray-800 dark:text-gray-100">Updated:</span> <span className="text-gray-700 dark:text-gray-200">{task.updatedDate}</span></div> + )} + </div> + )} + {/* Status */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3"> + <SectionHeader title="Status" /> + <StatusSelect current={status} onChange={(val) => handleInlineMetaUpdate({ status: val })} disabled={isFromOtherBranch} /> + </div> + + {/* Assignee */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3"> + <SectionHeader title="Assignee" /> + <ChipInput + name="assignee" + label="" + value={assignee} + onChange={(value) => handleInlineMetaUpdate({ assignee: value })} + placeholder="Type name and press Enter" + disabled={isFromOtherBranch} + /> + </div> + + {/* Labels */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3"> + <SectionHeader title="Labels" /> + <ChipInput + name="labels" + label="" + value={labels} + onChange={(value) => handleInlineMetaUpdate({ labels: value })} + placeholder="Type label and press Enter or comma" + disabled={isFromOtherBranch} + /> + </div> + + {/* Priority */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3"> + <SectionHeader title="Priority" /> + <select + className={`w-full px-3 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 ${isFromOtherBranch ? 'opacity-60 cursor-not-allowed' : ''}`} + value={priority} + onChange={(e) => handleInlineMetaUpdate({ priority: e.target.value as any })} + disabled={isFromOtherBranch} + > + <option value="">No Priority</option> + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + </select> + </div> + + {/* Dependencies */} + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3"> + <SectionHeader title="Dependencies" /> + <DependencyInput + value={dependencies} + onChange={(value) => handleInlineMetaUpdate({ dependencies: value })} + availableTasks={availableTasks} + currentTaskId={task?.id} + label="" + disabled={isFromOtherBranch} + /> + </div> + + {/* Metadata (render only if content exists) */} + {task?.milestone ? ( + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 text-xs text-gray-500 dark:text-gray-400 space-y-1"> + <div>Milestone: {task.milestone}</div> + </div> + ) : null} + + {/* Archive button at bottom of sidebar */} + {task && onArchive && !isFromOtherBranch && ( + <div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3"> + <button + onClick={handleArchive} + className="w-full inline-flex items-center justify-center px-4 py-2 bg-red-500 dark:bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-600 dark:hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-red-400 dark:focus:ring-red-500 transition-colors duration-200 cursor-pointer" + > + <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /> + </svg> + Archive Task + </button> + </div> + )} + </div> + </div> + </Modal> + ); +}; + +const StatusSelect: React.FC<{ current: string; onChange: (v: string) => void; disabled?: boolean }> = ({ current, onChange, disabled }) => { + const [statuses, setStatuses] = useState<string[]>([]); + useEffect(() => { + apiClient.fetchStatuses().then(setStatuses).catch(() => setStatuses(["To Do", "In Progress", "Done"])); + }, []); + return ( + <select + className={`w-full px-3 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`} + value={current} + onChange={(e) => onChange(e.target.value)} + disabled={disabled} + > + {statuses.map((s) => ( + <option key={s} value={s}>{s}</option> + ))} + </select> + ); +}; + +const AutoResizeTextarea: React.FC<{ + value: string; + onChange: (v: string) => void; + onBlur?: () => void; + placeholder?: string; +}> = ({ value, onChange, onBlur, placeholder }) => { + const ref = React.useRef<HTMLTextAreaElement | null>(null); + useEffect(() => { + if (!ref.current) return; + const el = ref.current; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, [value]); + return ( + <textarea + ref={ref} + value={value} + onChange={(e) => onChange(e.target.value)} + onBlur={onBlur} + rows={1} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 resize-none" + placeholder={placeholder} + /> + ); +}; + +export default TaskDetailsModal; diff --git a/src/web/components/TaskList.tsx b/src/web/components/TaskList.tsx new file mode 100644 index 0000000..1a162f7 --- /dev/null +++ b/src/web/components/TaskList.tsx @@ -0,0 +1,405 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { apiClient } from "../lib/api"; +import type { + SearchPriorityFilter, + Task, + TaskSearchResult, +} from "../../types"; +import CleanupModal from "./CleanupModal"; +import { SuccessToast } from "./SuccessToast"; + +interface TaskListProps { + onEditTask: (task: Task) => void; + onNewTask: () => void; + tasks: Task[]; + availableStatuses: string[]; + onRefreshData?: () => Promise<void>; +} + +const PRIORITY_OPTIONS: Array<{ label: string; value: "" | SearchPriorityFilter }> = [ + { label: "All priorities", value: "" }, + { label: "High", value: "high" }, + { label: "Medium", value: "medium" }, + { label: "Low", value: "low" }, +]; + +function sortTasksByIdDescending(list: Task[]): Task[] { + return [...list].sort((a, b) => { + const idA = Number.parseInt(a.id.replace("task-", ""), 10); + const idB = Number.parseInt(b.id.replace("task-", ""), 10); + return idB - idA; + }); +} + +const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, availableStatuses, onRefreshData }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [searchValue, setSearchValue] = useState(() => searchParams.get("query") ?? ""); + const [statusFilter, setStatusFilter] = useState(() => searchParams.get("status") ?? ""); + const [priorityFilter, setPriorityFilter] = useState<"" | SearchPriorityFilter>( + () => (searchParams.get("priority") as SearchPriorityFilter | null) ?? "", + ); + const [displayTasks, setDisplayTasks] = useState<Task[]>(() => sortTasksByIdDescending(tasks)); + const [error, setError] = useState<string | null>(null); + const [showCleanupModal, setShowCleanupModal] = useState(false); + const [cleanupSuccessMessage, setCleanupSuccessMessage] = useState<string | null>(null); + + const sortedBaseTasks = useMemo(() => sortTasksByIdDescending(tasks), [tasks]); + const normalizedSearch = searchValue.trim(); + const hasActiveFilters = Boolean(normalizedSearch || statusFilter || priorityFilter); + const totalTasks = sortedBaseTasks.length; + + useEffect(() => { + const paramQuery = searchParams.get("query") ?? ""; + const paramStatus = searchParams.get("status") ?? ""; + const paramPriority = (searchParams.get("priority") as SearchPriorityFilter | null) ?? ""; + + if (paramQuery !== searchValue) { + setSearchValue(paramQuery); + } + if (paramStatus !== statusFilter) { + setStatusFilter(paramStatus); + } + if (paramPriority !== priorityFilter) { + setPriorityFilter(paramPriority); + } + }, [searchParams]); + + useEffect(() => { + if (!hasActiveFilters) { + setDisplayTasks(sortedBaseTasks); + setError(null); + } + }, [hasActiveFilters, sortedBaseTasks]); + + useEffect(() => { + if (!hasActiveFilters) { + return; + } + + let cancelled = false; + setError(null); + + const fetchFilteredTasks = async () => { + try { + const results = await apiClient.search({ + query: normalizedSearch || undefined, + types: ["task"], + status: statusFilter || undefined, + priority: (priorityFilter || undefined) as SearchPriorityFilter | undefined, + }); + if (cancelled) { + return; + } + const taskResults = results.filter((result): result is TaskSearchResult => result.type === "task"); + setDisplayTasks(sortTasksByIdDescending(taskResults.map((result) => result.task))); + } catch (err) { + console.error("Failed to apply task filters:", err); + if (!cancelled) { + setDisplayTasks([]); + setError("Unable to fetch tasks for the selected filters."); + } + } + }; + + fetchFilteredTasks(); + + return () => { + cancelled = true; + }; + }, [hasActiveFilters, normalizedSearch, priorityFilter, statusFilter, tasks]); + + const syncUrl = (nextQuery: string, nextStatus: string, nextPriority: "" | SearchPriorityFilter) => { + const params = new URLSearchParams(); + const trimmedQuery = nextQuery.trim(); + if (trimmedQuery) { + params.set("query", trimmedQuery); + } + if (nextStatus) { + params.set("status", nextStatus); + } + if (nextPriority) { + params.set("priority", nextPriority); + } + setSearchParams(params, { replace: true }); + }; + + const handleSearchChange = (value: string) => { + setSearchValue(value); + syncUrl(value, statusFilter, priorityFilter); + }; + + const handleStatusChange = (value: string) => { + setStatusFilter(value); + syncUrl(searchValue, value, priorityFilter); + }; + + const handlePriorityChange = (value: "" | SearchPriorityFilter) => { + setPriorityFilter(value); + syncUrl(searchValue, statusFilter, value); + }; + + const handleClearFilters = () => { + setSearchValue(""); + setStatusFilter(""); + setPriorityFilter(""); + syncUrl("", "", ""); + setDisplayTasks(sortedBaseTasks); + setError(null); + }; + + const handleCleanupSuccess = async (movedCount: number) => { + setShowCleanupModal(false); + setCleanupSuccessMessage(`Successfully moved ${movedCount} task${movedCount !== 1 ? 's' : ''} to completed folder`); + + // Refresh the data - existing effects will handle re-filtering automatically + if (onRefreshData) { + await onRefreshData(); + } + + // Auto-dismiss success message after 4 seconds + setTimeout(() => { + setCleanupSuccessMessage(null); + }, 4000); + }; + + const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "to do": + return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"; + case "in progress": + return "bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200"; + case "done": + return "bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"; + } + }; + + const getPriorityColor = (priority?: string) => { + switch (priority?.toLowerCase()) { + case "high": + return "bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200"; + case "medium": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-200"; + case "low": + return "bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"; + } + }; + + const currentCount = displayTasks.length; + + return ( + <div className="container mx-auto px-4 py-8 transition-colors duration-200"> + <div className="flex flex-col gap-4 mb-6"> + <div className="flex items-center justify-between"> + <h1 className="text-2xl font-bold text-gray-900 dark:text-white">All Tasks</h1> + <button + className="inline-flex items-center px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400 dark:focus:ring-offset-gray-900 transition-colors duration-200 cursor-pointer" + onClick={onNewTask} + > + + New Task + </button> + </div> + + <div className="flex flex-wrap items-center gap-3"> + <div className="relative flex-1 min-w-[220px]"> + <span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 dark:text-gray-500"> + <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + </span> + <input + type="text" + value={searchValue} + onChange={(event) => handleSearchChange(event.target.value)} + placeholder="Search tasks" + className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200" + /> + {searchValue && ( + <button + type="button" + onClick={() => handleSearchChange("")} + className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 cursor-pointer" + > + <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + )} + </div> + + <select + value={statusFilter} + onChange={(event) => handleStatusChange(event.target.value)} + className="min-w-[160px] py-2 px-3 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + > + <option value="">All statuses</option> + {availableStatuses.map((status) => ( + <option key={status} value={status}> + {status} + </option> + ))} + </select> + + <select + value={priorityFilter} + onChange={(event) => handlePriorityChange(event.target.value as "" | SearchPriorityFilter)} + className="min-w-[160px] py-2 px-3 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 transition-colors duration-200" + > + {PRIORITY_OPTIONS.map((option) => ( + <option key={option.value || "all"} value={option.value}> + {option.label} + </option> + ))} + </select> + + {statusFilter.toLowerCase() === 'done' && displayTasks.length > 0 && ( + <button + type="button" + onClick={() => setShowCleanupModal(true)} + className="py-2 px-3 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 cursor-pointer flex items-center gap-2" + title="Clean up old completed tasks" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> + </svg> + Clean Up + </button> + )} + + {hasActiveFilters && ( + <button + type="button" + onClick={handleClearFilters} + className="py-2 px-3 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 cursor-pointer" + > + Clear filters + </button> + )} + + <div className="ml-auto text-sm text-gray-600 dark:text-gray-300"> + Showing {currentCount} of {totalTasks} tasks + </div> + </div> + + {error && ( + <div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 px-3 py-2 text-sm text-red-700 dark:text-red-300"> + {error} + </div> + )} + </div> + + {displayTasks.length === 0 ? ( + <div className="text-center py-12"> + <svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> + </svg> + <h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white"> + {hasActiveFilters ? "No tasks match the current filters" : "No tasks"} + </h3> + <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> + {hasActiveFilters + ? "Try adjusting your search or clearing filters to see more tasks." + : "Get started by creating a new task."} + </p> + </div> + ) : ( + <div className="space-y-4"> + {displayTasks.map((task) => { + const isFromOtherBranch = Boolean(task.branch); + return ( + <div + key={task.id} + className={`bg-white dark:bg-gray-800 border rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200 cursor-pointer ${ + isFromOtherBranch + ? 'border-amber-300 dark:border-amber-700 opacity-75' + : 'border-gray-200 dark:border-gray-700' + }`} + onClick={() => onEditTask(task)} + > + {/* Cross-branch indicator banner */} + {isFromOtherBranch && ( + <div className="flex items-center gap-1.5 mb-3 px-2 py-1.5 -mx-1 -mt-1 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-700 rounded-t text-xs text-amber-700 dark:text-amber-300"> + <svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> + </svg> + <span> + Read-only: From <span className="font-semibold">{task.branch}</span> branch + </span> + </div> + )} + <div className="flex items-start justify-between"> + <div className="flex-1"> + <div className="flex items-center space-x-3 mb-2"> + <h3 className={`text-lg font-medium ${isFromOtherBranch ? 'text-gray-600 dark:text-gray-400' : 'text-gray-900 dark:text-white'}`}>{task.title}</h3> + <span className={`px-2 py-1 text-xs font-medium rounded-circle ${getStatusColor(task.status)}`}> + {task.status} + </span> + {task.priority && ( + <span className={`px-2 py-1 text-xs font-medium rounded-circle ${getPriorityColor(task.priority)}`}> + {task.priority} + </span> + )} + </div> + <div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400 mb-2"> + <span>{task.id}</span> + <span>Created: {new Date(task.createdDate).toLocaleDateString()}</span> + {task.updatedDate && ( + <span>Updated: {new Date(task.updatedDate).toLocaleDateString()}</span> + )} + </div> + {task.assignee && task.assignee.length > 0 && ( + <div className="flex items-center space-x-2 mb-2"> + <span className="text-sm text-gray-500 dark:text-gray-400">Assigned to:</span> + <div className="flex flex-wrap gap-1"> + {task.assignee.map((person) => ( + <span key={person} className="px-2 py-1 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200 rounded-circle"> + {person} + </span> + ))} + </div> + </div> + )} + {task.labels && task.labels.length > 0 && ( + <div className="flex flex-wrap gap-1"> + {task.labels.map((label) => ( + <span key={label} className="px-2 py-1 text-xs bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded-circle"> + {label} + </span> + ))} + </div> + )} + </div> + </div> + </div> + )})} + </div> + )} + + {/* Cleanup Modal */} + <CleanupModal + isOpen={showCleanupModal} + onClose={() => setShowCleanupModal(false)} + onSuccess={handleCleanupSuccess} + /> + + {/* Cleanup Success Toast */} + {cleanupSuccessMessage && ( + <SuccessToast + message={cleanupSuccessMessage} + onDismiss={() => setCleanupSuccessMessage(null)} + icon={ + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + } + /> + )} + </div> + ); +}; + +export default TaskList; diff --git a/src/web/components/ThemeToggle.tsx b/src/web/components/ThemeToggle.tsx new file mode 100644 index 0000000..f99ebce --- /dev/null +++ b/src/web/components/ThemeToggle.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useTheme } from '../contexts/ThemeContext'; + +const ThemeToggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + <button + onClick={toggleTheme} + className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-stone-500 focus:ring-offset-2 dark:focus:ring-stone-400 dark:focus:ring-offset-gray-900 cursor-pointer" + aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} + title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} + > + {theme === 'light' ? ( + // Moon icon - show when in light mode (to switch to dark) + <svg + className="w-5 h-5 text-gray-600 dark:text-gray-400" + fill="currentColor" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /> + </svg> + ) : ( + // Sun icon - show when in dark mode (to switch to light) + <svg + className="w-5 h-5 text-gray-600 dark:text-gray-400" + fill="currentColor" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" /> + </svg> + )} + </button> + ); +}; + +export default ThemeToggle; \ No newline at end of file diff --git a/src/web/contexts/HealthCheckContext.tsx b/src/web/contexts/HealthCheckContext.tsx new file mode 100644 index 0000000..1529af1 --- /dev/null +++ b/src/web/contexts/HealthCheckContext.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; +import { useHealthCheck } from '../hooks/useHealthCheck'; + +interface HealthCheckContextType { + isOnline: boolean; + wasDisconnected: boolean; + retry: () => void; +} + +const HealthCheckContext = createContext<HealthCheckContextType | undefined>(undefined); + +export function HealthCheckProvider({ children }: { children: ReactNode }) { + const healthCheck = useHealthCheck(); + + return ( + <HealthCheckContext.Provider value={healthCheck}> + {children} + </HealthCheckContext.Provider> + ); +} + +export function useHealthCheckContext() { + const context = useContext(HealthCheckContext); + if (!context) { + throw new Error('useHealthCheckContext must be used within HealthCheckProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/web/contexts/ThemeContext.tsx b/src/web/contexts/ThemeContext.tsx new file mode 100644 index 0000000..9ffdeaf --- /dev/null +++ b/src/web/contexts/ThemeContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: React.ReactNode; +} + +export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => { + const [theme, setTheme] = useState<Theme>(() => { + // Check localStorage for saved theme preference + const savedTheme = localStorage.getItem('backlog-theme') as Theme; + if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) { + return savedTheme; + } + + // Check system preference + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; + }); + + useEffect(() => { + // Apply theme to document root + const root = document.documentElement; + + if (theme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + // Save to localStorage + localStorage.setItem('backlog-theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prev => prev === 'light' ? 'dark' : 'light'); + }; + + return ( + <ThemeContext.Provider value={{ theme, toggleTheme }}> + {children} + </ThemeContext.Provider> + ); +}; \ No newline at end of file diff --git a/src/web/favicon.png b/src/web/favicon.png new file mode 100644 index 0000000..b141890 Binary files /dev/null and b/src/web/favicon.png differ diff --git a/src/web/hooks/useHealthCheck.tsx b/src/web/hooks/useHealthCheck.tsx new file mode 100644 index 0000000..95cf635 --- /dev/null +++ b/src/web/hooks/useHealthCheck.tsx @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const RECONNECT_DELAY = 5000; // 5 seconds + +export function useHealthCheck() { + const [isOnline, setIsOnline] = useState(true); + const [wasDisconnected, setWasDisconnected] = useState(false); + + const wsRef = useRef<WebSocket | null>(null); + const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); + const isMountedRef = useRef(true); + + const connectWebSocket = useCallback(() => { + if (!isMountedRef.current) { + return; // Don't connect if component is unmounted + } + + // Check if already connected or connecting + if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { + return; + } + + // Clean up any existing connection before creating a new one + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + try { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setIsOnline(true); + setWasDisconnected(false); + }; + + ws.onclose = () => { + setIsOnline(false); + setWasDisconnected(true); + // Attempt to reconnect after delay if still mounted + if (isMountedRef.current) { + reconnectTimeoutRef.current = setTimeout(connectWebSocket, RECONNECT_DELAY); + } + }; + + ws.onerror = () => { + setIsOnline(false); + setWasDisconnected(true); + }; + + } catch (error) { + console.error("[WebSocket Client] Failed to create WebSocket:", error); + setIsOnline(false); + setWasDisconnected(true); + } + }, []); + + // Manual retry function + const retry = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + connectWebSocket(); + }, [connectWebSocket]); + + // Set up WebSocket connection + useEffect(() => { + isMountedRef.current = true; + + // Add a small delay to avoid rapid connect/disconnect in StrictMode + const connectTimer = setTimeout(() => { + connectWebSocket(); + }, 100); + + return () => { + // Mark as unmounted + isMountedRef.current = false; + + // Clear the connect timer if it hasn't fired yet + clearTimeout(connectTimer); + + // Cleanup on unmount + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.close(); + wsRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + }, [connectWebSocket]); + + return { + isOnline, + wasDisconnected, + retry, + }; +} \ No newline at end of file diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..1986260 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <base href="/"> + <title>Backlog.md - Task Management + + + + + +
+ + + \ No newline at end of file diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts new file mode 100644 index 0000000..e773144 --- /dev/null +++ b/src/web/lib/api.ts @@ -0,0 +1,427 @@ +import type { TaskStatistics } from "../../core/statistics.ts"; +import type { + BacklogConfig, + Decision, + Document, + SearchPriorityFilter, + SearchResult, + SearchResultType, + Task, + TaskStatus, +} from "../../types/index.ts"; + +const API_BASE = "/api"; + +export interface ReorderTaskPayload { + taskId: string; + targetStatus: string; + orderedTaskIds: string[]; +} + +// Enhanced error types for better error handling +export class ApiError extends Error { + constructor( + message: string, + public status?: number, + public code?: string, + public data?: unknown, + ) { + super(message); + this.name = "ApiError"; + } + + static fromResponse(response: Response, data?: unknown): ApiError { + const message = `HTTP ${response.status}: ${response.statusText}`; + return new ApiError(message, response.status, response.statusText, data); + } +} + +export class NetworkError extends Error { + constructor(message = "Network request failed") { + super(message); + this.name = "NetworkError"; + } +} + +// Request configuration interface +interface RequestConfig { + retries?: number; + timeout?: number; + Headers?: Record; +} + +// Default configuration +const DEFAULT_CONFIG: RequestConfig = { + retries: 3, + timeout: 10000, +}; + +export class ApiClient { + private config: RequestConfig; + + constructor(config: RequestConfig = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + // Enhanced fetch with retry logic and better error handling + private async fetchWithRetry(url: string, options: RequestInit = {}): Promise { + const { retries = 3, timeout = 10000 } = this.config; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + // Add timeout to the request + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + let errorData: unknown = null; + try { + errorData = await response.json(); + } catch { + // Ignore JSON parse errors for error data + } + throw ApiError.fromResponse(response, errorData); + } + + return response; + } catch (error) { + lastError = error as Error; + + // Don't retry on client errors (4xx) or specific cases + if (error instanceof ApiError && error.status && error.status >= 400 && error.status < 500) { + throw error; + } + + // For network errors or server errors, retry with exponential backoff + if (attempt < retries) { + const delay = Math.min(1000 * 2 ** attempt, 10000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + // If we get here, all retries failed + if (lastError instanceof ApiError) { + throw lastError; + } + throw new NetworkError(`Request failed after ${retries + 1} attempts: ${lastError?.message}`); + } + + // Helper method for JSON responses + private async fetchJson(url: string, options: RequestInit = {}): Promise { + const response = await this.fetchWithRetry(url, options); + return response.json(); + } + async fetchTasks(options?: { + status?: string; + assignee?: string; + parent?: string; + priority?: SearchPriorityFilter; + crossBranch?: boolean; + }): Promise { + const params = new URLSearchParams(); + if (options?.status) params.append("status", options.status); + if (options?.assignee) params.append("assignee", options.assignee); + if (options?.parent) params.append("parent", options.parent); + if (options?.priority) params.append("priority", options.priority); + // Default to true for cross-branch loading to match TUI behavior + if (options?.crossBranch !== false) params.append("crossBranch", "true"); + + const url = `${API_BASE}/tasks${params.toString() ? `?${params.toString()}` : ""}`; + return this.fetchJson(url); + } + + async search( + options: { + query?: string; + types?: SearchResultType[]; + status?: string | string[]; + priority?: SearchPriorityFilter | SearchPriorityFilter[]; + limit?: number; + } = {}, + ): Promise { + const params = new URLSearchParams(); + if (options.query) { + params.set("query", options.query); + } + if (options.types && options.types.length > 0) { + for (const type of options.types) { + params.append("type", type); + } + } + if (options.status) { + const statuses = Array.isArray(options.status) ? options.status : [options.status]; + for (const status of statuses) { + params.append("status", status); + } + } + if (options.priority) { + const priorities = Array.isArray(options.priority) ? options.priority : [options.priority]; + for (const priority of priorities) { + params.append("priority", priority); + } + } + if (options.limit !== undefined) { + params.set("limit", String(options.limit)); + } + + const url = `${API_BASE}/search${params.toString() ? `?${params.toString()}` : ""}`; + return this.fetchJson(url); + } + + async fetchTask(id: string): Promise { + return this.fetchJson(`${API_BASE}/task/${id}`); + } + + async createTask(task: Omit): Promise { + return this.fetchJson(`${API_BASE}/tasks`, { + method: "POST", + body: JSON.stringify(task), + }); + } + + async updateTask(id: string, updates: Partial): Promise { + return this.fetchJson(`${API_BASE}/tasks/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + } + + async reorderTask(payload: ReorderTaskPayload): Promise<{ success: boolean; task: Task }> { + return this.fetchJson<{ success: boolean; task: Task }>(`${API_BASE}/tasks/reorder`, { + method: "POST", + body: JSON.stringify(payload), + }); + } + + async archiveTask(id: string): Promise { + await this.fetchWithRetry(`${API_BASE}/tasks/${id}`, { + method: "DELETE", + }); + } + + async completeTask(id: string): Promise { + await this.fetchWithRetry(`${API_BASE}/tasks/${id}/complete`, { + method: "POST", + }); + } + + async getCleanupPreview(age: number): Promise<{ + count: number; + tasks: Array<{ id: string; title: string; updatedDate?: string; createdDate: string }>; + }> { + return this.fetchJson<{ + count: number; + tasks: Array<{ id: string; title: string; updatedDate?: string; createdDate: string }>; + }>(`${API_BASE}/tasks/cleanup?age=${age}`); + } + + async executeCleanup( + age: number, + ): Promise<{ success: boolean; movedCount: number; totalCount: number; message: string; failedTasks?: string[] }> { + return this.fetchJson<{ + success: boolean; + movedCount: number; + totalCount: number; + message: string; + failedTasks?: string[]; + }>(`${API_BASE}/tasks/cleanup/execute`, { + method: "POST", + body: JSON.stringify({ age }), + }); + } + + async updateTaskStatus(id: string, status: TaskStatus): Promise { + return this.updateTask(id, { status }); + } + + async fetchStatuses(): Promise { + const response = await fetch(`${API_BASE}/statuses`); + if (!response.ok) { + throw new Error("Failed to fetch statuses"); + } + return response.json(); + } + + async fetchConfig(): Promise { + const response = await fetch(`${API_BASE}/config`); + if (!response.ok) { + throw new Error("Failed to fetch config"); + } + return response.json(); + } + + async updateConfig(config: BacklogConfig): Promise { + const response = await fetch(`${API_BASE}/config`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + }); + if (!response.ok) { + throw new Error("Failed to update config"); + } + return response.json(); + } + + async fetchDocs(): Promise { + const response = await fetch(`${API_BASE}/docs`); + if (!response.ok) { + throw new Error("Failed to fetch documentation"); + } + return response.json(); + } + + async fetchDoc(filename: string): Promise { + const response = await fetch(`${API_BASE}/docs/${encodeURIComponent(filename)}`); + if (!response.ok) { + throw new Error("Failed to fetch document"); + } + return response.json(); + } + + async fetchDocument(id: string): Promise { + const response = await fetch(`${API_BASE}/doc/${encodeURIComponent(id)}`); + if (!response.ok) { + throw new Error("Failed to fetch document"); + } + return response.json(); + } + + async updateDoc(filename: string, content: string, title?: string): Promise { + const payload: Record = { content }; + if (typeof title === "string") { + payload.title = title; + } + + const response = await fetch(`${API_BASE}/docs/${encodeURIComponent(filename)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error("Failed to update document"); + } + } + + async createDoc(filename: string, content: string): Promise<{ id: string }> { + const response = await fetch(`${API_BASE}/docs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ filename, content }), + }); + if (!response.ok) { + throw new Error("Failed to create document"); + } + return response.json(); + } + + async fetchDecisions(): Promise { + const response = await fetch(`${API_BASE}/decisions`); + if (!response.ok) { + throw new Error("Failed to fetch decisions"); + } + return response.json(); + } + + async fetchDecision(id: string): Promise { + const response = await fetch(`${API_BASE}/decisions/${encodeURIComponent(id)}`); + if (!response.ok) { + throw new Error("Failed to fetch decision"); + } + return response.json(); + } + + async fetchDecisionData(id: string): Promise { + const response = await fetch(`${API_BASE}/decision/${encodeURIComponent(id)}`); + if (!response.ok) { + throw new Error("Failed to fetch decision"); + } + return response.json(); + } + + async updateDecision(id: string, content: string): Promise { + const response = await fetch(`${API_BASE}/decisions/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { + "Content-Type": "text/plain", + }, + body: content, + }); + if (!response.ok) { + throw new Error("Failed to update decision"); + } + } + + async createDecision(title: string): Promise { + const response = await fetch(`${API_BASE}/decisions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ title }), + }); + if (!response.ok) { + throw new Error("Failed to create decision"); + } + return response.json(); + } + + async fetchStatistics(): Promise< + TaskStatistics & { statusCounts: Record; priorityCounts: Record } + > { + return this.fetchJson< + TaskStatistics & { statusCounts: Record; priorityCounts: Record } + >(`${API_BASE}/statistics`); + } + + async checkStatus(): Promise<{ initialized: boolean; projectPath: string }> { + return this.fetchJson<{ initialized: boolean; projectPath: string }>(`${API_BASE}/status`); + } + + async initializeProject(options: { + projectName: string; + integrationMode: "mcp" | "cli" | "none"; + mcpClients?: ("claude" | "codex" | "gemini" | "guide")[]; + agentInstructions?: ("CLAUDE.md" | "AGENTS.md" | "GEMINI.md" | ".github/copilot-instructions.md")[]; + installClaudeAgent?: boolean; + advancedConfig?: { + checkActiveBranches?: boolean; + remoteOperations?: boolean; + activeBranchDays?: number; + bypassGitHooks?: boolean; + autoCommit?: boolean; + zeroPaddedIds?: number; + defaultEditor?: string; + defaultPort?: number; + autoOpenBrowser?: boolean; + }; + }): Promise<{ success: boolean; projectName: string; mcpResults?: Record }> { + return this.fetchJson<{ success: boolean; projectName: string; mcpResults?: Record }>( + `${API_BASE}/init`, + { + method: "POST", + body: JSON.stringify(options), + }, + ); + } +} + +export const apiClient = new ApiClient(); diff --git a/src/web/main.tsx b/src/web/main.tsx new file mode 100644 index 0000000..05c5311 --- /dev/null +++ b/src/web/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { HealthCheckProvider } from './contexts/HealthCheckContext'; + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + + + +); diff --git a/src/web/styles/source.css b/src/web/styles/source.css new file mode 100644 index 0000000..828f20b --- /dev/null +++ b/src/web/styles/source.css @@ -0,0 +1,194 @@ +@import "tailwindcss"; +@import '@uiw/react-md-editor/markdown-editor.css'; +@import '@uiw/react-markdown-preview/markdown.css'; + +@source not inline("{rounded-full}"); + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --radius-circle: 9999px; +} + +@layer utilities { + .rounded-circle { + border-radius: var(--radius-circle); + } + + /* Custom Blue-Dark Theme for MDEditor - moved to utilities for higher precedence */ + .wmde-markdown-var[data-color-mode*="dark"], + .wmde-markdown[data-color-mode*="dark"], + [data-color-mode*="dark"] .wmde-markdown, + [data-color-mode*="dark"] .wmde-markdown-var, + div[data-color-mode*="dark"] .wmde-markdown-var, + div[data-color-mode*="dark"] .wmde-markdown { + /* Base canvas and text colors */ + --color-canvas-default: #1e293b !important; + /* slate-800 - main background */ + --color-canvas-subtle: #334155 !important; + /* slate-700 - subtle background */ + --color-fg-default: #f1f5f9 !important; + /* slate-100 - primary text */ + --color-fg-muted: #94a3b8 !important; + /* slate-400 - muted text */ + --color-fg-subtle: #64748b !important; + /* slate-500 - subtle text */ + + /* Borders */ + --color-border-default: #475569 !important; + /* slate-600 - borders */ + --color-border-muted: #334155 !important; + /* slate-700 - muted borders */ + + /* Syntax highlighting with blue theme */ + --color-prettylights-syntax-comment: #94a3b8 !important; + --color-prettylights-syntax-constant: #60a5fa !important; + --color-prettylights-syntax-string: #10b981 !important; + --color-prettylights-syntax-keyword: #f59e0b !important; + --color-prettylights-syntax-entity: #a855f7 !important; + --color-prettylights-syntax-variable: #06b6d4 !important; + + /* Accent colors */ + --color-accent-fg: #3b82f6 !important; + --color-accent-emphasis: #2563eb !important; + + /* Additional variables for preview mode */ + --color-prettylights-syntax-markup-heading: #3b82f6 !important; + --color-prettylights-syntax-markup-bold: #f1f5f9 !important; + --color-prettylights-syntax-markup-italic: #cbd5e1 !important; + --color-prettylights-syntax-markup-list: #fbbf24 !important; + --color-prettylights-syntax-markup-code-bg: #334155 !important; + } +} + +@layer components { + /* Custom styles that can't be done with Tailwind utilities */ + /* Line clamp utilities for text truncation */ + .line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Hide scrollbar for chip input */ + .scrollbar-hide { + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + /* Chrome, Safari and Opera */ + } + + /* Health check notification animations */ + @keyframes slide-in-right { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } + } + + @keyframes slide-in-down { + from { + transform: translateY(-100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } + } + + .animate-slide-in-right { + animation: slide-in-right 0.3s ease-out; + } + + .animate-slide-in-down { + animation: slide-in-down 0.3s ease-out; + } + + /* Avoid forcing internal editor heights; use component props instead */ + + /* Fix for long content in markdown preview - scope to preview containers only */ + .wmde-markdown pre, + .wmde-markdown .wmde-markdown pre { + white-space: pre-wrap !important; + word-break: break-word !important; + overflow-wrap: break-word !important; + overflow-x: auto !important; + max-width: 100% !important; + } + + /* Ensure code blocks don't overflow in preview */ + .wmde-markdown pre code, + .wmde-markdown code.code-highlight { + white-space: pre-wrap !important; + word-break: break-word !important; + overflow-wrap: break-word !important; + display: block !important; + max-width: 100% !important; + } + + /* Ensure the markdown container itself doesn't overflow */ + .wmde-markdown, + .wmde-markdown-color { + max-width: 100% !important; + overflow-x: hidden !important; + } + + /* Restore list styles inside markdown preview (Tailwind preflight resets list-style) */ + .wmde-markdown ul { + list-style: disc !important; + padding-left: 1.5rem !important; + margin: 0 0 1rem !important; + } + .wmde-markdown ol { + list-style: decimal !important; + padding-left: 1.5rem !important; + margin: 0 0 1rem !important; + } + .wmde-markdown li { + margin: 0.25rem 0 !important; + } + .wmde-markdown ul ul { + list-style: circle !important; + } + .wmde-markdown ul ul ul { + list-style: square !important; + } + + /* Fix for inline code that might be too long */ + .wmde-markdown code { + word-break: break-word !important; + overflow-wrap: break-word !important; + } + + /* Ensure code lines wrap properly */ + .wmde-markdown .code-line { + white-space: pre-wrap !important; + word-break: break-word !important; + overflow-wrap: break-word !important; + } + + /* Prevent resizing of the editor's textarea to avoid overlay misalignment */ + .w-md-editor textarea { + resize: none !important; + } +} diff --git a/src/web/styles/style.css b/src/web/styles/style.css new file mode 100644 index 0000000..466dc95 --- /dev/null +++ b/src/web/styles/style.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-400:oklch(75% .183 55.934);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-900:oklch(40.8% .123 38.172);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-stone-50:oklch(98.5% .001 106.423);--color-stone-100:oklch(97% .001 106.424);--color-stone-200:oklch(92.3% .003 48.717);--color-stone-300:oklch(86.9% .005 56.366);--color-stone-400:oklch(70.9% .01 56.259);--color-stone-500:oklch(55.3% .013 58.071);--color-stone-600:oklch(44.4% .011 73.639);--color-stone-700:oklch(37.4% .01 67.558);--color-stone-800:oklch(26.8% .007 34.298);--color-stone-900:oklch(21.6% .006 56.043);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-circle:9999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.scrollbar-hide{-ms-overflow-style:none;scrollbar-width:none}.scrollbar-hide::-webkit-scrollbar{display:none}@keyframes slide-in-right{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes slide-in-down{0%{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:translateY(0)}}.animate-slide-in-right{animation:.3s ease-out slide-in-right}.animate-slide-in-down{animation:.3s ease-out slide-in-down}.wmde-markdown pre,.wmde-markdown .wmde-markdown pre{white-space:pre-wrap!important;word-break:break-word!important;overflow-wrap:break-word!important;max-width:100%!important;overflow-x:auto!important}.wmde-markdown pre code,.wmde-markdown code.code-highlight{white-space:pre-wrap!important;word-break:break-word!important;overflow-wrap:break-word!important;max-width:100%!important;display:block!important}.wmde-markdown,.wmde-markdown-color{max-width:100%!important;overflow-x:hidden!important}.wmde-markdown ul{margin:0 0 1rem!important;padding-left:1.5rem!important;list-style:outside!important}.wmde-markdown ol{margin:0 0 1rem!important;padding-left:1.5rem!important;list-style:decimal!important}.wmde-markdown li{margin:.25rem 0!important}.wmde-markdown ul ul{list-style:circle!important}.wmde-markdown ul ul ul{list-style:square!important}.wmde-markdown code{word-break:break-word!important;overflow-wrap:break-word!important}.wmde-markdown .code-line{white-space:pre-wrap!important;word-break:break-word!important;overflow-wrap:break-word!important}.w-md-editor textarea{resize:none!important}}@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.-top-12{top:calc(var(--spacing)*-12)}.top-0{top:calc(var(--spacing)*0)}.top-1\/2{top:50%}.top-4{top:calc(var(--spacing)*4)}.-right-3{right:calc(var(--spacing)*-3)}.right-0{right:calc(var(--spacing)*0)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.isolate{isolation:isolate}.z-10{z-index:10}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing)*0)}.m-1{margin:calc(var(--spacing)*1)}.m-2{margin:calc(var(--spacing)*2)}.m-3{margin:calc(var(--spacing)*3)}.m-4{margin:calc(var(--spacing)*4)}.m-5{margin:calc(var(--spacing)*5)}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-1{margin-inline:calc(var(--spacing)*1)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing)*2)}.-mt-1{margin-top:calc(var(--spacing)*-1)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-3{margin-right:calc(var(--spacing)*3)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.-ml-1{margin-left:calc(var(--spacing)*-1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-6{margin-left:calc(var(--spacing)*6)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-8{height:calc(var(--spacing)*8)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-18{height:calc(var(--spacing)*18)}.h-64{height:calc(var(--spacing)*64)}.h-full{height:100%}.h-screen{height:100vh}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[94vh\]{max-height:94vh}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-10{min-height:calc(var(--spacing)*10)}.min-h-96{min-height:calc(var(--spacing)*96)}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-11{width:calc(var(--spacing)*11)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-24{width:calc(var(--spacing)*24)}.w-32{width:calc(var(--spacing)*32)}.w-80{width:calc(var(--spacing)*80)}.w-full{width:100%}.w-max{width:max-content}.\!max-w-none{max-width:none!important}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[16rem\]{max-width:16rem}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-80{min-width:calc(var(--spacing)*80)}.min-w-\[2ch\]{min-width:2ch}.min-w-\[16rem\]{min-width:16rem}.min-w-\[160px\]{min-width:160px}.min-w-\[220px\]{min-width:220px}.min-w-fit{min-width:fit-content}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.-translate-x-1\/2{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-1\/2{--tw-translate-y:calc(1/2*100%);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-105{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-2{rotate:2deg}.rotate-45{rotate:45deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.grid-flow-col{grid-auto-flow:column}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*3)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-6>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*6)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-blue-500{border-color:var(--color-blue-500)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-green-400{border-color:var(--color-green-400)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-blue-600{border-top-color:var(--color-blue-600)}.border-l-gray-300{border-left-color:var(--color-gray-300)}.border-l-green-500{border-left-color:var(--color-green-500)}.border-l-red-500{border-left-color:var(--color-red-500)}.border-l-yellow-500{border-left-color:var(--color-yellow-500)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-stone-50{background-color:var(--color-stone-50)}.bg-stone-100{background-color:var(--color-stone-100)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-green-500{--tw-gradient-to:var(--color-green-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-0\.5{padding:calc(var(--spacing)*.5)}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-3{padding-right:calc(var(--spacing)*3)}.pr-8{padding-right:calc(var(--spacing)*8)}.pr-10{padding-right:calc(var(--spacing)*10)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-10{padding-left:calc(var(--spacing)*10)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-5{--tw-leading:calc(var(--spacing)*5);line-height:calc(var(--spacing)*5)}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.text-amber-300{color:var(--color-amber-300)}.text-amber-400{color:var(--color-amber-400)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-200{color:var(--color-green-200)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-orange-600{color:var(--color-orange-600)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-stone-500{color:var(--color-stone-500)}.text-stone-600{color:var(--color-stone-600)}.text-stone-700{color:var(--color-stone-700)}.text-stone-800{color:var(--color-stone-800)}.text-white{color:var(--color-white)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.text-yellow-800{color:var(--color-yellow-800)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.placeholder-gray-500::placeholder{color:var(--color-gray-500)}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.outline-none{--tw-outline-style:none;outline-style:none}.peer-checked\:bg-blue-500:is(:where(.peer):checked~*){background-color:var(--color-blue-500)}.peer-focus\:ring-4:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.peer-focus\:ring-blue-300:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-300)}.peer-focus\:outline-none:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing)*5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing)*5)}.after\:rounded-circle:after{content:var(--tw-content);border-radius:var(--radius-circle)}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.peer-checked\:after\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.focus-within\:border-transparent:focus-within{border-color:#0000}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-blue-500:focus-within{--tw-ring-color:var(--color-blue-500)}@media (hover:hover){.hover\:border-stone-500:hover{border-color:var(--color-stone-500)}.hover\:bg-blue-200:hover{background-color:var(--color-blue-200)}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-green-600:hover{background-color:var(--color-green-600)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-red-900:hover{color:var(--color-red-900)}.hover\:text-stone-700:hover{color:var(--color-stone-700)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-400:focus{--tw-ring-color:var(--color-blue-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-gray-500:focus{--tw-ring-color:var(--color-gray-500)}.focus\:ring-green-300:focus{--tw-ring-color:var(--color-green-300)}.focus\:ring-green-400:focus{--tw-ring-color:var(--color-green-400)}.focus\:ring-red-300:focus{--tw-ring-color:var(--color-red-300)}.focus\:ring-red-400:focus{--tw-ring-color:var(--color-red-400)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-stone-500:focus{--tw-ring-color:var(--color-stone-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-\[backdrop-filter\]\:bg-white\/75{background-color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.supports-\[backdrop-filter\]\:bg-white\/75{background-color:color-mix(in oklab,var(--color-white)75%,transparent)}}}@media (min-width:40rem){.sm\:max-w-\[20rem\]{max-width:20rem}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:col-span-1{grid-column:span 1/span 1}.md\:col-span-2{grid-column:span 2/span 2}.md\:max-w-\[24rem\]{max-width:24rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-gray-700:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-gray-700)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-green-500:where(.dark,.dark *){border-color:var(--color-green-500)}.dark\:border-green-600:where(.dark,.dark *){border-color:var(--color-green-600)}.dark\:border-red-400:where(.dark,.dark *){border-color:var(--color-red-400)}.dark\:border-red-700:where(.dark,.dark *){border-color:var(--color-red-700)}.dark\:border-red-800:where(.dark,.dark *){border-color:var(--color-red-800)}.dark\:border-t-blue-400:where(.dark,.dark *){border-top-color:var(--color-blue-400)}.dark\:border-l-gray-600:where(.dark,.dark *){border-left-color:var(--color-gray-600)}.dark\:border-l-green-400:where(.dark,.dark *){border-left-color:var(--color-green-400)}.dark\:border-l-red-400:where(.dark,.dark *){border-left-color:var(--color-red-400)}.dark\:border-l-yellow-400:where(.dark,.dark *){border-left-color:var(--color-yellow-400)}.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-amber-900)20%,transparent)}}.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-amber-900)30%,transparent)}}.dark\:bg-black\/60:where(.dark,.dark *){background-color:#0009}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/60:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-600\/20:where(.dark,.dark *){background-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-600\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-600)20%,transparent)}}.dark\:bg-blue-700:where(.dark,.dark *){background-color:var(--color-blue-700)}.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}}.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-900)50%,transparent)}}.dark\:bg-emerald-700:where(.dark,.dark *){background-color:var(--color-emerald-700)}.dark\:bg-gray-600:where(.dark,.dark *){background-color:var(--color-gray-600)}.dark\:bg-gray-700:where(.dark,.dark *){background-color:var(--color-gray-700)}.dark\:bg-gray-700\/50:where(.dark,.dark *){background-color:#36415380}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-700\/50:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-gray-700)50%,transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/95:where(.dark,.dark *){background-color:#1e2939f2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/95:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-gray-800)95%,transparent)}}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-green-600:where(.dark,.dark *){background-color:var(--color-green-600)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:#0d542b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-green-900)20%,transparent)}}.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.dark\:bg-green-900\/50:where(.dark,.dark *){background-color:#0d542b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-green-900)50%,transparent)}}.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-orange-900)30%,transparent)}}.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:#59168b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-purple-900)30%,transparent)}}.dark\:bg-red-600:where(.dark,.dark *){background-color:var(--color-red-600)}.dark\:bg-red-700:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-900)40%,transparent)}}.dark\:bg-red-900\/50:where(.dark,.dark *){background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-900)50%,transparent)}}.dark\:bg-stone-900:where(.dark,.dark *){background-color:var(--color-stone-900)}.dark\:bg-stone-900\/30:where(.dark,.dark *){background-color:#1c19174d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-stone-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-stone-900)30%,transparent)}}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-yellow-900)30%,transparent)}}.dark\:bg-yellow-900\/50:where(.dark,.dark *){background-color:#733e0a80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-yellow-900)50%,transparent)}}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:where(.dark,.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:where(.dark,.dark *){color:var(--color-blue-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-200:where(.dark,.dark *){color:var(--color-gray-200)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-orange-400:where(.dark,.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:where(.dark,.dark *){color:var(--color-purple-400)}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:text-stone-200:where(.dark,.dark *){color:var(--color-stone-200)}.dark\:text-stone-400:where(.dark,.dark *){color:var(--color-stone-400)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:text-yellow-200:where(.dark,.dark *){color:var(--color-yellow-200)}.dark\:text-yellow-400:where(.dark,.dark *){color:var(--color-yellow-400)}.dark\:placeholder-gray-400:where(.dark,.dark *)::placeholder{color:var(--color-gray-400)}.dark\:peer-focus\:ring-blue-800:where(.dark,.dark *):is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-800)}.dark\:focus-within\:ring-blue-400:where(.dark,.dark *):focus-within{--tw-ring-color:var(--color-blue-400)}@media (hover:hover){.dark\:hover\:border-stone-400:where(.dark,.dark *):hover{border-color:var(--color-stone-400)}.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-blue-800:where(.dark,.dark *):hover{background-color:var(--color-blue-800)}.dark\:hover\:bg-emerald-800:where(.dark,.dark *):hover{background-color:var(--color-emerald-800)}.dark\:hover\:bg-gray-600:where(.dark,.dark *):hover{background-color:var(--color-gray-600)}.dark\:hover\:bg-gray-600\/50:where(.dark,.dark *):hover{background-color:#4a556580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-gray-600\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-gray-600)50%,transparent)}}.dark\:hover\:bg-gray-700:where(.dark,.dark *):hover{background-color:var(--color-gray-700)}.dark\:hover\:bg-gray-700\/50:where(.dark,.dark *):hover{background-color:#36415380}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-gray-700\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-gray-700)50%,transparent)}}.dark\:hover\:bg-gray-800:where(.dark,.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-red-600:where(.dark,.dark *):hover{background-color:var(--color-red-600)}.dark\:hover\:bg-red-700:where(.dark,.dark *):hover{background-color:var(--color-red-700)}.dark\:hover\:bg-red-800:where(.dark,.dark *):hover{background-color:var(--color-red-800)}.dark\:hover\:text-gray-100:where(.dark,.dark *):hover{color:var(--color-gray-100)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}.dark\:hover\:text-stone-300:where(.dark,.dark *):hover{color:var(--color-stone-300)}.dark\:hover\:shadow-lg:where(.dark,.dark *):hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.dark\:focus\:ring-blue-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-400)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}.dark\:focus\:ring-emerald-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-emerald-400)}.dark\:focus\:ring-gray-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-gray-400)}.dark\:focus\:ring-green-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-green-400)}.dark\:focus\:ring-red-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-red-400)}.dark\:focus\:ring-red-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-red-500)}.dark\:focus\:ring-stone-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-stone-400)}.dark\:focus\:ring-offset-gray-800:where(.dark,.dark *):focus{--tw-ring-offset-color:var(--color-gray-800)}.dark\:focus\:ring-offset-gray-900:where(.dark,.dark *):focus{--tw-ring-offset-color:var(--color-gray-900)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-\[backdrop-filter\]\:dark\:bg-gray-800\/75:where(.dark,.dark *){background-color:#1e2939bf}@supports (color:color-mix(in lab, red, red)){.supports-\[backdrop-filter\]\:dark\:bg-gray-800\/75:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-gray-800)75%,transparent)}}}.rounded-circle{border-radius:var(--radius-circle)}.wmde-markdown-var[data-color-mode*=dark],.wmde-markdown[data-color-mode*=dark],[data-color-mode*=dark] .wmde-markdown,[data-color-mode*=dark] .wmde-markdown-var,div[data-color-mode*=dark] .wmde-markdown-var,div[data-color-mode*=dark] .wmde-markdown{--color-canvas-default:#1e293b!important;--color-canvas-subtle:#334155!important;--color-fg-default:#f1f5f9!important;--color-fg-muted:#94a3b8!important;--color-fg-subtle:#64748b!important;--color-border-default:#475569!important;--color-border-muted:#334155!important;--color-prettylights-syntax-comment:#94a3b8!important;--color-prettylights-syntax-constant:#60a5fa!important;--color-prettylights-syntax-string:#10b981!important;--color-prettylights-syntax-keyword:#f59e0b!important;--color-prettylights-syntax-entity:#a855f7!important;--color-prettylights-syntax-variable:#06b6d4!important;--color-accent-fg:#3b82f6!important;--color-accent-emphasis:#2563eb!important;--color-prettylights-syntax-markup-heading:#3b82f6!important;--color-prettylights-syntax-markup-bold:#f1f5f9!important;--color-prettylights-syntax-markup-italic:#cbd5e1!important;--color-prettylights-syntax-markup-list:#fbbf24!important;--color-prettylights-syntax-markup-code-bg:#334155!important}}.w-md-editor-bar{cursor:s-resize;z-index:3;-webkit-user-select:none;user-select:none;border-radius:0 0 3px;width:14px;height:10px;margin-top:-11px;margin-right:0;position:absolute;bottom:0;right:0}.w-md-editor-bar svg{margin:0 auto;display:block}.w-md-editor-area{border-radius:5px;overflow:auto}.w-md-editor-text{text-align:left;white-space:pre-wrap;word-break:keep-all;overflow-wrap:break-word;box-sizing:border-box;font-variant-ligatures:common-ligatures;min-height:100%;margin:0;padding:10px;position:relative;font-size:14px!important;line-height:18px!important}.w-md-editor-text-pre,.w-md-editor-text-input,.w-md-editor-text>.w-md-editor-text-pre{box-sizing:inherit;display:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-variant-ligatures:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;tab-size:inherit;text-indent:inherit;text-rendering:inherit;text-transform:inherit;white-space:inherit;overflow-wrap:inherit;word-break:inherit;word-break:normal;background:0 0;border:0;margin:0;padding:0;font-family:var(--md-editor-font-family)!important}.w-md-editor-text-pre{pointer-events:none;position:relative;background-color:#0000!important;margin:0!important}.w-md-editor-text-pre>code{font-family:var(--md-editor-font-family)!important;padding:0!important;font-size:14px!important;line-height:18px!important}.w-md-editor-text-input{resize:none;width:100%;height:100%;color:inherit;padding:inherit;-webkit-font-smoothing:antialiased;-webkit-text-fill-color:transparent;outline:0;position:absolute;top:0;left:0;overflow:hidden}.w-md-editor-text-input:empty{-webkit-text-fill-color:inherit!important}.w-md-editor-text-pre,.w-md-editor-text-input{word-wrap:pre;word-break:break-word;white-space:pre-wrap}@media (-ms-high-contrast:none),(-ms-high-contrast:active){.w-md-editor-text-input{color:#0000!important}.w-md-editor-text-input::selection{color:#0000!important;background-color:#accef7!important}}.w-md-editor-text-pre .punctuation{color:var(--color-prettylights-syntax-comment,#8b949e)!important}.w-md-editor-text-pre .token.url,.w-md-editor-text-pre .token.content{color:var(--color-prettylights-syntax-constant,#0550ae)!important}.w-md-editor-text-pre .token.title.important{color:var(--color-prettylights-syntax-markup-bold,#24292f)}.w-md-editor-text-pre .token.code-block .function{color:var(--color-prettylights-syntax-entity,#8250df)}.w-md-editor-text-pre .token.bold{font-weight:unset!important}.w-md-editor-text-pre .token.title{line-height:unset!important;font-size:unset!important;font-weight:unset!important}.w-md-editor-text-pre .token.code.keyword{color:var(--color-prettylights-syntax-constant,#0550ae)!important}.w-md-editor-text-pre .token.strike,.w-md-editor-text-pre .token.strike .content{color:var(--color-prettylights-syntax-markup-deleted-text,#82071e)!important}.w-md-editor-toolbar-child{box-shadow:0 0 0 1px var(--md-editor-box-shadow-color),0 0 0 var(--md-editor-box-shadow-color),0 1px 1px var(--md-editor-box-shadow-color);background-color:var(--md-editor-background-color);z-index:1;border-radius:3px;display:none;position:absolute}.w-md-editor-toolbar-child.active{display:block}.w-md-editor-toolbar-child .w-md-editor-toolbar{border-bottom:0;border-radius:3px;padding:3px}.w-md-editor-toolbar-child .w-md-editor-toolbar ul>li{display:block}.w-md-editor-toolbar-child .w-md-editor-toolbar ul>li button{width:-webkit-fill-available;height:initial;box-sizing:border-box;margin:0;padding:3px 4px 2px}.w-md-editor-toolbar{border-bottom:1px solid var(--md-editor-box-shadow-color);background-color:var(--md-editor-background-color);-webkit-user-select:none;user-select:none;border-radius:3px 3px 0 0;flex-wrap:wrap;justify-content:space-between;align-items:center;padding:3px;display:flex}.w-md-editor-toolbar.bottom{border-bottom:0;border-top:1px solid var(--md-editor-box-shadow-color);border-radius:0 0 3px 3px}.w-md-editor-toolbar ul,.w-md-editor-toolbar li{line-height:initial;margin:0;padding:0;list-style:none}.w-md-editor-toolbar li{font-size:14px;display:inline-block}.w-md-editor-toolbar li+li{margin:0}.w-md-editor-toolbar li>button{text-transform:none;cursor:pointer;white-space:nowrap;height:20px;color:var(--color-fg-default);background:0 0;border:none;border-radius:2px;outline:none;margin:0 1px;padding:4px;font-weight:400;line-height:14px;transition:all .3s;overflow:visible}.w-md-editor-toolbar li>button:hover,.w-md-editor-toolbar li>button:focus{background-color:var(--color-neutral-muted);color:var(--color-accent-fg)}.w-md-editor-toolbar li>button:active{background-color:var(--color-neutral-muted);color:var(--color-danger-fg)}.w-md-editor-toolbar li>button:disabled{color:var(--md-editor-box-shadow-color);cursor:not-allowed}.w-md-editor-toolbar li>button:disabled:hover{color:var(--md-editor-box-shadow-color);background-color:#0000}.w-md-editor-toolbar li.active>button{color:var(--color-accent-fg);background-color:var(--color-neutral-muted)}.w-md-editor-toolbar-divider{vertical-align:middle;background-color:var(--md-editor-box-shadow-color);width:1px;height:14px;margin:-3px 3px 0!important}.w-md-editor{text-align:left;color:var(--color-fg-default);--md-editor-font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;--md-editor-background-color:var(--color-canvas-default,#fff);--md-editor-box-shadow-color:var(--color-border-default,#d0d7de);box-shadow:0 0 0 1px var(--md-editor-box-shadow-color),0 0 0 var(--md-editor-box-shadow-color),0 1px 1px var(--md-editor-box-shadow-color);background-color:var(--md-editor-background-color);border-radius:3px;flex-direction:column;padding-bottom:1px;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;display:flex;position:relative}.w-md-editor.w-md-editor-rtl{text-align:right!important;direction:rtl!important}.w-md-editor.w-md-editor-rtl .w-md-editor-preview{box-shadow:inset -1px 0 0 0 var(--md-editor-box-shadow-color);left:0;right:unset!important;text-align:right!important}.w-md-editor.w-md-editor-rtl .w-md-editor-text{text-align:right!important}.w-md-editor-toolbar{height:fit-content}.w-md-editor-content{border-radius:0 0 3px;height:100%;position:relative;overflow:auto}.w-md-editor .copied{display:none!important}.w-md-editor-input{width:50%;height:100%}.w-md-editor-text-pre>code{word-break:break-word!important;white-space:pre-wrap!important}.w-md-editor-preview{box-sizing:border-box;width:50%;box-shadow:inset 1px 0 0 0 var(--md-editor-box-shadow-color);border-radius:0 0 5px;flex-direction:column;padding:10px 20px;display:flex;position:absolute;top:0;bottom:0;right:0;overflow:auto}.w-md-editor-preview .anchor{display:none}.w-md-editor-preview .contains-task-list li.task-list-item{list-style:none}.w-md-editor-show-preview .w-md-editor-input{background-color:var(--md-editor-background-color);width:0%;overflow:hidden}.w-md-editor-show-preview .w-md-editor-preview{width:100%;box-shadow:inset 0 0}.w-md-editor-show-edit .w-md-editor-input{width:100%}.w-md-editor-show-edit .w-md-editor-preview{width:0%;padding:0}.w-md-editor-fullscreen{z-index:99999;position:fixed;inset:0;overflow:hidden;height:100%!important}.w-md-editor-fullscreen .w-md-editor-content{height:100%}@media (prefers-color-scheme:dark){.wmde-markdown,.wmde-markdown-var{color-scheme:dark;--color-prettylights-syntax-comment:#8b949e;--color-prettylights-syntax-constant:#79c0ff;--color-prettylights-syntax-entity:#d2a8ff;--color-prettylights-syntax-storage-modifier-import:#c9d1d9;--color-prettylights-syntax-entity-tag:#7ee787;--color-prettylights-syntax-keyword:#ff7b72;--color-prettylights-syntax-string:#a5d6ff;--color-prettylights-syntax-variable:#ffa657;--color-prettylights-syntax-brackethighlighter-unmatched:#f85149;--color-prettylights-syntax-invalid-illegal-text:#f0f6fc;--color-prettylights-syntax-invalid-illegal-bg:#8e1519;--color-prettylights-syntax-carriage-return-text:#f0f6fc;--color-prettylights-syntax-carriage-return-bg:#b62324;--color-prettylights-syntax-string-regexp:#7ee787;--color-prettylights-syntax-markup-list:#f2cc60;--color-prettylights-syntax-markup-heading:#1f6feb;--color-prettylights-syntax-markup-italic:#c9d1d9;--color-prettylights-syntax-markup-bold:#c9d1d9;--color-prettylights-syntax-markup-deleted-text:#ffdcd7;--color-prettylights-syntax-markup-deleted-bg:#67060c;--color-prettylights-syntax-markup-inserted-text:#aff5b4;--color-prettylights-syntax-markup-inserted-bg:#033a16;--color-prettylights-syntax-markup-changed-text:#ffdfb6;--color-prettylights-syntax-markup-changed-bg:#5a1e02;--color-prettylights-syntax-markup-ignored-text:#c9d1d9;--color-prettylights-syntax-markup-ignored-bg:#1158c7;--color-prettylights-syntax-meta-diff-range:#d2a8ff;--color-prettylights-syntax-brackethighlighter-angle:#8b949e;--color-prettylights-syntax-sublimelinter-gutter-mark:#484f58;--color-prettylights-syntax-constant-other-reference-link:#a5d6ff;--color-fg-default:#c9d1d9;--color-fg-muted:#8b949e;--color-fg-subtle:#484f58;--color-canvas-default:#0d1117;--color-canvas-subtle:#161b22;--color-border-default:#30363d;--color-border-muted:#21262d;--color-neutral-muted:#6e768166;--color-accent-fg:#58a6ff;--color-accent-emphasis:#1f6feb;--color-attention-subtle:#bb800926;--color-danger-fg:#f85149;--color-danger-emphasis:#da3633;--color-attention-fg:#d29922;--color-attention-emphasis:#9e6a03;--color-done-fg:#a371f7;--color-done-emphasis:#8957e5;--color-success-fg:#3fb950;--color-success-emphasis:#238636;--color-copied-active-bg:#2e9b33}}@media (prefers-color-scheme:light){.wmde-markdown,.wmde-markdown-var{color-scheme:light;--color-prettylights-syntax-comment:#6e7781;--color-prettylights-syntax-constant:#0550ae;--color-prettylights-syntax-entity:#8250df;--color-prettylights-syntax-storage-modifier-import:#24292f;--color-prettylights-syntax-entity-tag:#116329;--color-prettylights-syntax-keyword:#cf222e;--color-prettylights-syntax-string:#0a3069;--color-prettylights-syntax-variable:#953800;--color-prettylights-syntax-brackethighlighter-unmatched:#82071e;--color-prettylights-syntax-invalid-illegal-text:#f6f8fa;--color-prettylights-syntax-invalid-illegal-bg:#82071e;--color-prettylights-syntax-carriage-return-text:#f6f8fa;--color-prettylights-syntax-carriage-return-bg:#cf222e;--color-prettylights-syntax-string-regexp:#116329;--color-prettylights-syntax-markup-list:#3b2300;--color-prettylights-syntax-markup-heading:#0550ae;--color-prettylights-syntax-markup-italic:#24292f;--color-prettylights-syntax-markup-bold:#24292f;--color-prettylights-syntax-markup-deleted-text:#82071e;--color-prettylights-syntax-markup-deleted-bg:#ffebe9;--color-prettylights-syntax-markup-inserted-text:#116329;--color-prettylights-syntax-markup-inserted-bg:#dafbe1;--color-prettylights-syntax-markup-changed-text:#953800;--color-prettylights-syntax-markup-changed-bg:#ffd8b5;--color-prettylights-syntax-markup-ignored-text:#eaeef2;--color-prettylights-syntax-markup-ignored-bg:#0550ae;--color-prettylights-syntax-meta-diff-range:#8250df;--color-prettylights-syntax-brackethighlighter-angle:#57606a;--color-prettylights-syntax-sublimelinter-gutter-mark:#8c959f;--color-prettylights-syntax-constant-other-reference-link:#0a3069;--color-fg-default:#24292f;--color-fg-muted:#57606a;--color-fg-subtle:#6e7781;--color-canvas-default:#fff;--color-canvas-subtle:#f6f8fa;--color-border-default:#d0d7de;--color-border-muted:#d8dee4;--color-neutral-muted:#afb8c133;--color-accent-fg:#0969da;--color-accent-emphasis:#0969da;--color-attention-subtle:#fff8c5;--color-danger-fg:#d1242f;--color-danger-emphasis:#cf222e;--color-attention-fg:#9a6700;--color-attention-emphasis:#9a6700;--color-done-fg:#8250df;--color-done-emphasis:#8250df;--color-success-fg:#1a7f37;--color-success-emphasis:#1f883d;--color-copied-active-bg:#2e9b33}}[data-color-mode*=dark] .wmde-markdown,[data-color-mode*=dark] .wmde-markdown-var,.wmde-markdown-var[data-color-mode*=dark],.wmde-markdown[data-color-mode*=dark],body[data-color-mode*=dark]{color-scheme:dark;--color-prettylights-syntax-comment:#8b949e;--color-prettylights-syntax-constant:#79c0ff;--color-prettylights-syntax-entity:#d2a8ff;--color-prettylights-syntax-storage-modifier-import:#c9d1d9;--color-prettylights-syntax-entity-tag:#7ee787;--color-prettylights-syntax-keyword:#ff7b72;--color-prettylights-syntax-string:#a5d6ff;--color-prettylights-syntax-variable:#ffa657;--color-prettylights-syntax-brackethighlighter-unmatched:#f85149;--color-prettylights-syntax-invalid-illegal-text:#f0f6fc;--color-prettylights-syntax-invalid-illegal-bg:#8e1519;--color-prettylights-syntax-carriage-return-text:#f0f6fc;--color-prettylights-syntax-carriage-return-bg:#b62324;--color-prettylights-syntax-string-regexp:#7ee787;--color-prettylights-syntax-markup-list:#f2cc60;--color-prettylights-syntax-markup-heading:#1f6feb;--color-prettylights-syntax-markup-italic:#c9d1d9;--color-prettylights-syntax-markup-bold:#c9d1d9;--color-prettylights-syntax-markup-deleted-text:#ffdcd7;--color-prettylights-syntax-markup-deleted-bg:#67060c;--color-prettylights-syntax-markup-inserted-text:#aff5b4;--color-prettylights-syntax-markup-inserted-bg:#033a16;--color-prettylights-syntax-markup-changed-text:#ffdfb6;--color-prettylights-syntax-markup-changed-bg:#5a1e02;--color-prettylights-syntax-markup-ignored-text:#c9d1d9;--color-prettylights-syntax-markup-ignored-bg:#1158c7;--color-prettylights-syntax-meta-diff-range:#d2a8ff;--color-prettylights-syntax-brackethighlighter-angle:#8b949e;--color-prettylights-syntax-sublimelinter-gutter-mark:#484f58;--color-prettylights-syntax-constant-other-reference-link:#a5d6ff;--color-fg-default:#c9d1d9;--color-fg-muted:#8b949e;--color-fg-subtle:#484f58;--color-canvas-default:#0d1117;--color-canvas-subtle:#161b22;--color-border-default:#30363d;--color-border-muted:#21262d;--color-neutral-muted:#6e768166;--color-accent-fg:#58a6ff;--color-accent-emphasis:#1f6feb;--color-attention-subtle:#bb800926;--color-danger-fg:#f85149}[data-color-mode*=light] .wmde-markdown,[data-color-mode*=light] .wmde-markdown-var,.wmde-markdown-var[data-color-mode*=light],.wmde-markdown[data-color-mode*=light],body[data-color-mode*=light]{color-scheme:light;--color-prettylights-syntax-comment:#6e7781;--color-prettylights-syntax-constant:#0550ae;--color-prettylights-syntax-entity:#8250df;--color-prettylights-syntax-storage-modifier-import:#24292f;--color-prettylights-syntax-entity-tag:#116329;--color-prettylights-syntax-keyword:#cf222e;--color-prettylights-syntax-string:#0a3069;--color-prettylights-syntax-variable:#953800;--color-prettylights-syntax-brackethighlighter-unmatched:#82071e;--color-prettylights-syntax-invalid-illegal-text:#f6f8fa;--color-prettylights-syntax-invalid-illegal-bg:#82071e;--color-prettylights-syntax-carriage-return-text:#f6f8fa;--color-prettylights-syntax-carriage-return-bg:#cf222e;--color-prettylights-syntax-string-regexp:#116329;--color-prettylights-syntax-markup-list:#3b2300;--color-prettylights-syntax-markup-heading:#0550ae;--color-prettylights-syntax-markup-italic:#24292f;--color-prettylights-syntax-markup-bold:#24292f;--color-prettylights-syntax-markup-deleted-text:#82071e;--color-prettylights-syntax-markup-deleted-bg:#ffebe9;--color-prettylights-syntax-markup-inserted-text:#116329;--color-prettylights-syntax-markup-inserted-bg:#dafbe1;--color-prettylights-syntax-markup-changed-text:#953800;--color-prettylights-syntax-markup-changed-bg:#ffd8b5;--color-prettylights-syntax-markup-ignored-text:#eaeef2;--color-prettylights-syntax-markup-ignored-bg:#0550ae;--color-prettylights-syntax-meta-diff-range:#8250df;--color-prettylights-syntax-brackethighlighter-angle:#57606a;--color-prettylights-syntax-sublimelinter-gutter-mark:#8c959f;--color-prettylights-syntax-constant-other-reference-link:#0a3069;--color-fg-default:#24292f;--color-fg-muted:#57606a;--color-fg-subtle:#6e7781;--color-canvas-default:#fff;--color-canvas-subtle:#f6f8fa;--color-border-default:#d0d7de;--color-border-muted:#d8dee4;--color-neutral-muted:#afb8c133;--color-accent-fg:#0969da;--color-accent-emphasis:#0969da;--color-attention-subtle:#fff8c5;--color-danger-fg:#cf222e}.wmde-markdown{-webkit-text-size-adjust:100%;word-wrap:break-word;color:var(--color-fg-default);background-color:var(--color-canvas-default);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Noto Sans,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;font-size:16px;line-height:1.5}.wmde-markdown details,.wmde-markdown figcaption,.wmde-markdown figure{display:block}.wmde-markdown summary{display:list-item}.wmde-markdown [hidden]{display:none!important}.wmde-markdown a{color:var(--color-accent-fg);background-color:#0000;text-decoration:none}.wmde-markdown a:active,.wmde-markdown a:hover{outline-width:0}.wmde-markdown abbr[title]{border-bottom:none;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.wmde-markdown b,.wmde-markdown strong{font-weight:600}.wmde-markdown dfn{font-style:italic}.wmde-markdown h1{border-bottom:1px solid var(--color-border-muted);margin:.67em 0;padding-bottom:.3em;font-size:2em;font-weight:600}.wmde-markdown mark{background-color:var(--color-attention-subtle);color:var(--color-text-primary)}.wmde-markdown small{font-size:90%}.wmde-markdown sub,.wmde-markdown sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}.wmde-markdown sub{bottom:-.25em}.wmde-markdown sup{top:-.5em}.wmde-markdown img{box-sizing:content-box;background-color:var(--color-canvas-default);border-style:none;max-width:100%;display:inline-block}.wmde-markdown code,.wmde-markdown kbd,.wmde-markdown pre,.wmde-markdown samp{font-family:monospace;font-size:1em}.wmde-markdown figure{margin:1em 40px}.wmde-markdown hr{box-sizing:content-box;border:0;border-bottom:1px solid var(--color-border-muted);background:0 0;background-color:var(--color-border-default);height:.25em;margin:24px 0;padding:0;overflow:hidden}.wmde-markdown input{font:inherit;font-family:inherit;font-size:inherit;line-height:inherit;margin:0;overflow:visible}.wmde-markdown [type=button],.wmde-markdown [type=reset],.wmde-markdown [type=submit]{-webkit-appearance:button}.wmde-markdown [type=button]::-moz-focus-inner{border-style:none;padding:0}.wmde-markdown [type=reset]::-moz-focus-inner{border-style:none;padding:0}.wmde-markdown [type=submit]::-moz-focus-inner{border-style:none;padding:0}:is(.wmde-markdown [type=button]:-moz-focusring,.wmde-markdown [type=reset]:-moz-focusring,.wmde-markdown [type=submit]:-moz-focusring){outline:1px dotted buttontext}.wmde-markdown [type=checkbox],.wmde-markdown [type=radio]{box-sizing:border-box;padding:0}.wmde-markdown [type=number]::-webkit-inner-spin-button{height:auto}.wmde-markdown [type=number]::-webkit-outer-spin-button{height:auto}.wmde-markdown [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.wmde-markdown [type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.wmde-markdown [type=search]::-webkit-search-decoration{-webkit-appearance:none}.wmde-markdown ::-webkit-input-placeholder{color:inherit;opacity:.54}.wmde-markdown ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.wmde-markdown a:hover{text-decoration:underline}.wmde-markdown hr:before{content:"";display:table}.wmde-markdown hr:after{clear:both;content:"";display:table}.wmde-markdown table{border-spacing:0;border-collapse:collapse;width:max-content;max-width:100%;display:block}.wmde-markdown td,.wmde-markdown th{padding:0}.wmde-markdown details summary{cursor:pointer}.wmde-markdown details:not([open])>:not(summary){display:none!important}.wmde-markdown kbd{color:var(--color-fg-default);vertical-align:middle;background-color:var(--color-canvas-subtle);border:solid 1px var(--color-neutral-muted);border-bottom-color:var(--color-neutral-muted);box-shadow:inset 0 -1px 0 var(--color-neutral-muted);border-radius:6px;padding:3px 5px;font:11px/10px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;display:inline-block}.wmde-markdown h1,.wmde-markdown h2,.wmde-markdown h3,.wmde-markdown h4,.wmde-markdown h5,.wmde-markdown h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}.wmde-markdown h2{border-bottom:1px solid var(--color-border-muted);padding-bottom:.3em;font-size:1.5em;font-weight:600}.wmde-markdown h3{font-size:1.25em;font-weight:600}.wmde-markdown h4{font-size:1em;font-weight:600}.wmde-markdown h5{font-size:.875em;font-weight:600}.wmde-markdown h6{color:var(--color-fg-muted);font-size:.85em;font-weight:600}.wmde-markdown p{margin-top:0;margin-bottom:10px}.wmde-markdown blockquote{color:var(--color-fg-muted);border-left:.25em solid var(--color-border-default);margin:0;padding:0 1em}.wmde-markdown ul,.wmde-markdown ol{margin-top:0;margin-bottom:0;padding-left:2em}.wmde-markdown ol ol,.wmde-markdown ul ol{list-style-type:lower-roman}.wmde-markdown ul ul ol,.wmde-markdown ul ol ol,.wmde-markdown ol ul ol,.wmde-markdown ol ol ol{list-style-type:lower-alpha}.wmde-markdown dd{margin-left:0}.wmde-markdown tt,.wmde-markdown code{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;font-size:12px}.wmde-markdown pre{word-wrap:normal;margin-top:0;margin-bottom:0;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;font-size:12px}.wmde-markdown .octicon{vertical-align:text-bottom;fill:currentColor;display:inline-block;overflow:visible!important}.wmde-markdown ::placeholder{color:var(--color-fg-subtle);opacity:1}.wmde-markdown input::-webkit-outer-spin-button{appearance:none;margin:0}.wmde-markdown input::-webkit-inner-spin-button{appearance:none;margin:0}.wmde-markdown [data-catalyst]{display:block}.wmde-markdown:before{content:"";display:table}.wmde-markdown:after{clear:both;content:"";display:table}.wmde-markdown>:first-child{margin-top:0!important}.wmde-markdown>:last-child{margin-bottom:0!important}.wmde-markdown a:not([href]){color:inherit;text-decoration:none}.wmde-markdown .absent{color:var(--color-danger-fg)}.wmde-markdown a.anchor{float:left;margin-left:-20px;padding-right:4px;line-height:1}.wmde-markdown .anchor:focus{outline:none}.wmde-markdown p,.wmde-markdown blockquote,.wmde-markdown ul,.wmde-markdown ol,.wmde-markdown dl,.wmde-markdown table,.wmde-markdown pre,.wmde-markdown details{margin-top:0;margin-bottom:16px}.wmde-markdown blockquote>:first-child{margin-top:0}.wmde-markdown blockquote>:last-child{margin-bottom:0}.wmde-markdown sup>a:before{content:"["}.wmde-markdown sup>a:after{content:"]"}.wmde-markdown h1 .octicon-link,.wmde-markdown h2 .octicon-link,.wmde-markdown h3 .octicon-link,.wmde-markdown h4 .octicon-link,.wmde-markdown h5 .octicon-link,.wmde-markdown h6 .octicon-link{color:var(--color-fg-default);vertical-align:middle;visibility:hidden}.wmde-markdown h1:hover .anchor,.wmde-markdown h2:hover .anchor,.wmde-markdown h3:hover .anchor,.wmde-markdown h4:hover .anchor,.wmde-markdown h5:hover .anchor,.wmde-markdown h6:hover .anchor{text-decoration:none}.wmde-markdown h1:hover .anchor .octicon-link,.wmde-markdown h2:hover .anchor .octicon-link,.wmde-markdown h3:hover .anchor .octicon-link,.wmde-markdown h4:hover .anchor .octicon-link,.wmde-markdown h5:hover .anchor .octicon-link,.wmde-markdown h6:hover .anchor .octicon-link{visibility:visible}.wmde-markdown h1 tt,.wmde-markdown h1 code,.wmde-markdown h2 tt,.wmde-markdown h2 code,.wmde-markdown h3 tt,.wmde-markdown h3 code,.wmde-markdown h4 tt,.wmde-markdown h4 code,.wmde-markdown h5 tt,.wmde-markdown h5 code,.wmde-markdown h6 tt,.wmde-markdown h6 code{font-size:inherit;padding:0 .2em}.wmde-markdown ul.no-list,.wmde-markdown ol.no-list{padding:0;list-style-type:none}.wmde-markdown ol[type="1"]{list-style-type:decimal}.wmde-markdown ol[type=a]{list-style-type:lower-alpha}.wmde-markdown ol[type=i]{list-style-type:lower-roman}.wmde-markdown div>ol:not([type]){list-style-type:decimal}.wmde-markdown ul ul,.wmde-markdown ul ol,.wmde-markdown ol ol,.wmde-markdown ol ul{margin-top:0;margin-bottom:0}.wmde-markdown li>p{margin-top:16px}.wmde-markdown li+li{margin-top:.25em}.wmde-markdown dl{padding:0}.wmde-markdown dl dt{margin-top:16px;padding:0;font-size:1em;font-style:italic;font-weight:600}.wmde-markdown dl dd{margin-bottom:16px;padding:0 16px}.wmde-markdown table th{font-weight:600}.wmde-markdown table th,.wmde-markdown table td{border:1px solid var(--color-border-default);padding:6px 13px}.wmde-markdown table tr{background-color:var(--color-canvas-default);border-top:1px solid var(--color-border-muted)}.wmde-markdown table tr:nth-child(2n){background-color:var(--color-canvas-subtle)}.wmde-markdown table img{background-color:#0000}.wmde-markdown img[align=right]{padding-left:20px}.wmde-markdown img[align=left]{padding-right:20px}.wmde-markdown .emoji{vertical-align:text-top;background-color:#0000;max-width:none}.wmde-markdown span.frame{display:block;overflow:hidden}.wmde-markdown span.frame>span{float:left;border:1px solid var(--color-border-default);width:auto;margin:13px 0 0;padding:7px;display:block;overflow:hidden}.wmde-markdown span.frame span img{float:left;display:block}.wmde-markdown span.frame span span{clear:both;color:var(--color-fg-default);padding:5px 0 0;display:block}.wmde-markdown span.align-center{clear:both;display:block;overflow:hidden}.wmde-markdown span.align-center>span{text-align:center;margin:13px auto 0;display:block;overflow:hidden}.wmde-markdown span.align-center span img{text-align:center;margin:0 auto}.wmde-markdown span.align-right{clear:both;display:block;overflow:hidden}.wmde-markdown span.align-right>span{text-align:right;margin:13px 0 0;display:block;overflow:hidden}.wmde-markdown span.align-right span img{text-align:right;margin:0}.wmde-markdown span.float-left{float:left;margin-right:13px;display:block;overflow:hidden}.wmde-markdown span.float-left span{margin:13px 0 0}.wmde-markdown span.float-right{float:right;margin-left:13px;display:block;overflow:hidden}.wmde-markdown span.float-right>span{text-align:right;margin:13px auto 0;display:block;overflow:hidden}.wmde-markdown code,.wmde-markdown tt{background-color:var(--color-neutral-muted);border-radius:6px;margin:0;padding:.2em .4em;font-size:85%}.wmde-markdown code br,.wmde-markdown tt br{display:none}.wmde-markdown del code{-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}.wmde-markdown pre code{font-size:100%}.wmde-markdown pre>code{word-break:normal;white-space:pre;background:0 0;border:0;margin:0;padding:0}.wmde-markdown pre{background-color:var(--color-canvas-subtle);border-radius:6px;font-size:85%;line-height:1.45}.wmde-markdown pre code,.wmde-markdown pre tt{max-width:auto;line-height:inherit;word-wrap:normal;background-color:#0000;border:0;margin:0;padding:0;display:inline;overflow:visible}.wmde-markdown pre>code{padding:16px;display:block;overflow:auto}.wmde-markdown pre>code::-webkit-scrollbar{background:0 0;width:8px;height:8px}.wmde-markdown pre>code::-webkit-scrollbar-thumb{background:var(--color-fg-muted);border-radius:10px}.wmde-markdown .csv-data td,.wmde-markdown .csv-data th{text-align:left;white-space:nowrap;padding:5px;font-size:12px;line-height:1;overflow:hidden}.wmde-markdown .csv-data .blob-num{text-align:right;background:var(--color-canvas-default);border:0;padding:10px 8px 9px}.wmde-markdown .csv-data tr{border-top:0}.wmde-markdown .csv-data th{background:var(--color-canvas-subtle);border-top:0;font-weight:600}.wmde-markdown .footnotes{color:var(--color-fg-muted);border-top:1px solid var(--color-border-default);font-size:12px}.wmde-markdown .footnotes ol{padding-left:16px}.wmde-markdown .footnotes li{position:relative}.wmde-markdown .footnotes li:target:before{pointer-events:none;content:"";border:2px solid var(--color-accent-emphasis);border-radius:6px;position:absolute;inset:-8px -8px -8px -24px}.wmde-markdown .footnotes li:target{color:var(--color-fg-default)}.wmde-markdown .footnotes .data-footnote-backref g-emoji{font-family:monospace}.wmde-markdown .task-list-item{list-style-type:none}.wmde-markdown .task-list-item label{font-weight:400}.wmde-markdown .task-list-item.enabled label{cursor:pointer}.wmde-markdown .task-list-item+.wmde-markdown .task-list-item{margin-top:3px}.wmde-markdown .task-list-item .handle{display:none}.wmde-markdown .task-list-item-checkbox,.wmde-markdown .contains-task-list input[type=checkbox]{vertical-align:middle;margin:0 .2em .25em -1.6em}.wmde-markdown .contains-task-list:dir(rtl) .task-list-item-checkbox{margin:0 -1.6em .25em .2em}.wmde-markdown .contains-task-list:dir(rtl) input[type=checkbox]{margin:0 -1.6em .25em .2em}.wmde-markdown ::-webkit-calendar-picker-indicator{filter:invert(50%)}.wmde-markdown pre{position:relative}.wmde-markdown pre .copied{visibility:hidden;cursor:pointer;color:var(--color-fg-default);background:var(--color-border-default);border-radius:5px;padding:6px;font-size:12px;transition:all .3s;display:flex;position:absolute;top:6px;right:6px}.wmde-markdown pre .copied .octicon-copy{display:block}.wmde-markdown pre .copied .octicon-check{display:none}.wmde-markdown pre:hover .copied{visibility:visible}.wmde-markdown pre:hover .copied:hover{background:var(--color-prettylights-syntax-entity-tag);color:var(--color-canvas-default)}.wmde-markdown pre:hover .copied:active,.wmde-markdown pre .copied.active{background:var(--color-copied-active-bg);color:var(--color-canvas-default)}.wmde-markdown pre .active .octicon-copy{display:none}.wmde-markdown pre .active .octicon-check{display:block}.wmde-markdown .markdown-alert{color:inherit;border-left:.25em solid var(--borderColor-default,var(--color-border-default));margin-bottom:16px;padding:.5rem 1em}.wmde-markdown .markdown-alert>:last-child{margin-bottom:0!important}.wmde-markdown .markdown-alert .markdown-alert-title{align-items:center;font-size:14px;font-weight:500;line-height:1;display:flex}.wmde-markdown .markdown-alert .markdown-alert-title svg.octicon{margin-right:var(--base-size-8,8px)!important}.wmde-markdown .markdown-alert.markdown-alert-note{border-left-color:var(--borderColor-accent-emphasis,var(--color-accent-emphasis))}.wmde-markdown .markdown-alert.markdown-alert-note .markdown-alert-title{color:var(--fgColor-accent,var(--color-accent-fg))}.wmde-markdown .markdown-alert.markdown-alert-tip{border-left-color:var(--borderColor-success-emphasis,var(--color-success-emphasis))}.wmde-markdown .markdown-alert.markdown-alert-tip .markdown-alert-title{color:var(--fgColor-success,var(--color-success-fg))}.wmde-markdown .markdown-alert.markdown-alert-important{border-left-color:var(--borderColor-done-emphasis,var(--color-done-emphasis))}.wmde-markdown .markdown-alert.markdown-alert-important .markdown-alert-title{color:var(--fgColor-done,var(--color-done-fg))}.wmde-markdown .markdown-alert.markdown-alert-warning{border-left-color:var(--borderColor-attention-emphasis,var(--color-attention-emphasis))}.wmde-markdown .markdown-alert.markdown-alert-warning .markdown-alert-title{color:var(--fgColor-attention,var(--color-attention-fg))}.wmde-markdown .markdown-alert.markdown-alert-caution{border-left-color:var(--borderColor-danger-emphasis,var(--color-danger-emphasis))}.wmde-markdown .markdown-alert.markdown-alert-caution .markdown-alert-title{color:var(--fgColor-danger,var(--color-danger-fg))}.wmde-markdown .highlight-line{background-color:var(--color-neutral-muted)}.wmde-markdown .code-line.line-number:before{text-align:right;width:1rem;color:var(--color-fg-subtle);content:attr(line);white-space:nowrap;margin-right:16px;display:inline-block}.wmde-markdown .token.comment,.wmde-markdown .token.prolog,.wmde-markdown .token.doctype,.wmde-markdown .token.cdata{color:var(--color-prettylights-syntax-comment)}.wmde-markdown .token.namespace{opacity:.7}.wmde-markdown .token.property,.wmde-markdown .token.tag,.wmde-markdown .token.selector,.wmde-markdown .token.constant,.wmde-markdown .token.symbol,.wmde-markdown .token.deleted{color:var(--color-prettylights-syntax-entity-tag)}.wmde-markdown .token.maybe-class-name{color:var(--color-prettylights-syntax-variable)}.wmde-markdown .token.property-access,.wmde-markdown .token.operator,.wmde-markdown .token.boolean,.wmde-markdown .token.number,.wmde-markdown .token.selector .token.class,.wmde-markdown .token.attr-name,.wmde-markdown .token.string,.wmde-markdown .token.char,.wmde-markdown .token.builtin{color:var(--color-prettylights-syntax-constant)}.wmde-markdown .token.deleted{color:var(--color-prettylights-syntax-markup-deleted-text)}.wmde-markdown .code-line .token.deleted{background-color:var(--color-prettylights-syntax-markup-deleted-bg)}.wmde-markdown .token.inserted{color:var(--color-prettylights-syntax-markup-inserted-text)}.wmde-markdown .code-line .token.inserted{background-color:var(--color-prettylights-syntax-markup-inserted-bg)}.wmde-markdown .token.variable{color:var(--color-prettylights-syntax-constant)}.wmde-markdown .token.entity,.wmde-markdown .token.url,.wmde-markdown .language-css .token.string,.wmde-markdown .style .token.string,.wmde-markdown .token.color,.wmde-markdown .token.atrule,.wmde-markdown .token.attr-value,.wmde-markdown .token.function,.wmde-markdown .token.class-name{color:var(--color-prettylights-syntax-string)}.wmde-markdown .token.rule,.wmde-markdown .token.regex,.wmde-markdown .token.important,.wmde-markdown .token.keyword{color:var(--color-prettylights-syntax-keyword)}.wmde-markdown .token.coord{color:var(--color-prettylights-syntax-meta-diff-range)}.wmde-markdown .token.important,.wmde-markdown .token.bold{font-weight:700}.wmde-markdown .token.italic{font-style:italic}.wmde-markdown .token.entity{cursor:help}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/web/utils/mermaid.ts b/src/web/utils/mermaid.ts new file mode 100644 index 0000000..6152cef --- /dev/null +++ b/src/web/utils/mermaid.ts @@ -0,0 +1,137 @@ +// Type definitions for Mermaid API +interface MermaidAPI { + initialize: (config: MermaidConfig) => void; + run?: (options?: MermaidRunOptions) => Promise; + render: (id: string, text: string) => Promise; +} + +interface MermaidConfig { + startOnLoad?: boolean; + securityLevel?: "strict" | "loose" | "antiscript" | "sandbox"; + theme?: "base" | "default" | "dark" | "forest" | "neutral" | "null"; + logLevel?: number; + [key: string]: unknown; +} + +interface MermaidRunOptions { + nodes?: HTMLElement[]; + querySelector?: string; + suppressErrors?: boolean; +} + +interface MermaidRenderResult { + svg: string; + bindFunctions?: (element: HTMLElement) => void; +} + +interface MermaidModule { + default: MermaidAPI; +} + +type MermaidGlobal = typeof globalThis & { + __MERMAID_MOCK__?: MermaidModule; +}; + +let mermaidModule: MermaidModule | null = null; +let initializationPromise: Promise | null = null; + +export async function ensureMermaid(): Promise { + const mock = (globalThis as MermaidGlobal).__MERMAID_MOCK__; + if (mock) { + // Reset cached initialization so each mock can configure itself. + initializationPromise = null; + return mock; + } + + if (mermaidModule) return mermaidModule; + + // Dynamic import so client bundles can tree-shake and server doesn't need it + mermaidModule = (await import("mermaid")) as unknown as MermaidModule; + return mermaidModule; +} + +async function initializeMermaid(mermaid: MermaidAPI): Promise { + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async () => { + // Initialize with secure settings + // Use 'strict' for production to prevent XSS attacks + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "default", + }); + })(); + + return initializationPromise; +} + +export async function renderMermaidIn(element: HTMLElement): Promise { + // Check for mermaid blocks before touching the heavy library so plain markdown stays fast. + const codeBlocks = Array.from(element.querySelectorAll("pre > code.language-mermaid")) as HTMLElement[]; + if (codeBlocks.length === 0) { + return; + } + + try { + const m = await ensureMermaid(); + await initializeMermaid(m.default); + + // Find mermaid code blocks and render each into a generated div + for (const codeEl of codeBlocks) { + const parent = codeEl.parentElement as HTMLElement; + if (!parent) continue; + const diagramText = codeEl.textContent || ""; + + // Create container for mermaid + const wrapper = document.createElement("div"); + wrapper.className = "mermaid"; + wrapper.textContent = diagramText; + + // Replace the code block's parent (pre) with our wrapper so it's in the DOM + parent.replaceWith(wrapper); + + // Ensure wrapper is attached to document before rendering + if (!document.body.contains(wrapper)) { + // try to append to the element as a last resort + element.appendChild(wrapper); + } + + try { + if (m?.default?.run) { + try { + await m.default.run({ nodes: [wrapper] }); + continue; + } catch { + // Continue to render fallback if run fails + } + } + + if (m?.default?.render) { + const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`; + try { + const result = await m.default.render(id, diagramText); + wrapper.innerHTML = result.svg; + + // Bind interactive functions if available (for click events, etc.) + if (result.bindFunctions) { + result.bindFunctions(wrapper); + } + continue; + } catch { + // Continue to next fallback if render fails + } + } + + // If none of the above worked, log warning + console.warn("mermaid: no compatible render method found, leaving raw code block"); + } catch (err) { + console.warn("mermaid render failed", err); + } + } + } catch (err) { + console.warn("Failed to load mermaid", err); + } +} diff --git a/src/web/utils/urlHelpers.ts b/src/web/utils/urlHelpers.ts new file mode 100644 index 0000000..1d9bb88 --- /dev/null +++ b/src/web/utils/urlHelpers.ts @@ -0,0 +1,33 @@ +/** + * Sanitizes a string to be URL-friendly + * - Converts to lowercase + * - Replaces spaces with hyphens + * - Removes special characters except hyphens and underscores + * - Removes multiple consecutive hyphens + * - Trims hyphens from start and end + */ +export function sanitizeUrlTitle(title: string): string { + return ( + title + .toLowerCase() + .trim() + // Replace spaces with hyphens + .replace(/\s+/g, "-") + // Remove special characters except hyphens and underscores + .replace(/[^a-z0-9\-_]/g, "") + // Replace multiple hyphens with single hyphen + .replace(/-+/g, "-") + // Remove leading and trailing hyphens + .replace(/^-+|-+$/g, "") + ); +} + +/** + * Creates a URL-friendly path for documentation or decision items + */ +export function createUrlPath(basePath: string, id: string, title: string): string { + const sanitizedTitle = sanitizeUrlTitle(title); + // Remove prefix from ID for cleaner URLs + const cleanId = id.replace(/^(doc-|decision-)/, ""); + return `${basePath}/${cleanId}/${sanitizedTitle}`; +} diff --git a/src/web/utils/version.ts b/src/web/utils/version.ts new file mode 100644 index 0000000..85af1b2 --- /dev/null +++ b/src/web/utils/version.ts @@ -0,0 +1,11 @@ +// Version utility for web UI +export async function getWebVersion(): Promise { + try { + const response = await fetch("/api/version"); + const data = await response.json(); + return data.version; + } catch { + // If API call fails, just return empty string - UI can decide what to show + return ""; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6f7e61c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "typeRoots": ["./src/types", "./node_modules/@types"], + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "exclude": [] +}