diff --git a/.husky/pre-commit b/.husky/pre-commit index 3395b2f1..8c500287 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -22,5 +22,9 @@ npx lint-staged # Type check the entire project (can't be limited to staged files) npm run typecheck -# Validate cluster templates (catches hardcoded model names, broken configs) -npm run validate:templates +# Validate cluster templates only when template files are staged (catches hardcoded model names, broken configs) +STAGED_TEMPLATES=$(git diff --cached --name-only --diff-filter=ACM -- 'cluster-templates/**/*.json' || true) +if [ -n "$STAGED_TEMPLATES" ]; then + CHANGED_ARG=$(echo "$STAGED_TEMPLATES" | tr '\n' ',' | sed 's/,$//') + node scripts/validate-templates.js --changed="$CHANGED_ARG" +fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee519550..51a3f1c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,7 +122,8 @@ zeroshot/ npm test ``` -Runs all tests in `tests/**/*.test.js` using Mocha. +Runs the fast test suite (unit + top-level tests) as defined in `package.json`. +Integration tests in `tests/integration/**/*.test.js` are excluded from `npm test`; run them with `npm run test:slow`. ### Specific Test File diff --git a/README.md b/README.md index 7d59751d..7abb10cb 100644 --- a/README.md +++ b/README.md @@ -1,554 +1,221 @@ -# zeroshot CLI +
-> **πŸŽ‰ New in v5.4:** Now supports **OpenCode** CLI! Use Claude, Codex, Gemini, or OpenCode as your AI provider. Also supports **GitHub, GitLab, Jira, and Azure DevOps** as issue backends. See [Providers](#providers) and [Multi-Platform Issue Support](#multi-platform-issue-support). + + + Zeroshot. Self-driving software engineering. Layer 01 Β· Verification, The Open Engine. + - -

- npm install -g @the-open-engine/zeroshot -

- -

- Demo -
- Demo (100x speed, 90-minute run, 5 iterations to approval) -

- -[![CI](https://github.com/the-open-engine/zeroshot/actions/workflows/ci.yml/badge.svg)](https://github.com/the-open-engine/zeroshot/actions/workflows/ci.yml) -[![npm version](https://img.shields.io/npm/v/@the-open-engine/zeroshot.svg)](https://www.npmjs.com/package/@the-open-engine/zeroshot) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Node 18+](https://img.shields.io/badge/node-18%2B-brightgreen.svg)](https://nodejs.org/) -![Platform: Linux | macOS](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-blue.svg) - - - -[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/PdZ3UEXB) - -Zeroshot is an open-source AI coding agent orchestration CLI that runs multi-agent workflows to autonomously implement, review, test, and verify code changes. +  -It runs a **planner**, an **implementer**, and independent **validators** in isolated environments, looping until changes are **verified** or **rejected** with actionable, reproducible failures. +Website +X Β· @OpenEngineCo +LinkedIn +Discord -Built for tasks where correctness matters more than speed. +[![npm](https://img.shields.io/npm/v/@the-open-engine/zeroshot?style=flat&labelColor=171411&color=171411)](https://www.npmjs.com/package/@the-open-engine/zeroshot) +[![CI](https://img.shields.io/github/actions/workflow/status/the-open-engine/zeroshot/ci.yml?style=flat&labelColor=171411&label=CI)](https://github.com/the-open-engine/zeroshot/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-171411?style=flat)](LICENSE) +[![node](https://img.shields.io/badge/node-%E2%89%A5%2018-171411?style=flat)](#install) +[![platforms](https://img.shields.io/badge/platforms-linux%20%C2%B7%20macos-171411?style=flat)](#install) +[![stars](https://img.shields.io/github/stars/the-open-engine/zeroshot?style=flat&labelColor=171411&color=171411)](https://github.com/the-open-engine/zeroshot) +[![Layer 01 Β· The Open Engine](https://img.shields.io/badge/Layer_01-The_Open_Engine-C2240C?style=flat&labelColor=171411)](#the-open-engine) -## How It Works +
-- Plan: translate a task into concrete acceptance criteria -- Implement: make changes in an isolated workspace (local, worktree, or Docker) -- Validate: run automated checks with independent validators -- Iterate: repeat until verified, or return actionable failures -- Resume: crash-safe state persisted for recovery +**The agent that wrote the code shouldn't be the one that says it works.** -## Quick Start +Zeroshot is an open-source, multi-agent orchestration engine for autonomous software engineering. It drives a coding agent you already run (Claude Code, OpenAI Codex, Gemini CLI, or OpenCode) through an **executor-verifier loop**: an agent writes the change, then an _independent_ verifier that never saw how it was made approves it, or hands back a reproducible failure. The loop runs until the change is verified. -```bash -zeroshot run 123 # GitHub issue number -zeroshot run feature.md # Markdown file -zeroshot run "Add dark mode" # Inline text -``` +## Install -Or describe a complex task inline: - -```bash -zeroshot run "Add optimistic locking with automatic retry: when updating a user, -retry with exponential backoff up to 3 times, merge non-conflicting field changes, -and surface conflicts with details. Handle the ABA problem where version goes A->B->A." -``` - -## Why Not Just Use a Single AI Agent? - -| Approach | Writes Code | Runs Tests | Blind Validation | Iterates Until Verified | -| -------------------------- | ----------- | ---------- | ---------------- | ----------------------- | -| Chat-based assistant | βœ… | ⚠️ | ❌ | ❌ | -| Single coding agent | βœ… | ⚠️ | ❌ | ⚠️ | -| **Zeroshot (multi-agent)** | βœ… | βœ… | βœ… | βœ… | - -## Use Cases - -- Autonomous AI code refactoring -- AI-powered pull request automation -- Automated bug fixing with validation -- Multi-agent code generation for software engineering -- Agentic coding workflows with blind validation - -## Who Is This For? - -- Senior engineers who care about correctness and reproducibility -- Teams automating PR workflows and code review gates -- Infra/platform teams standardizing agentic workflows -- Open-source maintainers working through issue backlogs -- AI power users who want verification, not vibes - -## Install and Requirements - -**Platforms**: Linux, macOS. Windows (native/WSL) is deferred while we harden reliability and multi-provider correctness. + ```bash npm install -g @the-open-engine/zeroshot ``` -**Requires**: Node 18+, at least one provider CLI (Claude Code, Codex, Gemini, Opencode). - -```bash -# Install one or more providers -npm i -g @anthropic-ai/claude-code -npm i -g @openai/codex -npm i -g @google/gemini-cli -# Opencode: see https://opencode.ai - -# Authenticate with the provider CLI -claude login # Claude -codex login # Codex -gemini auth login # Gemini -opencode auth login # Opencode - -# GitHub auth (for issue numbers) -gh auth login -``` - -## Providers - -Zeroshot shells out to provider CLIs. Pick a default and override per run: - -```bash -zeroshot providers -zeroshot providers set-default codex -zeroshot run 123 --provider gemini -``` - -See `docs/providers.md` for setup, model levels, and Docker mounts. -See `docs/provider-cli-helper.md` for the strict TypeScript provider helper, -the `zeroshot-agent-provider` JSON executable contract, and the boundary with -Orchestra. - -## Why Multiple Agents? - -Single-agent sessions degrade. Context gets buried under thousands of tokens. The model optimizes for "done" over "correct." - -Zeroshot fixes this with isolated agents that check each other's work. Validators can't lie about code they didn't write. Fail the check? Fix and retry until it actually works. - -## What Makes It Different - -- **Blind validation** - Validators never see the worker's context or code history -- **Repeatable workflows** - Task complexity determines agent count and model selection -- **Accept/reject loop** - Rejections include actionable findings, not vague complaints -- **Crash recovery** - All state persisted to SQLite; resume anytime -- **Isolation modes** - None, git worktree, or Docker container -- **Cost control** - Model ceilings prevent runaway API spend - -## Required Handoff Quality Gates - -Zeroshot owns a tool-neutral handoff contract for `--pr` and `--ship` flows. It does not know which tool produced a gate. Repos configure required gates, validators publish matching `qualityGates` evidence, and the git-pusher trigger refuses to wake until every configured gate has fresh passing evidence after `IMPLEMENTATION_READY`. - -Gate config can come from run options or repo settings: - -```json -{ - "ship": { - "requiredQualityGates": [ - { - "id": "repo-quality", - "scope": "repo", - "description": "Repository quality gate", - "command": "repo-quality --changed --json" - } - ] - } -} -``` - -The `id` and optional `scope` are generic. A repo may bind `repo-quality` to any local quality command, a CI status command, or another quality command outside Zeroshot. Validators receive the configured gate list and must publish entries with `status`, `completedAt` or `timestamp`, and `evidence.command`, `evidence.exitCode`, and string `evidence.output`. Failing or unavailable commands mean `approved: false` with gate status `FAIL` or `UNAVAILABLE`. +Requires **Node β‰₯ 18** and at least one provider CLI (Claude Code, Codex, Gemini, or OpenCode). Linux and macOS today; Windows is deferred. -The pusher fails closed before commit, push, PR creation, or merge when a configured gate is missing, failing, unavailable, stale, older than `IMPLEMENTATION_READY`, or lacks usable evidence. If no `requiredQualityGates` are configured, Zeroshot preserves its existing validator consensus behavior. - -## Cmdproof Command Reuse - -Clusters can make expensive exact commands reusable across workers and validators with `cmdproof`. Zeroshot does not intercept arbitrary shell commands; it gives agents a cluster-scoped helper for configured command proofs. +
+ Zeroshot resolving an issue through the executor-verifier loop +
+ One run, unattended. 100Γ— speed Β· 90-minute run Β· 5 iterations to approval. +
-Issue bodies can opt in per run: +## How it works -````markdown -```zeroshot-command-proofs -[ - { - "id": "repo-ci", - "profile": "ci-equivalent", - "scope": "repo", - "description": "Repository local CI-equivalent gate", - "command": "bash ./scripts/ci/run-local-ci-equivalent.sh" - } -] -``` -```` - -Repos can also configure the same commands in `.zeroshot/settings.json`: - -```json -{ - "ship": { - "commandProofs": [ - { - "id": "repo-ci", - "profile": "ci-equivalent", - "scope": "repo", - "description": "Repository local CI-equivalent gate", - "command": "bash ./scripts/ci/run-local-ci-equivalent.sh" - } - ] - } -} -``` +Zeroshot separates the agent that **writes** the code from the agent that **judges** it. -Agents receive instructions to use: +A conductor sizes the workflow to the task. An executor (an AI coding agent) implements the change in an isolated workspace (git worktree or Docker). Then an **independent verifier** inspects the result without ever seeing the executor's context or history, so it cannot approve its own reasoning. The verifier returns `APPROVED`, or `REJECTED` with an actionable, reproducible failure, and the loop repeats until the change is verified or hands back a concrete reason it isn't. Every step is written to a crash-safe SQLite ledger, so a run survives a reboot and resumes where it stopped. -```bash -zeroshot cmdproof check repo-ci +```text +task --> plan --> implement --> verify --> APPROVED --> done + ^ | + +- REJECTED -+ (reproducible failure) ``` -The helper uses a cluster-local cache and trusted-agent key under `~/.zeroshot/cmdproof//`, runs `cmdproof verify` first, and falls back to `cmdproof prove --fallback run` on misses. In `--ship` flows, configured command proofs are also required handoff quality gates. - -## When to Use Zeroshot - -Zeroshot performs best when tasks have clear acceptance criteria. +Bring your own provider and your own backend. Zeroshot orchestrates the agents that write your code; it doesn't store your keys or replace your models. -| Scenario | Use | Why | -| ----------------------------------------------- | --- | ------------------------- | -| Add rate limiting (sliding window, per-IP, 429) | Yes | Clear requirements | -| Refactor auth to JWT | Yes | Defined end state | -| Fix login bug | Yes | Success is measurable | -| Fix 2410 lint violations | Yes | Clear completion criteria | -| Make the app faster | No | Needs exploration first | -| Improve the codebase | No | No acceptance criteria | -| Figure out flaky tests | No | Exploratory | +## How is this different from a single coding agent? -Rule of thumb: if you cannot describe what "done" means, validators cannot verify it. +| | A single coding agent | Zeroshot | +| --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------- | +| Who says it is correct? | the same agent that wrote it | a separate agent that never saw how it was written | +| Is the code actually run? | usually just claimed | executed against your real tests | +| When it fails, you get | an assertion it is fine | a reproducible failure | +| When does it stop? | when it decides it is done | when the change is verified, or provably is not | +| Which coding agent runs it? | one, fixed | any you already run: Zeroshot is the harness around Claude Code, Codex, Gemini, or OpenCode | -## Command Overview +## Quick start ```bash -# Run -zeroshot run 123 # GitHub issue -zeroshot run feature.md # Markdown file -zeroshot run "Add dark mode" # Inline text - -# Isolation -zeroshot run 123 --worktree # git worktree -zeroshot run 123 --docker # container - -# Automation (--ship implies --pr implies --worktree) -zeroshot run 123 --pr # worktree + create PR -zeroshot run 123 --ship # PR + auto-merge on approval - -# Background mode -zeroshot run 123 -d -zeroshot run 123 --ship -d - -# Control -zeroshot list -zeroshot status -zeroshot logs -f -zeroshot resume -zeroshot stop -zeroshot kill - -# Providers -zeroshot providers -zeroshot providers set-default codex - -# Agent library -zeroshot agents list -zeroshot agents show - -# Maintenance -zeroshot clean -zeroshot purge +zeroshot run 123 # a GitHub issue number +zeroshot run feature.md # a markdown spec +zeroshot run "Add a --json flag" # inline text ``` -## Multi-Platform Issue Support - -Zeroshot works with **GitHub, GitLab, Jira, and Azure DevOps**. Just paste the issue URL or key. -When working in a git repository, zeroshot automatically detects the issue provider from your git remote URL. No configuration needed! +Describe a non-trivial task inline and let the loop run it to a verified change: ```bash -# GitHub -zeroshot run 123 -zeroshot run https://github.com/org/repo/issues/123 - -# GitLab (cloud and self-hosted) -zeroshot run https://gitlab.com/org/repo/-/issues/456 -zeroshot run https://gitlab.mycompany.com/org/repo/-/issues/789 - -# Jira -zeroshot run PROJ-789 -zeroshot run https://company.atlassian.net/browse/PROJ-789 - -# Azure DevOps -zeroshot run https://dev.azure.com/org/project/_workitems/edit/999 -``` - -**Requires**: CLI tools ([`gh`](https://cli.github.com/), [`glab`](https://gitlab.com/gitlab-org/cli), [`jira`](https://github.com/go-jira/jira), or [`az`](https://docs.microsoft.com/cli/azure/)) for the platform you use. See [issue-providers README](src/issue-providers/README.md) for setup and self-hosted instances. - -**Important for `--pr` mode**: Run zeroshot from the target repository directory. PRs are created on the git remote of your current directory. If you run from a different repo, zeroshot will warn you and skip the "Closes #X" reference (the PR is still created, but won't auto-close the issue). - -## Architecture - -Zeroshot is a message-driven coordination layer with smart defaults. - -- The conductor classifies tasks by complexity and type. -- A workflow template selects agents and validators. -- Agents publish results to a SQLite ledger. -- Validators approve or reject with specific findings. -- Rejections route back to the worker for fixes. - -``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ TASK β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ CONDUCTOR β”‚ - β”‚ Complexity Γ— TaskType β†’ Workflow β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ β”‚ - β–Ό β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ TRIVIAL β”‚ β”‚ SIMPLE β”‚ β”‚ STANDARD+ β”‚ - β”‚ 1 agent │──────────▢ β”‚ worker β”‚ β”‚ planner β”‚ - β”‚ (level1) β”‚ COMPLETE β”‚ + 1 valid.β”‚ β”‚ + worker β”‚ - β”‚ no valid. β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ + 3-5 val.β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ - β–Ό β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό - β”Œβ”€β”€β–Άβ”‚ WORKER β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ PLANNER β”‚ - β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β–Ό β”‚ - β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό - β”‚ β”‚ βœ“ validator β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ (generic check) β”‚ β”Œβ”€β”€β–Άβ”‚ WORKER β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ - β”‚ REJECT β”‚ ALL OK β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β–Ό - β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ β”‚ βœ“ requirements β”‚ - β”‚ β”‚ β”‚ βœ“ code (STANDARD+) β”‚ - β”‚ β”‚ β”‚ βœ“ security (CRIT) β”‚ - β”‚ β”‚ β”‚ βœ“ tester (CRIT) β”‚ - β”‚ β”‚ β”‚ βœ“ adversarial β”‚ - β”‚ β”‚ β”‚ (real execution) β”‚ - β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ REJECT β”‚ ALL OK - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ COMPLETE β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +zeroshot run "Add optimistic locking with automatic retry: when updating a user, +retry with exponential backoff up to 3 times, merge non-conflicting field changes, +and surface conflicts with details. Handle the ABA problem where version goes A->B->A." ``` -### Complexity Model - -| Task | Complexity | Agents | Validators | -| ---------------------- | ---------- | ------ | ------------------------------------------------- | -| Fix typo in README | TRIVIAL | 1 | None | -| Add dark mode toggle | SIMPLE | 2 | Generic validator | -| Refactor auth system | STANDARD | 4 | Requirements, code | -| Implement payment flow | CRITICAL | 7 | Requirements, code, security, tester, adversarial | - -### Model Selection by Complexity - -| Complexity | Planner | Worker | Validators | -| ---------- | ------- | ------ | ---------- | -| TRIVIAL | - | level1 | - | -| SIMPLE | - | level2 | 1 (level2) | -| STANDARD | level2 | level2 | 2 (level2) | -| CRITICAL | level3 | level2 | 5 (level2) | - -Levels map to provider-specific models. Configure with `zeroshot providers setup ` or -`settings.providerSettings`. (Legacy `maxModel` applies to Claude only.) -
-Custom Workflows (Framework Mode) - -Zeroshot is message-driven, so you can define any agent topology. - -- Expert panels: parallel specialists -> aggregator -> decision -- Staged gates: sequential validators, each with veto power -- Hierarchical: supervisor dynamically spawns workers -- Dynamic: conductor adds agents mid-execution - -**Coordination primitives:** +Command reference -- Message bus (pub/sub topics) -- Triggers (wake agents on conditions) -- Ledger (SQLite, crash recovery) -- Dynamic spawning (CLUSTER_OPERATIONS) - -#### Creating Custom Clusters with a Provider CLI - -Start your provider CLI and describe your cluster: - -``` -Create a zeroshot cluster config for security-critical features: - -1. Implementation agent (level2) implements the feature -2. FOUR parallel validators: - - Security validator: OWASP checks, SQL injection, XSS, CSRF - - Performance validator: No N+1 queries, proper indexing - - Privacy validator: GDPR compliance, data minimization - - Code reviewer: General code quality - -3. ALL validators must approve before merge -4. If ANY validator rejects, implementation agent fixes and resubmits -5. Use level3 for security validator (highest stakes) - -Look at cluster-templates/base-templates/full-workflow.json -and create a similar cluster. Save to cluster-templates/security-review.json +```bash +# Run +zeroshot run # issue number / URL / key / markdown file / inline text +zeroshot run 123 --worktree # isolate in a git worktree +zeroshot run 123 --docker # isolate in a container +zeroshot run 123 --pr # worktree + open a pull request +zeroshot run 123 --ship # worktree + PR + auto-merge on approval +zeroshot run 123 -d # background (daemon) +zeroshot run 123 --provider gemini # override the provider for this run + +# Monitor & manage +zeroshot list # all clusters (--json) +zeroshot status # cluster details +zeroshot logs -f # stream logs +zeroshot resume [prompt] # resume a stopped/failed run +zeroshot stop # graceful stop +zeroshot kill # force kill +zeroshot export # export the conversation + +# Library & config +zeroshot providers # list providers / set-default / setup +zeroshot agents list # available agents (agents show ) +zeroshot settings # view / get / set settings +zeroshot cmdproof check # reuse a verified command result ``` -Built-in validation checks for missing triggers, deadlocks, and invalid type wiring before running. - -See [CLAUDE.md](./CLAUDE.md) for the cluster schema and examples. -
-## Crash Recovery +## Providers and backends -All state is persisted in the SQLite ledger. You can resume at any time: +Zeroshot shells out to provider CLIs; it stores no API keys and manages no auth. Pick a default and override per run. -```bash -zeroshot resume cluster-bold-panther -``` - -## Isolation Modes - -### Git Worktree (Default for --pr/--ship) +| Provider | CLI | +| ------------ | -------------------------------------- | +| Claude Code | `npm i -g @anthropic-ai/claude-code` | +| OpenAI Codex | `npm i -g @openai/codex` | +| Gemini CLI | `npm i -g @google/gemini-cli` | +| OpenCode | see [opencode.ai](https://opencode.ai) | ```bash -zeroshot run 123 --worktree +zeroshot providers # see what's installed +zeroshot providers set-default codex +zeroshot run 123 --provider gemini ``` -Lightweight isolation using git worktree. Creates a separate working directory with its own branch. Auto-enabled with `--pr` and `--ship`. - -### Docker Container +Issue backends are **auto-detected from your git remote**: **GitHub, GitLab, Jira, and Azure DevOps**. Paste a number, key, or URL: ```bash -zeroshot run 123 --docker +zeroshot run 123 # GitHub +zeroshot run https://gitlab.com/org/repo/-/issues/456 # GitLab +zeroshot run PROJ-789 # Jira +zeroshot run https://dev.azure.com/org/project/_workitems/edit/999 # Azure DevOps ``` -Full isolation in a fresh container. Your workspace stays untouched. Useful for risky experiments or parallel runs. +Each backend needs its own CLI installed (`gh`, `glab`, `jira`, or `az`). See [`docs/providers.md`](docs/providers.md) for model levels and setup. -### When to Use Which +## Isolation -| Scenario | Recommended | -| ------------------------------------ | ---------------------- | -| Quick task, review changes yourself | No isolation (default) | -| PR workflow, code review | `--worktree` or `--pr` | -| Risky experiment, might break things | `--docker` | -| Running multiple tasks in parallel | `--docker` | -| Full automation, no review needed | `--ship` | +By default, agents modify files only; they do **not** commit or push. Opt into isolation to let the loop own a branch (the flags cascade: `--ship` β†’ `--pr` β†’ `--worktree`). -**Default behavior:** Agents modify files only; they do not commit or push unless using an isolation mode that explicitly allows it. +| Mode | Flag | Use when | +| ------------ | ------------ | ------------------------------------------------ | +| None | _(default)_ | quick task, you review the changes yourself | +| Git worktree | `--worktree` | PR workflows, lightweight branch isolation | +| Docker | `--docker` | risky experiments, parallel runs, full isolation |
-Docker Credential Mounts - -When using `--docker`, zeroshot mounts credential directories so agents can access provider CLIs and tools like AWS, Azure, and kubectl. +Docker credential mounts -**Default mounts**: `gh`, `git`, `ssh` (GitHub CLI, git config, SSH keys) - -**Available presets**: `gh`, `git`, `ssh`, `aws`, `azure`, `kube`, `terraform`, `gcloud`, `claude`, `codex`, `gemini` +When using `--docker`, Zeroshot mounts credential directories so agents can reach provider CLIs and tools. Defaults: `gh`, `git`, `ssh`. Presets include `aws`, `azure`, `kube`, `terraform`, `gcloud`, and the provider configs. ```bash -# Configure via settings (persistent) -zeroshot settings set dockerMounts '["gh", "git", "ssh", "aws", "azure"]' - -# View current config -zeroshot settings get dockerMounts - -# Per-run override +zeroshot settings set dockerMounts '["gh","git","ssh","aws"]' zeroshot run 123 --docker --mount ~/.aws:/root/.aws:ro - -# Provider credentials -zeroshot run 123 --docker --mount ~/.config/codex:/home/node/.config/codex:ro -zeroshot run 123 --docker --mount ~/.config/gemini:/home/node/.config/gemini:ro - -# Disable all mounts zeroshot run 123 --docker --no-mounts - -# CI: env var override -ZEROSHOT_DOCKER_MOUNTS='["aws","azure"]' zeroshot run 123 --docker ``` -See `docs/providers.md` for provider CLI setup and mount details. - -**Custom mounts** (mix presets with explicit paths): +See [`docs/providers.md`](docs/providers.md) for mount details. -```bash -zeroshot settings set dockerMounts '[ - "gh", - "git", - {"host": "~/.myconfig", "container": "$HOME/.myconfig", "readonly": true} -]' -``` +
-**Container home**: Presets use `$HOME` placeholder. Default: `/root`. Override with: +## Scope and status -```bash -zeroshot settings set dockerContainerHome '/home/node' -# Or per-run: -zeroshot run 123 --docker --container-home /home/node -``` +Zeroshot performs best when a task has **clear acceptance criteria**. If you can't say what "done" means, an independent verifier can't confirm it. -**Env var passthrough**: Presets auto-pass related env vars (for example, `aws` -> `AWS_REGION`, `AWS_PROFILE`). Add custom: +| Task | Good fit? | Why | +| ----------------------------------------------- | --------- | ----------------------- | +| Add rate limiting (sliding window, per-IP, 429) | Yes | clear requirements | +| Refactor auth to JWT | Yes | defined end state | +| Fix a login bug | Yes | success is measurable | +| "Make the app faster" | No | needs exploration first | +| "Improve the codebase" | No | no acceptance criteria | -```bash -zeroshot settings set dockerEnvPassthrough '["MY_API_KEY", "TF_VAR_*"]' -``` +- **Pre-1.0 in spirit.** Interfaces still move between releases; pin your version. (The npm version auto-increments on every merge, so read it as a build counter, not a stability promise.) +- **Crash-safe.** All state persists to a SQLite ledger; `zeroshot resume ` continues at any time. +- **No TUI in this release.** Monitor with `zeroshot logs -f`, `zeroshot list`, and `zeroshot status `. - +
+Architecture, quality gates and command proofs -## Resources +Zeroshot is a message-driven coordination layer: a conductor classifies each task by complexity and type, a workflow template selects agents and validators, agents publish results to a SQLite ledger, and validators approve or reject with specific findings. -- [CLAUDE.md](./CLAUDE.md) - Architecture, cluster config schema, agent primitives -- `docs/providers.md` - Provider setup, model levels, and Docker mounts -- `docs/context-management.md` - Context selection, context packs, and state snapshots -- [Discord](https://discord.gg/PdZ3UEXB) - Support and community -- `zeroshot export ` - Export conversation to markdown -- `sqlite3 ~/.zeroshot/*.db` - Direct ledger access for debugging +- **Required handoff quality gates**: in `--pr`/`--ship` flows, the git-pusher fails closed until every configured gate has fresh passing evidence. +- **Cmdproof**: make expensive exact commands reusable across agents with `zeroshot cmdproof check `. -
-Troubleshooting - -| Issue | Fix | -| ----------------------------- | ----------------------------------------------------------------------------------------- | -| `claude: command not found` | `npm i -g @anthropic-ai/claude-code && claude auth login` | -| `codex: command not found` | `npm i -g @openai/codex && codex login` | -| `gemini: command not found` | `npm i -g @google/gemini-cli && gemini auth login` | -| `gh: command not found` | [Install GitHub CLI](https://cli.github.com/) | -| `--docker` fails | Docker must be running: `docker ps` to verify | -| Cluster stuck | `zeroshot resume ` to continue | -| Agent keeps failing | Check `zeroshot logs ` for actual error | -| `zeroshot: command not found` | `npm install -g @the-open-engine/zeroshot` | -| Agents misbehave | `/analyze-cluster-postmortem ` in Claude Code (creates issue if fix is generalizable) | +See [CLAUDE.md](./CLAUDE.md) for the cluster schema, primitives, and the conductor's classification model.
-## Contributing +## The Open Engine -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. +Zeroshot is **Layer 01 Β· Verification** of [The Open Engine](https://theopenengine.com), the open stack for autonomous software production. Generating code is easy; trusting it is not. The engine is layered because trust is layered: -Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before participating. +| | Layer | Status | +| ------ | -------------------------- | --------------------------- | +| **01** | **Verification: Zeroshot** | This repo Β· open Β· shipping | +| 02 | Constraints: **Opcore** | Sibling Β· alpha | +| 03-05 | Intent Β· Context Β· Runtime | In development | -For security issues, see [SECURITY.md](SECURITY.md). +Zeroshot runs the loop: an agent writes the change, and an **independent** verifier decides whether it holds: approve, or a reproducible failure. **Opcore** is the sibling layer, a deterministic, local, read-only **constraints** gate for coding agents (currently private alpha `0.1.0-alpha.0`, built in the open, not yet published). Verification asks _"does this meet the goal?"_; constraints ask _"is this within tolerance?"_ -## TUI +Each layer ships the same way: extracted from the platform we run, then opened. **Trust nothing. Verify everything.** -The TUI is not included in this release. Use `zeroshot logs -f`, `zeroshot logs -w`, -`zeroshot list`, and `zeroshot status ` for monitoring. +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before participating, and [SECURITY.md](SECURITY.md) for security reports. More in [`docs/`](docs/) and [CLAUDE.md](./CLAUDE.md). + + ---- +Questions and help: [Discord](https://discord.gg/fZyzf2Cut9). -MIT - [The Open Engine Company](https://github.com/the-open-engine) +## License -Built on [Claude Code](https://claude.com/product/claude-code) by Anthropic. +MIT. [The Open Engine Company](https://github.com/the-open-engine). diff --git a/cli/event-copy.js b/cli/event-copy.js new file mode 100644 index 00000000..8bb56607 --- /dev/null +++ b/cli/event-copy.js @@ -0,0 +1,12 @@ +const EVENT_COPY = { + IMPLEMENTATION_READY: 'Implementation ready', + PR_CREATED: 'Pull request created', +}; + +function formatMergeStatus(merged) { + if (merged === true || merged === 'true') return 'merged'; + if (merged === false || merged === 'false') return 'auto-merge pending approval'; + return null; +} + +module.exports = { EVENT_COPY, formatMergeStatus }; diff --git a/cli/index.js b/cli/index.js index f10eec49..45949724 100755 --- a/cli/index.js +++ b/cli/index.js @@ -22,6 +22,7 @@ const { URL } = require('url'); const chalk = require('chalk'); const Orchestrator = require('../src/orchestrator'); const { setupCompletion } = require('../lib/completion'); +const { resolveRunMode, describeRunMode } = require('../lib/run-mode'); const { formatWatchMode } = require('./message-formatters-watch'); const { formatAgentLifecycle, @@ -48,10 +49,16 @@ const { } = require('../lib/settings'); const { normalizeProviderName } = require('../lib/provider-names'); const { getProvider, parseProviderChunk } = require('../src/providers'); +const { readClustersFileSync } = require('../lib/clusters-registry'); const { MOUNT_PRESETS, resolveEnvs } = require('../lib/docker-config'); const { detectGitRepoRoot, detectRunInput, + isStdinInput, + readStdinText, + encodeStdinEnv, + decodeStdinEnv, + buildTextInput, loadClusterConfig, resolveConfigPath, resolveProviderOverride, @@ -66,10 +73,13 @@ const { runCmdproof } = require('./commands/cmdproof'); const { markDetachedSetupFailed, registerDetachedSetupCluster, + removeDetachedSetupCluster, } = require('../lib/detached-startup'); // Setup wizard removed - use: zeroshot settings set const { checkForUpdates, printLegacyDistroNotice } = require('./lib/update-checker'); +const { checkBinDirOnPath, printPathWarning } = require('../lib/path-check'); const { StatusFooter, AGENT_STATE, ACTIVE_STATES } = require('../src/status-footer'); +const { EVENT_COPY, formatMergeStatus } = require('./event-copy'); // ============================================================================= // GLOBAL ERROR HANDLERS - Prevent silent process death @@ -179,9 +189,10 @@ function normalizeRunOptions(options) { if (options.docker) { options.worktree = false; } + options.autoMerge = Boolean(options.ship); } -function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) { +async function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) { // Detect which issue provider tool is needed let issueProvider = null; let targetHost = null; @@ -205,7 +216,7 @@ function runClusterPreflight({ input, options, providerOverride, settings, force } } - requirePreflight({ + await requirePreflight({ requireGh: issueProvider === 'github', // gh CLI required for GitHub requireDocker: options.docker, requireGit: options.worktree, @@ -221,11 +232,8 @@ function shouldRunDetached(options) { } function printDetachedClusterStart(options, clusterId, logPath) { - if (options.docker) { - console.log(`Started ${clusterId} (docker)`); - } else { - console.log(`Started ${clusterId}`); - } + const runMode = resolveRunMode(options); + console.log(runMode ? `Started ${clusterId} (${runMode})` : `Started ${clusterId}`); if (logPath) { console.log(`Setup log: ${logPath}`); } @@ -256,7 +264,7 @@ function resolveMergeQueueEnv(value) { return ''; } -function buildDaemonEnv(options, clusterId, targetCwd) { +function buildDaemonEnv(options, clusterId, targetCwd, stdinText) { const mergeQueueEnv = resolveMergeQueueEnv(options.mergeQueue); const runOptionsEnv = serializeRunOptions(options); return { @@ -275,10 +283,11 @@ function buildDaemonEnv(options, clusterId, targetCwd) { ZEROSHOT_MERGE_QUEUE: mergeQueueEnv, ZEROSHOT_CLOSE_ISSUE: options.closeIssue || '', ZEROSHOT_CWD: targetCwd, + ZEROSHOT_STDIN_TASK: stdinText !== undefined ? encodeStdinEnv(stdinText) : '', }; } -async function spawnDetachedCluster(options, clusterId) { +async function spawnDetachedCluster(options, clusterId, stdinText) { const { spawn } = require('child_process'); const logFd = createDaemonLogFile(clusterId); const targetCwd = detectGitRepoRoot(); @@ -287,7 +296,7 @@ async function spawnDetachedCluster(options, clusterId) { detached: true, stdio: ['ignore', logFd, logFd], cwd: targetCwd, - env: buildDaemonEnv(options, clusterId, targetCwd), + env: buildDaemonEnv(options, clusterId, targetCwd, stdinText), }); await registerDetachedSetupCluster({ clusterId, @@ -321,13 +330,8 @@ function printForegroundStartInfo(options, clusterId, configName) { if (process.env.ZEROSHOT_DAEMON) { return; } - if (options.docker) { - console.log(`Starting ${clusterId} (docker)`); - } else if (options.worktree) { - console.log(`Starting ${clusterId} (worktree)`); - } else { - console.log(`Starting ${clusterId}`); - } + const runMode = resolveRunMode(options); + console.log(runMode ? `Starting ${clusterId} (${runMode})` : `Starting ${clusterId}`); console.log(chalk.dim(`Config: ${configName}`)); console.log(chalk.dim('Ctrl+C to stop following (cluster keeps running)\n')); } @@ -389,13 +393,14 @@ function applyModelOverrideToConfig(config, modelOverride, providerOverride, set console.log(chalk.dim(`Model override: ${modelOverride} (all agents)`)); } -function createStatusFooter(clusterId, messageBus) { +function createStatusFooter(clusterId, messageBus, runMode) { const statusFooter = new StatusFooter({ refreshInterval: 1000, enabled: process.stdout.isTTY, }); statusFooter.setCluster(clusterId); statusFooter.setClusterState('running'); + statusFooter.setRunMode(runMode); statusFooter.setMessageBus(messageBus); activeStatusFooter = statusFooter; return statusFooter; @@ -571,11 +576,11 @@ function waitForClusterCompletion(orchestrator, clusterId, cleanup) { }); } -async function streamClusterInForeground(cluster, orchestrator, clusterId) { +async function streamClusterInForeground(cluster, orchestrator, clusterId, options) { const sendersWithOutput = new Set(); const processedMessageIds = new Set(); - const statusFooter = createStatusFooter(clusterId, cluster.messageBus); + const statusFooter = createStatusFooter(clusterId, cluster.messageBus, resolveRunMode(options)); const handleLifecycleMessage = createLifecycleHandler(statusFooter); const lifecycleUnsubscribe = cluster.messageBus.subscribeTopic( 'AGENT_LIFECYCLE', @@ -629,22 +634,28 @@ function setupDaemonCleanup(orchestrator, clusterId) { } function readClusterTokenTotals(orchestrator, clusterId) { - let totalTokens = 0; - let totalCostUsd = 0; try { const clusterObj = orchestrator.getCluster(clusterId); - if (clusterObj?.messageBus) { - const tokensByRole = clusterObj.messageBus.getTokensByRole(clusterId); - if (tokensByRole?._total?.count > 0) { - const total = tokensByRole._total; - totalTokens = (total.inputTokens || 0) + (total.outputTokens || 0); - totalCostUsd = total.totalCostUsd || 0; - } + if (!clusterObj?.messageBus) { + return { totalTokens: 0, totalCostUsd: 0 }; } - } catch { - /* Token tracking not available */ + const { tokensByRole } = clusterObj.messageBus.readSnapshot(clusterId); + const total = tokensByRole?._total; + if (!total || total.count === 0) { + return { totalTokens: 0, totalCostUsd: 0 }; + } + return { + totalTokens: (total.inputTokens || 0) + (total.outputTokens || 0), + totalCostUsd: total.totalCostUsd || 0, + }; + } catch (error) { + // A confirmed-empty ledger (0) and a failed read must stay distinguishable - + // fabricating 0 here is what produced phantom "msgs=0 $0" rows in `list --json`. + console.error( + `[cli] Failed to read token totals for cluster ${clusterId}: ${error.message || String(error)}` + ); + return { totalTokens: null, totalCostUsd: null }; } - return { totalTokens, totalCostUsd }; } function enrichClustersWithTokens(clusters, orchestrator) { @@ -672,8 +683,9 @@ function formatClusterRow(cluster) { const stateDisplay = cluster.state === 'zombie' ? chalk.red(cluster.state.padEnd(12)) : cluster.state.padEnd(12); const rowColor = cluster.state === 'zombie' ? chalk.red : (text) => text; + const modeDisplay = (cluster.runMode || '-').padEnd(10); - return `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${cluster.agentCount + return `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${modeDisplay} ${cluster.agentCount .toString() .padEnd(8)} ${tokenDisplay.padEnd(12)} ${costDisplay.padEnd(8)} ${created}`; } @@ -687,11 +699,11 @@ function printClusterTable(enrichedClusters) { console.log(chalk.bold('\n=== Clusters ===')); console.log( - `${'ID'.padEnd(25)} ${'State'.padEnd(12)} ${'Agents'.padEnd(8)} ${'Tokens'.padEnd( - 12 - )} ${'Cost'.padEnd(8)} Created` + `${'ID'.padEnd(25)} ${'State'.padEnd(12)} ${'Mode'.padEnd(10)} ${'Agents'.padEnd( + 8 + )} ${'Tokens'.padEnd(12)} ${'Cost'.padEnd(8)} Created` ); - console.log('-'.repeat(100)); + console.log('-'.repeat(110)); for (const cluster of enrichedClusters) { console.log(formatClusterRow(cluster)); } @@ -734,13 +746,16 @@ function reportMissingId(id, options) { function getClusterTokensByRole(orchestrator, clusterId) { try { const cluster = orchestrator.getCluster(clusterId); - if (cluster?.messageBus) { - return cluster.messageBus.getTokensByRole(clusterId); + if (!cluster?.messageBus) { + return null; } - } catch { - /* Token tracking not available */ + return cluster.messageBus.readSnapshot(clusterId).tokensByRole; + } catch (error) { + console.error( + `[cli] Failed to read tokens by role for cluster ${clusterId}: ${error.message || String(error)}` + ); + return null; } - return null; } function printClusterStatusJson(status, tokensByRole) { @@ -774,6 +789,9 @@ function printClusterStatusHeader(status, clusterId) { } else { console.log(`State: ${status.state}`); } + if (status.runMode) { + console.log(`Mode: ${describeRunMode(status.runMode)}`); + } if (status.pid) { console.log(`PID: ${status.pid}`); } @@ -1216,9 +1234,26 @@ function followSetupLog(logPath) { } function printRecentMessages(messages, limit, isActive, options) { - const recentMessages = messages.slice(-limit); - for (const msg of recentMessages) { - printMessage(msg, true, options.watch, isActive); + if (options.watch) { + const recentMessages = selectRecentPrintableMessages(messages, limit); + for (const msg of recentMessages) { + printMessage(msg, true, true, isActive); + } + if (recentMessages.length === 0 && messages.length > 0) { + console.log(chalk.dim(`No printable history in ${messages.length} stored messages.`)); + } + return; + } + + const output = renderRecentMessagesToTerminal(messages, limit); + + if (output) { + console.log(output); + return; + } + + if (messages.length > 0) { + console.log(chalk.dim(`No printable history in ${messages.length} stored messages.`)); } } @@ -1461,9 +1496,8 @@ async function printAttachableClusters(clusters, socketDiscovery) { } function readClusterFromDisk(id) { - const clustersFile = path.join(os.homedir(), '.zeroshot', 'clusters.json'); try { - const clusters = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + const clusters = readClustersFileSync(path.join(os.homedir(), '.zeroshot')); return clusters[id] || null; } catch { return null; @@ -1554,7 +1588,7 @@ async function resolveClusterSocketPath(id, options, socketDiscovery) { const cluster = readClusterFromDisk(id); ensureClusterRunning(cluster, id); - const orchestrator = await Orchestrator.create({ quiet: true }); + const orchestrator = await Orchestrator.create({ quiet: true, readonly: true }); try { const status = orchestrator.getStatus(id); const activeAgents = getActiveAgents(status); @@ -2028,6 +2062,31 @@ function getOrchestrator() { return _orchestratorPromise; } +// Separate lazy-loaded read-only orchestrator for commands that only ever read +// cluster state (list/status/logs). Read-only ledgers can never contend with a +// live daemon's writer connection or mutate clusters.json as a side effect. +/** @type {import('../src/orchestrator') | null} */ +let _readonlyOrchestrator = null; +/** @type {Promise | null} */ +let _readonlyOrchestratorPromise = null; +/** + * @returns {Promise} + */ +function getReadonlyOrchestrator() { + if (_readonlyOrchestrator) { + return Promise.resolve(_readonlyOrchestrator); + } + if (!_readonlyOrchestratorPromise) { + _readonlyOrchestratorPromise = Orchestrator.create({ quiet: true, readonly: true }).then( + (orch) => { + _readonlyOrchestrator = orch; + return orch; + } + ); + } + return _readonlyOrchestratorPromise; +} + /** * @typedef {Object} TaskLogMessage * @property {string} topic @@ -2323,7 +2382,9 @@ Shell completion: // Run command - CLUSTER with auto-detection program .command('run ') - .description('Start a multi-agent cluster (GitHub issue, markdown file, or plain text)') + .description( + 'Start a multi-agent cluster (GitHub issue, markdown file, plain text, or "-" for stdin)' + ) .option('--config ', 'Path to cluster config JSON (default: conductor-bootstrap)') .option('--docker', 'Run cluster inside Docker container (full isolation)') .option('--worktree', 'Use git worktree for isolation (lightweight, no Docker required)') @@ -2337,7 +2398,7 @@ program ) .option( '--pr', - 'Create PR for human review (uses worktree isolation by default, use --docker for Docker)' + 'Create PR for human review (uses worktree isolation by default, use --docker for Docker). Never auto-merges itself; a repo-side branch-protection auto-merge rule or merge queue may still merge the PR independently of zeroshot.' ) .option( '--ship', @@ -2364,6 +2425,7 @@ program .option('-L, --gitlab', 'Force GitLab as issue source') .option('-J, --jira', 'Force Jira as issue source') .option('-D, --devops', 'Force Azure DevOps as issue source') + .option('-N, --linear', 'Force Linear as issue source') .option('-d, --detach', 'Run in background (default: attach to first agent)') .option('--mount ', 'Add Docker mount (host:container[:ro]). Repeatable.') .option('--no-mounts', 'Disable all Docker credential mounts') @@ -2391,11 +2453,20 @@ Input formats: Azure DevOps: https://dev.azure.com/org/proj/_workitems/edit/123 + Linear: + ENG-42 Issue key + https://linear.app//issue/ENG-42/... + File/Text: feature.md Markdown file "Implement feature X" Plain text task -Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) + Stdin: + - Read task body from stdin (pipe/redirect) + pbpaste | zeroshot run - + zeroshot run - < issue.md + +Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps), -N (Linear) ` ) .action(async (inputArg, options) => { @@ -2409,14 +2480,41 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) else if (options.gitlab) forceProvider = 'gitlab'; else if (options.jira) forceProvider = 'jira'; else if (options.devops) forceProvider = 'azure-devops'; + else if (options.linear) forceProvider = 'linear'; + + // Stdin input ('-'): read task body from stdin to avoid shell-quoting breakage + let stdinText; + if (isStdinInput(inputArg)) { + if (process.env.ZEROSHOT_DAEMON === '1') { + const encoded = process.env.ZEROSHOT_STDIN_TASK; + if (!encoded) { + throw new Error('zeroshot run -: daemon mode missing forwarded stdin content'); + } + stdinText = decodeStdinEnv(encoded); + } else if (process.stdin.isTTY) { + throw new Error( + 'zeroshot run -: no piped input detected. Pipe or redirect a task body, e.g.\n' + + ' pbpaste | zeroshot run -\n' + + ' zeroshot run - < issue.md' + ); + } else { + stdinText = await readStdinText(); + } + if (!stdinText) { + throw new Error('zeroshot run -: stdin was empty, no task content received'); + } + } // Auto-detect input type - const input = detectRunInput(inputArg); const settings = loadSettings(); + const input = + stdinText !== undefined + ? buildTextInput(stdinText) + : detectRunInput(inputArg, settings, forceProvider); const providerOverride = resolveProviderOverride(options); // Preflight checks - runClusterPreflight({ input, options, providerOverride, settings, forceProvider }); + await runClusterPreflight({ input, options, providerOverride, settings, forceProvider }); // Secondary preflight: token-free template simulation/validation const simMode = String(options.sim || 'fast').toLowerCase(); @@ -2446,7 +2544,7 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) if (shouldRunDetached(options)) { const clusterId = generateName('cluster'); - await spawnDetachedCluster(options, clusterId); + await spawnDetachedCluster(options, clusterId, stdinText); return; } @@ -2511,11 +2609,28 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) } if (!process.env.ZEROSHOT_DAEMON) { - await streamClusterInForeground(cluster, orchestrator, clusterId); + await streamClusterInForeground(cluster, orchestrator, clusterId, options); } setupDaemonCleanup(orchestrator, clusterId); } catch (error) { + if (error.code === 'DUPLICATE_CLUSTER') { + // Benign guard rejection, not a crash: nothing was allocated, so there is + // nothing to roll back. No stack trace, no "Error:" framing. + if (process.env.ZEROSHOT_DAEMON && process.env.ZEROSHOT_CLUSTER_ID) { + try { + await removeDetachedSetupCluster({ + clusterId: process.env.ZEROSHOT_CLUSTER_ID, + storageDir: path.join(os.homedir(), '.zeroshot'), + }); + } catch (removeError) { + console.error('Failed to remove provisional setup cluster:', removeError.message); + } + } + console.log(chalk.yellow(error.message)); + process.exit(1); + } + if (process.env.ZEROSHOT_DAEMON && process.env.ZEROSHOT_CLUSTER_ID) { try { await markDetachedSetupFailed({ @@ -2585,7 +2700,7 @@ taskCmd const providerOverride = normalizeProviderName( options.provider || process.env.ZEROSHOT_PROVIDER || settings.defaultProvider ); - requirePreflight({ + await requirePreflight({ requireGh: false, // gh not needed for plain tasks requireDocker: false, // Docker not needed for plain tasks quiet: false, @@ -2629,7 +2744,7 @@ program .option('--json', 'Output as JSON') .action(async (options) => { try { - const orchestrator = await getOrchestrator(); + const orchestrator = await getReadonlyOrchestrator(); const clusters = filterClustersByStatus(orchestrator.listClusters(), options.status); const enrichedClusters = enrichClustersWithTokens(clusters, orchestrator); @@ -2667,7 +2782,7 @@ program } if (type === 'cluster') { - const orchestrator = await getOrchestrator(); + const orchestrator = await getReadonlyOrchestrator(); const status = orchestrator.getStatus(id); const tokensByRole = getClusterTokensByRole(orchestrator, id); if (options.json) { @@ -2725,7 +2840,7 @@ program } const limit = parseLogLimit(options); - const quietOrchestrator = await Orchestrator.create({ quiet: true }); + const quietOrchestrator = await Orchestrator.create({ quiet: true, readonly: true }); if (!id) { showAllClusterLogs(quietOrchestrator, options, limit); @@ -2949,9 +3064,9 @@ program program .command('export ') .description('Export cluster conversation') - .option('-f, --format ', 'Export format: json, markdown, pdf', 'pdf') - .option('-o, --output ', 'Output file (auto-generated for pdf)') - .action(async (clusterId, options) => { + .option('-f, --format ', 'Export format: json, markdown, html', 'html') + .option('-o, --output ', 'Output file (auto-generated for html)') + .action((clusterId, options) => { try { // Get messages from DB const Ledger = require('../src/ledger'); @@ -2993,10 +3108,12 @@ program return; } - // PDF export - convert ANSI to HTML, then render it to PDF - const outputFile = options.output || `${clusterId}.pdf`; + // HTML export - convert ANSI to a self-contained, print-ready HTML file. + // Open it in any browser and use Print -> Save as PDF for a PDF copy. + // `pdf` is accepted as a migration alias for the previous puppeteer-based export. + const isLegacyPdfFormat = options.format === 'pdf'; + const outputFile = options.output || `${clusterId}.html`; const AnsiToHtml = require('ansi-to-html'); - const { default: puppeteer } = await import('puppeteer'); const ansiConverter = new AnsiToHtml({ fg: '#d4d4d4', @@ -3042,27 +3159,13 @@ program `; - const browser = await puppeteer.launch(); - try { - const page = await browser.newPage(); - await page.setContent(fullHtml, { waitUntil: 'networkidle0' }); - const pdf = await page.pdf({ - format: 'A4', - landscape: true, - margin: { - top: '10mm', - right: '10mm', - bottom: '10mm', - left: '10mm', - }, - printBackground: true, - }); - - require('fs').writeFileSync(outputFile, pdf); - } finally { - await browser.close(); - } + require('fs').writeFileSync(outputFile, fullHtml, 'utf8'); console.log(`Exported to ${outputFile}`); + if (isLegacyPdfFormat) { + console.log( + 'Note: PDF export now produces print-ready HTML. Open it in a browser and use Print -> Save as PDF.' + ); + } } catch (error) { console.error('Error exporting cluster:', error.message); process.exit(1); @@ -3096,7 +3199,7 @@ program cluster.config?.defaultProvider || settings.defaultProvider; - requirePreflight({ + await requirePreflight({ requireGh: false, // Resume doesn't fetch new issues requireDocker: requiresDocker, quiet: false, @@ -3228,7 +3331,7 @@ program // If task store is unavailable, fall back to default provider } - requirePreflight({ + await requirePreflight({ requireGh: false, requireDocker: false, quiet: false, @@ -3334,6 +3437,23 @@ async function runGc(dryRun) { } } +async function detectAndReportCorruptedClusters(dryRun) { + if (dryRun) { + return; + } + try { + const orchestrator = await getOrchestrator(); + const corrupted = await orchestrator.detectCorruptedClusters(); + if (corrupted.length > 0) { + console.log(chalk.yellow(`\nFound ${corrupted.length} corrupted cluster(s):`)); + corrupted.forEach((id) => console.log(chalk.yellow(` ${id}`))); + console.log(chalk.dim(`Run 'zeroshot kill ' to remove them.`)); + } + } catch (error) { + console.error(`Error detecting corrupted clusters: ${error.message}`); + } +} + program .command('gc') .description('Clean up orphaned worktree directories and stale database files') @@ -3341,6 +3461,7 @@ program .action(async (options) => { try { printGcResult(await runGc(options.dryRun), options.dryRun); + await detectAndReportCorruptedClusters(options.dryRun); } catch (error) { console.error('Error during garbage collection:', error.message); process.exit(1); @@ -3514,9 +3635,11 @@ function printNonDockerSettings(settings) { if (dockerKeys.has(key)) { continue; } + const displayValue = + key === 'linearApiKey' && value ? `${String(value).slice(0, 7)}***` : value; const isDefault = JSON.stringify(DEFAULT_SETTINGS[key]) === JSON.stringify(value); const label = isDefault ? chalk.dim(key) : chalk.cyan(key); - const val = isDefault ? chalk.dim(String(value)) : chalk.white(String(value)); + const val = isDefault ? chalk.dim(String(displayValue)) : chalk.white(String(displayValue)); console.log(` ${label.padEnd(30)} ${val}`); } } @@ -4788,7 +4911,9 @@ function handleIssueOpenedRender({ msg, prefix, timestamp, lines }) { } function handleImplementationReadyRender({ prefix, timestamp, lines }) { - lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow('βœ… IMPLEMENTATION READY')}`); + lines.push( + `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow(`βœ… ${EVENT_COPY.IMPLEMENTATION_READY.toUpperCase()}`)}` + ); } function normalizeIssueList(rawIssues) { @@ -4857,12 +4982,17 @@ function handleValidationResultRender({ msg, prefix, timestamp, lines }) { function handlePrCreatedRender({ msg, prefix, timestamp, lines }) { lines.push(''); lines.push(chalk.bold.green('─'.repeat(80))); - lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('πŸ”— PR CREATED')}`); + lines.push( + `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green(`πŸ”— ${EVENT_COPY.PR_CREATED.toUpperCase()}`)}` + ); if (msg.content?.data?.pr_url) { lines.push(`${prefix} ${chalk.cyan(msg.content.data.pr_url)}`); } - if (msg.content?.data?.merged) { - lines.push(`${prefix} ${chalk.bold.cyan('βœ“ MERGED')}`); + const mergeStatus = formatMergeStatus(msg.content?.data?.merged); + if (mergeStatus) { + lines.push( + `${prefix} ${chalk.gray('Merge:')} ${mergeStatus === 'merged' ? chalk.bold.cyan(mergeStatus) : chalk.yellow(mergeStatus)}` + ); } lines.push(chalk.bold.green('─'.repeat(80))); } @@ -4945,7 +5075,15 @@ function handleAgentOutputRender({ msg, prefix, lines, buffers, toolCalls }) { } if (event.type === 'tool_result') { appendAgentToolResultEvent(lines, msg.sender, prefix, toolCalls, event); + continue; } + if (event.type === 'result') { + appendAgentResultEvent(lines, prefix, event); + } + } + + if (events.length === 0) { + appendRawAgentOutputLines(lines, prefix, content); } } @@ -4960,6 +5098,63 @@ const RENDER_TOPIC_HANDLERS = { AGENT_OUTPUT: handleAgentOutputRender, }; +const HISTORY_NOISE_TOPICS = new Set(['STATE_SNAPSHOT', 'TOKEN_USAGE']); + +function appendAgentResultEvent(lines, prefix, event) { + if (!event.success) { + lines.push(`${prefix} ${chalk.bold.red('βœ— Error:')} ${event.error || 'Task failed'}`); + return; + } + + const usage = []; + if (Number.isFinite(event.inputTokens)) usage.push(`${event.inputTokens} in`); + if (Number.isFinite(event.outputTokens)) usage.push(`${event.outputTokens} out`); + + const suffix = usage.length > 0 ? ` (${usage.join(', ')})` : ''; + lines.push(`${prefix} ${chalk.green('βœ“')} completed${suffix}`); +} + +function appendRawAgentOutputLines(lines, prefix, content) { + for (const line of String(content).split('\n')) { + const trimmed = line.trim(); + if (shouldSkipAgentOutputLine(trimmed)) continue; + lines.push(`${prefix} ${line}`); + } +} + +function agentOutputHasPrintableHistory(msg) { + const content = msg.content?.data?.line || msg.content?.data?.chunk || msg.content?.text; + if (!content || !content.trim()) return false; + + const provider = normalizeProviderName( + msg.content?.data?.provider || msg.sender_provider || 'claude' + ); + const events = parseProviderChunk(provider, content); + if (events.length === 0) { + return String(content) + .split('\n') + .some((line) => !shouldSkipAgentOutputLine(line.trim())); + } + + return events.some((event) => + ['text', 'thinking', 'thinking_start', 'tool_call', 'tool_result', 'result'].includes( + event.type + ) + ); +} + +function isPrintableHistoryMessage(msg) { + if (!msg?.topic || HISTORY_NOISE_TOPICS.has(msg.topic)) return false; + if (msg.topic === 'AGENT_OUTPUT') return agentOutputHasPrintableHistory(msg); + return true; +} + +function selectRecentPrintableMessages(messages, limit) { + const printableMessages = messages.filter(isPrintableHistoryMessage); + if (!Number.isFinite(limit) || limit <= 0) return printableMessages; + return printableMessages.slice(-limit); +} + /** * Render messages to terminal-style output with ANSI colors (same as zeroshot logs) */ @@ -4987,6 +5182,13 @@ function renderMessagesToTerminal(clusterId, messages) { return lines.join('\n'); } +function renderRecentMessagesToTerminal(messages, limit) { + const selectedMessages = selectRecentPrintableMessages(messages, limit); + if (selectedMessages.length === 0) return ''; + + return renderMessagesToTerminal(null, selectedMessages); +} + // Get terminal width for word wrapping function getTerminalWidth() { return process.stdout.columns || 100; @@ -5412,6 +5614,15 @@ async function main() { printLegacyDistroNotice(); + try { + const { onPath, binDir } = checkBinDirOnPath(); + if (!onPath && binDir) { + printPathWarning(binDir); + } + } catch { + // Never block CLI startup on a PATH check failure + } + // Check for updates (non-blocking if offline) if (!isTest) { await checkForUpdates({ quiet: isQuiet }); @@ -5447,7 +5658,11 @@ async function main() { } // Run main -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); +if (require.main === module) { + main().catch((err) => { + console.error('Fatal error:', err.message); + process.exit(1); + }); +} + +module.exports = { renderRecentMessagesToTerminal, resolveRunMode }; diff --git a/cli/message-formatters-normal.js b/cli/message-formatters-normal.js index 7bfaad04..d6733a48 100644 --- a/cli/message-formatters-normal.js +++ b/cli/message-formatters-normal.js @@ -7,6 +7,7 @@ */ const chalk = require('chalk'); +const { EVENT_COPY, formatMergeStatus } = require('./event-copy'); /** * Format AGENT_LIFECYCLE events @@ -115,7 +116,9 @@ function formatIssueOpened(msg, prefix, timestamp, shownNewTaskForCluster, print * @returns {boolean} True if message was handled */ function formatImplementationReady(msg, prefix, timestamp, print = console.log) { - print(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow('βœ… IMPLEMENTATION READY')}`); + print( + `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow(`βœ… ${EVENT_COPY.IMPLEMENTATION_READY.toUpperCase()}`)}` + ); if (msg.content?.data?.commit) { print( @@ -233,7 +236,9 @@ function formatPrCreated(msg, prefix, timestamp, print = console.log) { print(''); // Blank line before PR notification print(chalk.bold.green(`${'─'.repeat(60)}`)); - print(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('πŸŽ‰ PULL REQUEST CREATED')}`); + print( + `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green(`πŸŽ‰ ${EVENT_COPY.PR_CREATED.toUpperCase()}`)}` + ); if (prNumber) { print(`${prefix} ${chalk.gray('PR:')} ${chalk.cyan(`#${prNumber}`)}`); @@ -242,6 +247,13 @@ function formatPrCreated(msg, prefix, timestamp, print = console.log) { print(`${prefix} ${chalk.gray('URL:')} ${chalk.blue(prUrl)}`); } + const mergeStatus = formatMergeStatus(msg.content?.data?.merged); + if (mergeStatus) { + print( + `${prefix} ${chalk.gray('Merge:')} ${mergeStatus === 'merged' ? chalk.green(mergeStatus) : chalk.yellow(mergeStatus)}` + ); + } + print(chalk.bold.green(`${'─'.repeat(60)}`)); return true; } diff --git a/cli/message-formatters-watch.js b/cli/message-formatters-watch.js index b09613b0..72d5624b 100644 --- a/cli/message-formatters-watch.js +++ b/cli/message-formatters-watch.js @@ -9,6 +9,7 @@ const { getColorForSender, parseDataField, } = require('./message-formatter-utils'); +const { EVENT_COPY, formatMergeStatus } = require('./event-copy'); /** * Format AGENT_ERROR for watch mode @@ -49,7 +50,7 @@ function formatIssueOpened(msg, clusterPrefix) { function formatImplementationReady(msg, clusterPrefix) { const agentColor = getColorForSender(msg.sender); const agentName = agentColor(msg.sender); - const eventText = `${agentName} completed implementation`; + const eventText = `${agentName} ${EVENT_COPY.IMPLEMENTATION_READY.toLowerCase()}`; console.log(`${clusterPrefix} ${eventText}`); } @@ -109,7 +110,11 @@ function formatPrCreated(msg, clusterPrefix) { const agentColor = getColorForSender(msg.sender); const agentName = agentColor(msg.sender); const prNum = msg.content?.data?.pr_number || ''; - const eventText = `${agentName} created PR${prNum ? ` #${prNum}` : ''}`; + let eventText = `${agentName} ${EVENT_COPY.PR_CREATED.toLowerCase()}${prNum ? ` #${prNum}` : ''}`; + const mergeStatus = formatMergeStatus(msg.content?.data?.merged); + if (mergeStatus) { + eventText += chalk.dim(` β€” ${mergeStatus}`); + } console.log(`${clusterPrefix} ${eventText}`); } @@ -182,4 +187,6 @@ function formatWatchMode(msg, isActive) { module.exports = { formatWatchMode, + formatImplementationReady, + formatPrCreated, }; diff --git a/docs/brand/README.md b/docs/brand/README.md new file mode 100644 index 00000000..6c88b3dc --- /dev/null +++ b/docs/brand/README.md @@ -0,0 +1,29 @@ +# Zeroshot brand assets + +Zeroshot's repo/social visuals, part of the shared **The Open Engine** system (sibling to Opcore). Fraunces wordmark with a rust period (`Zeroshot.`), the engraved **guillochΓ© seal** (a verification variant of the family: an executor-verifier lemniscate whose two lobes cross on the rust verdict node), engineering-plate registration ticks, and the `β„– 001` serial. + +## Files + +| File | What | Size | +| ----------------------------------------------------- | ------------------------------------------------------------------- | --------- | +| `zeroshot-hero-light.png` / `zeroshot-hero-dark.png` | README hero (``, light/dark) | 2560Γ—640 | +| `zeroshot-og.png` | Social / OpenGraph card (dark) | 2400Γ—1260 | +| `zeroshot-seal.svg` | Standalone seal | n/a | +| `zeroshot-hero-{light,dark}.html`, `zeroshot-og.html` | Reproducible sources (real Fraunces via Google Fonts + inline seal) | n/a | + +## Tokens + +Rust `#C2240C`, the single accent, **semantic only** (the period, the verdict/PASS mark, one rule; never decoration or fill) Β· cream `#FAF7F1` Β· ink `#171411` Β· OG dark `#14110E`. Type: **Fraunces** (wordmark + headlines), **Spline Sans** (body), system mono (labels, `β„–`). + +## Re-render + +The `.html` files are the source of truth. Render to PNG with headless Chrome at 2Γ— device scale. Puppeteer is not a project dependency; install it on demand (`npx puppeteer browsers install chrome`) or run the snippet below with a one-off `npx -p puppeteer node`: + +```js +const puppeteer = require('puppeteer'); +const page = await (await puppeteer.launch()).newPage(); +await page.setViewport({ width: 1280, height: 320, deviceScaleFactor: 2 }); // OG: 1200x630 +await page.goto('file://.../zeroshot-hero-light.html', { waitUntil: 'networkidle0' }); +await page.evaluate(() => document.fonts.ready); +await (await page.$('.hero')).screenshot({ path: 'zeroshot-hero-light.png' }); // OG: '.og' +``` diff --git a/docs/brand/social/discord-dark.png b/docs/brand/social/discord-dark.png new file mode 100644 index 00000000..25baf032 Binary files /dev/null and b/docs/brand/social/discord-dark.png differ diff --git a/docs/brand/social/discord-light.png b/docs/brand/social/discord-light.png new file mode 100644 index 00000000..fb3cdad2 Binary files /dev/null and b/docs/brand/social/discord-light.png differ diff --git a/docs/brand/social/linkedin-dark.png b/docs/brand/social/linkedin-dark.png new file mode 100644 index 00000000..ec70e34d Binary files /dev/null and b/docs/brand/social/linkedin-dark.png differ diff --git a/docs/brand/social/linkedin-light.png b/docs/brand/social/linkedin-light.png new file mode 100644 index 00000000..b0240169 Binary files /dev/null and b/docs/brand/social/linkedin-light.png differ diff --git a/docs/brand/social/website-dark.png b/docs/brand/social/website-dark.png new file mode 100644 index 00000000..9167d825 Binary files /dev/null and b/docs/brand/social/website-dark.png differ diff --git a/docs/brand/social/website-light.png b/docs/brand/social/website-light.png new file mode 100644 index 00000000..85f89183 Binary files /dev/null and b/docs/brand/social/website-light.png differ diff --git a/docs/brand/social/x-dark.png b/docs/brand/social/x-dark.png new file mode 100644 index 00000000..0a7a36fa Binary files /dev/null and b/docs/brand/social/x-dark.png differ diff --git a/docs/brand/social/x-light.png b/docs/brand/social/x-light.png new file mode 100644 index 00000000..e8b93b8f Binary files /dev/null and b/docs/brand/social/x-light.png differ diff --git a/docs/brand/zeroshot-hero-dark.html b/docs/brand/zeroshot-hero-dark.html new file mode 100644 index 00000000..6b60282c --- /dev/null +++ b/docs/brand/zeroshot-hero-dark.html @@ -0,0 +1,20 @@ + + +
+
LAYER 01 Β· VERIFICATION
Zeroshot.
+
Self-driving software engineering.
+
TRUST NOTHING Β· VERIFY EVERYTHING Β· INDEPENDENT VERIFIER Β· APPROVED OR REJECTED Β· EXECUTOR-VERIFIER LOOP Β· TRUST NOTHING Β· VERIFY EVERYTHING Β· INDEPENDENT VERIFIER Β·
+
+ + + + ZEROSHOT Β· LAYER 01 Β· VERIFICATION Β· INDEPENDENT VERIFIER Β· EXECUTOR-VERIFIER LOOP Β· BLIND VALIDATION Β· +
β„– 001 Β· LIVE
\ No newline at end of file diff --git a/docs/brand/zeroshot-hero-dark.png b/docs/brand/zeroshot-hero-dark.png new file mode 100644 index 00000000..2c3509f7 Binary files /dev/null and b/docs/brand/zeroshot-hero-dark.png differ diff --git a/docs/brand/zeroshot-hero-light.html b/docs/brand/zeroshot-hero-light.html new file mode 100644 index 00000000..d74e03d8 --- /dev/null +++ b/docs/brand/zeroshot-hero-light.html @@ -0,0 +1,20 @@ + + +
+
LAYER 01 Β· VERIFICATION
Zeroshot.
+
Self-driving software engineering.
+
TRUST NOTHING Β· VERIFY EVERYTHING Β· INDEPENDENT VERIFIER Β· APPROVED OR REJECTED Β· EXECUTOR-VERIFIER LOOP Β· TRUST NOTHING Β· VERIFY EVERYTHING Β· INDEPENDENT VERIFIER Β·
+
+ + + + ZEROSHOT Β· LAYER 01 Β· VERIFICATION Β· INDEPENDENT VERIFIER Β· EXECUTOR-VERIFIER LOOP Β· BLIND VALIDATION Β· +
β„– 001 Β· LIVE
\ No newline at end of file diff --git a/docs/brand/zeroshot-hero-light.png b/docs/brand/zeroshot-hero-light.png new file mode 100644 index 00000000..c3873dfb Binary files /dev/null and b/docs/brand/zeroshot-hero-light.png differ diff --git a/docs/brand/zeroshot-og.html b/docs/brand/zeroshot-og.html new file mode 100644 index 00000000..8666c1af --- /dev/null +++ b/docs/brand/zeroshot-og.html @@ -0,0 +1,23 @@ + + +
+
THE OPEN ENGINE Β· LAYER 01 / VERIFICATION
+
Zeroshot.
+
Self-driving software engineering.
+
The agent that wrote the code shouldn't be the one that says it works.
+
$ zeroshot run 123 --ship  β†’  APPROVED
+
+ + + + ZEROSHOT Β· LAYER 01 Β· VERIFICATION Β· INDEPENDENT VERIFIER Β· EXECUTOR-VERIFIER LOOP Β· BLIND VALIDATION Β· +
+
β„– 001
\ No newline at end of file diff --git a/docs/brand/zeroshot-og.png b/docs/brand/zeroshot-og.png new file mode 100644 index 00000000..d10bf75d Binary files /dev/null and b/docs/brand/zeroshot-og.png differ diff --git a/docs/brand/zeroshot-seal.svg b/docs/brand/zeroshot-seal.svg new file mode 100644 index 00000000..bdfa8a2a --- /dev/null +++ b/docs/brand/zeroshot-seal.svg @@ -0,0 +1,6 @@ + + + + + ZEROSHOT Β· LAYER 01 Β· VERIFICATION Β· INDEPENDENT VERIFIER Β· EXECUTOR-VERIFIER LOOP Β· BLIND VALIDATION Β· + \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index e050e98a..e20bf759 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,7 +12,7 @@ const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); export default [ { linterOptions: { - noInlineConfig: true, // Disallow eslint-disable - FIX CODE, don't disable rules + reportUnusedDisableDirectives: 'error', // Fail on eslint-disable that suppresses nothing }, }, js.configs.recommended, @@ -42,6 +42,8 @@ export default [ clearInterval: 'readonly', setImmediate: 'readonly', clearImmediate: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', }, }, rules: { diff --git a/lib/clusters-registry.js b/lib/clusters-registry.js new file mode 100644 index 00000000..33a11098 --- /dev/null +++ b/lib/clusters-registry.js @@ -0,0 +1,48 @@ +/** + * Single read/write path for clusters.json. + * + * All callers that need the raw registry (Orchestrator, id-detector, gc, + * socket-discovery, CLI) go through readClustersFileSync/writeClustersFileAtomic + * instead of ad-hoc JSON.parse(fs.readFileSync(...)) / fs.writeFileSync(...). + * + * Writes are atomic (temp file + rename) so a reader can never observe a + * partially-written file, even without taking the write lock. Callers that + * read-modify-write (Orchestrator._saveClusters) still need proper-lockfile + * around the whole operation to avoid losing concurrent updates. + */ + +const fs = require('fs'); +const path = require('path'); + +function clustersFilePath(storageDir) { + return path.join(storageDir, 'clusters.json'); +} + +/** + * Read clusters.json. Returns {} if missing or unparsable. + * @param {string} storageDir + * @returns {Object} + */ +function readClustersFileSync(storageDir) { + const clustersFile = clustersFilePath(storageDir); + if (!fs.existsSync(clustersFile)) { + return {}; + } + const raw = fs.readFileSync(clustersFile, 'utf8'); + return JSON.parse(raw); +} + +/** + * Write clusters.json atomically (write to a pid-scoped temp file, then rename). + * rename(2) is atomic on the same filesystem, so no reader can observe a partial file. + * @param {string} storageDir + * @param {Object} data + */ +function writeClustersFileAtomic(storageDir, data) { + const clustersFile = clustersFilePath(storageDir); + const tmpPath = `${clustersFile}.tmp-${process.pid}`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + fs.renameSync(tmpPath, clustersFile); +} + +module.exports = { clustersFilePath, readClustersFileSync, writeClustersFileAtomic }; diff --git a/lib/compose-utils.js b/lib/compose-utils.js new file mode 100644 index 00000000..c91fc6e5 --- /dev/null +++ b/lib/compose-utils.js @@ -0,0 +1,101 @@ +/** + * Docker Compose teardown resolution for worktree cleanup. + * + * Zeroshot never runs `docker compose up` β€” any compose file found in a worktree + * belongs to the target repo, not to zeroshot. Tearing it down is only safe when + * the resolved Compose project name is scoped to the worktree directory itself + * (i.e. nothing pins it to the host's real, possibly-already-running project). + */ + +const fs = require('fs'); +const path = require('path'); + +const COMPOSE_FILENAMES = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'compose.yml', + 'compose.yaml', +]; + +/** + * Reproduce Docker Compose's default project-name normalization + * (compose-go NormalizeProjectName: lowercase -> keep [a-z0-9_-] -> trim leading '_'/'-'). + * + * @param {string} name + * @returns {string} + */ +function normalizeComposeProjectName(name) { + return name + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '') + .replace(/^[-_]+/, ''); +} + +/** + * Decide whether it's safe to run `docker compose down` in a worktree, and with what args. + * Never includes `--volumes` β€” automatic cleanup must not delete named volumes. + * Skips teardown entirely when the Compose project name is pinned (top-level `name:` + * in the compose file, or `COMPOSE_PROJECT_NAME` env var), since that resolves to a + * shared/host project zeroshot did not create. + * + * @param {string} worktreePath - Path to the worktree directory + * @returns {{ shouldTeardown: boolean, reason?: string, composePath?: string, args?: string[] }} + */ +function resolveWorktreeComposeTeardown(worktreePath) { + let composePath = null; + for (const filename of COMPOSE_FILENAMES) { + const candidate = path.join(worktreePath, filename); + if (fs.existsSync(candidate)) { + composePath = candidate; + break; + } + } + + if (!composePath) { + return { shouldTeardown: false, reason: 'no compose file' }; + } + + if ( + typeof process.env.COMPOSE_PROJECT_NAME === 'string' && + process.env.COMPOSE_PROJECT_NAME.trim() !== '' + ) { + return { + shouldTeardown: false, + reason: 'pinned compose project name (shared host project)', + composePath, + }; + } + + try { + const yaml = require('js-yaml'); + const contents = fs.readFileSync(composePath, 'utf8'); + const parsed = yaml.load(contents); + if (parsed && typeof parsed === 'object' && parsed.name) { + return { + shouldTeardown: false, + reason: 'pinned compose project name (shared host project)', + composePath, + }; + } + } catch { + // Fail safe: if the compose file can't be read/parsed, we cannot confirm project + // identity is worktree-scoped, so never tear down. + return { shouldTeardown: false, reason: 'compose file unreadable or unparsable', composePath }; + } + + return { + shouldTeardown: true, + composePath, + args: [ + 'compose', + '-p', + normalizeComposeProjectName(path.basename(worktreePath)), + 'down', + '--remove-orphans', + '--timeout', + '10', + ], + }; +} + +module.exports = { resolveWorktreeComposeTeardown, normalizeComposeProjectName }; diff --git a/lib/detached-startup.js b/lib/detached-startup.js index ccaf94c3..98bb6e05 100644 --- a/lib/detached-startup.js +++ b/lib/detached-startup.js @@ -131,6 +131,7 @@ async function registerDetachedSetupCluster({ prBase: runOptions.prBase, mergeQueue: runOptions.mergeQueue || false, closeIssue: runOptions.closeIssue || null, + autoMerge: Boolean(runOptions.ship), cwd: cwd || null, } : null, @@ -146,6 +147,13 @@ async function registerDetachedSetupCluster({ }); } +async function removeDetachedSetupCluster({ clusterId, storageDir }) { + await updateClustersFile(storageDir, (clusters) => { + delete clusters[clusterId]; + return clusters; + }); +} + async function markDetachedSetupFailed({ clusterId, storageDir, error, logPath }) { await updateClustersFile(storageDir, (clusters) => { const existing = clusters[clusterId] || { id: clusterId, createdAt: Date.now() }; @@ -215,6 +223,7 @@ module.exports = { isProcessAlive, markDetachedSetupFailed, registerDetachedSetupCluster, + removeDetachedSetupCluster, resolveWaitTimeoutMs, waitForClusterRegistration, }; diff --git a/lib/id-detector.js b/lib/id-detector.js index d00dcd13..1f014d51 100644 --- a/lib/id-detector.js +++ b/lib/id-detector.js @@ -11,6 +11,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const Database = require('better-sqlite3'); +const { readClustersFileSync } = require('./clusters-registry'); /** * Detect if ID is a cluster or task @@ -20,19 +21,17 @@ const Database = require('better-sqlite3'); function detectIdType(id) { const homeDir = process.env.ZEROSHOT_HOME || process.env.HOME || process.env.USERPROFILE || os.homedir(); - const clusterFile = path.join(homeDir, '.zeroshot', 'clusters.json'); + const storageDir = path.join(homeDir, '.zeroshot'); const taskDbFile = path.join(homeDir, '.claude-zeroshot', 'store.db'); // Check clusters - if (fs.existsSync(clusterFile)) { - try { - const clusters = JSON.parse(fs.readFileSync(clusterFile, 'utf8')); - if (clusters[id]) { - return 'cluster'; - } - } catch { - // Ignore parse errors + try { + const clusters = readClustersFileSync(storageDir); + if (clusters[id]) { + return 'cluster'; } + } catch { + // Ignore parse errors } // Check tasks in SQLite diff --git a/lib/path-check.js b/lib/path-check.js new file mode 100644 index 00000000..b8dbd07f --- /dev/null +++ b/lib/path-check.js @@ -0,0 +1,63 @@ +/** + * Detects whether the npm global bin directory is on PATH, so we can warn + * the user when `npm install -g` succeeds but the `zeroshot` binary is + * unreachable (e.g. non-standard Node installs whose global bin dir isn't + * exported). + */ + +const path = require('path'); + +function getGlobalBinDir(installPrefix) { + if (process.platform === 'win32') { + return installPrefix; + } + + return path.join(installPrefix, 'bin'); +} + +function isDirOnPath(dir, pathEnv = process.env.PATH || '') { + const resolvedDir = path.resolve(dir); + + return pathEnv + .split(path.delimiter) + .filter((entry) => entry.length > 0) + .some((entry) => path.resolve(entry) === resolvedDir); +} + +function getPathExportLine(dir) { + return `export PATH="${dir}:$PATH"`; +} + +function checkBinDirOnPath(options = {}) { + if (process.platform === 'win32') { + return { onPath: true, binDir: null }; + } + + try { + const { getInstallPrefix } = require('../cli/lib/update-checker'); + const installPrefix = options.installPrefix || getInstallPrefix(options); + const binDir = getGlobalBinDir(installPrefix); + + return { onPath: isDirOnPath(binDir, options.pathEnv), binDir }; + } catch { + return { onPath: true, binDir: null }; + } +} + +function printPathWarning(binDir) { + console.error( + `[zeroshot] Warning: ${binDir} is not on your PATH β€” the 'zeroshot' command may not be found.` + ); + console.error(` ${getPathExportLine(binDir)}`); + console.error( + ' Add this line to your shell profile (~/.zshrc, ~/.bashrc, or ~/.profile) to fix this permanently.' + ); +} + +module.exports = { + getGlobalBinDir, + isDirOnPath, + getPathExportLine, + checkBinDirOnPath, + printPathWarning, +}; diff --git a/lib/run-mode.js b/lib/run-mode.js new file mode 100644 index 00000000..dc9fb9ea --- /dev/null +++ b/lib/run-mode.js @@ -0,0 +1,22 @@ +function resolveRunMode(options) { + if (options.ship) return options.docker ? 'ship+docker' : 'ship'; + if (options.pr) return options.docker ? 'pr+docker' : 'pr'; + if (options.docker) return 'docker'; + if (options.worktree) return 'worktree'; + return null; +} + +const RUN_MODE_LABELS = { + ship: 'ship (worktree + PR + auto-merge)', + 'ship+docker': 'ship (docker + PR + auto-merge)', + pr: 'pr (worktree + PR)', + 'pr+docker': 'pr (docker + PR)', + docker: 'docker (isolated container)', + worktree: 'worktree (isolated branch)', +}; + +function describeRunMode(mode) { + return RUN_MODE_LABELS[mode] || 'local (no isolation)'; +} + +module.exports = { resolveRunMode, describeRunMode }; diff --git a/lib/start-cluster.js b/lib/start-cluster.js index 9b661e4d..d13a5874 100644 --- a/lib/start-cluster.js +++ b/lib/start-cluster.js @@ -3,7 +3,9 @@ const { spawnSync } = require('child_process'); const chalk = require('chalk'); const { normalizeProviderName } = require('./provider-names'); const { getProvider } = require('../src/providers'); +const { detectProvider } = require('../src/issue-providers'); const TemplateResolver = require('../src/template-resolver'); +const { resolveRunMode } = require('./run-mode'); const PACKAGE_ROOT = path.resolve(__dirname, '..'); @@ -96,35 +98,44 @@ function buildFileInput(file) { return { file }; } -function detectRunInput(inputArg) { - const isGitHubUrl = /^https?:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/.test(inputArg); - const isGitLabUrl = /gitlab\.(com|[\w.-]+)\/[\w-]+\/[\w-]+\/-\/issues\/\d+/.test(inputArg); - const isJiraUrl = /(atlassian\.net|jira\.[\w.-]+)\/browse\/[A-Z][A-Z0-9]+-\d+/.test(inputArg); - const isAzureUrl = - /dev\.azure\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg) || - /visualstudio\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg); - const isJiraKey = /^[A-Z][A-Z0-9]+-\d+$/.test(inputArg); - const isIssueNumber = /^\d+$/.test(inputArg); - const isRepoIssue = /^[\w-]+\/[\w-]+#\d+$/.test(inputArg); +function detectRunInput(inputArg, settings = {}, forceProvider = null) { const isMarkdownFile = /\.(md|markdown)$/i.test(inputArg); - - if ( - isGitHubUrl || - isGitLabUrl || - isJiraUrl || - isAzureUrl || - isJiraKey || - isIssueNumber || - isRepoIssue - ) { - return buildIssueInput(inputArg); - } if (isMarkdownFile) { return buildFileInput(inputArg); } + + const ProviderClass = detectProvider(inputArg, settings, forceProvider); + if (ProviderClass) { + return buildIssueInput(inputArg); + } + return buildTextInput(inputArg); } +const STDIN_MARKER = '-'; + +function isStdinInput(inputArg) { + return inputArg === STDIN_MARKER; +} + +async function readStdinText(stream = process.stdin) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))) + .toString('utf8') + .trim(); +} + +function encodeStdinEnv(text) { + return Buffer.from(text, 'utf8').toString('base64'); +} + +function decodeStdinEnv(value) { + return Buffer.from(value, 'base64').toString('utf8'); +} + function resolveProviderOverride(options = {}) { const override = options.provider || options.envProvider || process.env.ZEROSHOT_PROVIDER; if (!override || (typeof override === 'string' && !override.trim())) { @@ -234,7 +245,7 @@ function buildStartOptions({ isolationImage: firstTruthy(mergedOptions.dockerImage, process.env.ZEROSHOT_DOCKER_IMAGE), worktree: anyTruthy(mergedOptions.worktree, process.env.ZEROSHOT_WORKTREE === '1'), autoPr: anyTruthy(mergedOptions.pr, process.env.ZEROSHOT_PR === '1'), - autoMerge: process.env.ZEROSHOT_MERGE === '1', + autoMerge: Boolean(mergedOptions.ship), autoPush: process.env.ZEROSHOT_PUSH === '1', modelOverride: optionalValue(modelOverride), providerOverride: optionalValue(providerOverride), @@ -246,6 +257,7 @@ function buildStartOptions({ mergeQueue: resolveMergeQueue(mergedOptions), closeIssue: resolveCloseIssue(mergedOptions), ship: mergedOptions.ship, + runMode: resolveRunMode(mergedOptions) || null, requiredQualityGates: mergedOptions.requiredQualityGates, settings, }; @@ -310,6 +322,10 @@ module.exports = { buildIssueInput, buildFileInput, detectRunInput, + isStdinInput, + readStdinText, + encodeStdinEnv, + decodeStdinEnv, resolveProviderOverride, resolveConfigPath, loadClusterConfig, diff --git a/package-lock.json b/package-lock.json index 53c6425d..209a3445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,7 @@ "node-pty": "^1.1.0", "omelette": "^0.4.17", "pidusage": "^4.0.1", - "proper-lockfile": "^4.1.2", - "puppeteer": "^24.34.0" + "proper-lockfile": "^4.1.2" }, "bin": { "zeroshot": "cli/index.js", @@ -98,6 +97,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -149,6 +149,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1010,52 +1011,6 @@ "node": ">=12" } }, - "node_modules/@puppeteer/browsers": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.0.tgz", - "integrity": "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.3", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/@puppeteer/browsers/node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -1716,12 +1671,6 @@ "node": ">=4" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@ts-morph/common": { "version": "0.12.3", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.12.3.tgz", @@ -1777,7 +1726,7 @@ "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1797,16 +1746,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -2544,6 +2483,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -2630,6 +2570,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/argv-formatter": { @@ -2688,18 +2629,6 @@ "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", "dev": true }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -2709,20 +2638,6 @@ "node": ">= 4.0.0" } }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -2745,97 +2660,6 @@ "node": "20 || >=22" } }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", - "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2856,15 +2680,6 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -3080,15 +2895,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -3178,6 +2984,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3247,19 +3054,6 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, - "node_modules/chromium-bidi": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz", - "integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -3417,6 +3211,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3431,6 +3226,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3440,6 +3236,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3452,6 +3249,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -3646,6 +3444,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -3672,6 +3471,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, "funding": [ { "type": "github", @@ -3734,19 +3534,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3817,20 +3609,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/depcheck": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/depcheck/-/depcheck-1.4.7.tgz", @@ -4028,12 +3806,6 @@ "node": ">=8" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1534754", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", - "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", - "license": "BSD-3-Clause" - }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", @@ -4165,6 +3937,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/emojilib": { @@ -4341,6 +4114,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4363,6 +4137,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -4402,6 +4177,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4420,27 +4196,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", @@ -4680,6 +4435,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -4719,6 +4475,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -4735,6 +4492,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -4746,15 +4504,6 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4860,26 +4609,6 @@ "node": ">=0.10.0" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -4903,12 +4632,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4979,15 +4702,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5273,6 +4987,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5332,6 +5047,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -5343,20 +5059,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/git-log-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", @@ -5658,6 +5360,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -5671,6 +5374,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -5740,6 +5444,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -5841,19 +5546,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, "license": "MIT" }, "node_modules/is-core-module": { @@ -5908,6 +5605,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6123,6 +5821,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -6212,6 +5911,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -6301,6 +6001,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/lint-staged": { @@ -6920,12 +6621,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -7049,6 +6744,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/multimatch": { @@ -7142,15 +6838,6 @@ "dev": true, "license": "MIT" }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", @@ -9456,38 +9143,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -9499,6 +9154,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -9511,6 +9167,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -9643,16 +9300,11 @@ "node": ">=8" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9917,15 +9569,6 @@ "dev": true, "license": "MIT" }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -9959,40 +9602,6 @@ "dev": true, "license": "ISC" }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/pug": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", @@ -10137,45 +9746,6 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "24.34.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.34.0.tgz", - "integrity": "sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.11.0", - "chromium-bidi": "12.0.1", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1534754", - "puppeteer-core": "24.34.0", - "typed-query-selector": "^2.12.0" - }, - "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.34.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.34.0.tgz", - "integrity": "sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.11.0", - "chromium-bidi": "12.0.1", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1534754", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.3.10", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10523,6 +10093,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10583,6 +10154,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -11694,49 +11266,11 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -11869,17 +11403,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -11894,6 +11417,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11947,6 +11471,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11956,6 +11481,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12211,15 +11737,6 @@ "node": ">=18" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -12477,12 +11994,6 @@ "node": ">=10" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -12538,17 +12049,11 @@ "node": ">=8" } }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12696,7 +12201,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -13285,12 +12790,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz", - "integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==", - "license": "Apache-2.0" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13387,27 +12886,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -13422,6 +12900,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -13441,6 +12920,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -13459,6 +12939,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -13480,16 +12961,6 @@ "node": ">=10" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13515,15 +12986,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index 7725e1b7..853b4028 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:all": "npm run test && npm run test:slow", "test:coverage": "c8 npm run test:unit", "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'", - "postinstall": "node scripts/fix-node-pty-permissions.js", + "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js", "start": "node cli/index.js", "typecheck": "tsc --noEmit", "typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json", @@ -94,7 +94,7 @@ "cli/", "task-lib/", "cluster-templates/", - "hooks/", + "cluster-hooks/", "docker/", "scripts/", "README.md", @@ -110,7 +110,6 @@ "node-pty": "^1.1.0", "omelette": "^0.4.17", "pidusage": "^4.0.1", - "puppeteer": "^24.34.0", "proper-lockfile": "^4.1.2" }, "devDependencies": { diff --git a/scripts/check-path.js b/scripts/check-path.js new file mode 100644 index 00000000..fd04aad6 --- /dev/null +++ b/scripts/check-path.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/** + * Postinstall check: warn if the npm global bin dir isn't on PATH. + * + * Runs after `npm install -g` regardless of whether the user can invoke + * `zeroshot` afterward (this is the only guaranteed-execution point in + * that failure scenario). + */ + +const { checkBinDirOnPath, printPathWarning } = require('../lib/path-check'); + +try { + const { onPath, binDir } = checkBinDirOnPath(); + if (!onPath && binDir) { + printPathWarning(binDir); + } +} catch (err) { + console.warn(`[postinstall] Warning: PATH check failed: ${err.message}`); +} diff --git a/scripts/validate-templates.js b/scripts/validate-templates.js index bc45fb03..719dd4f4 100755 --- a/scripts/validate-templates.js +++ b/scripts/validate-templates.js @@ -8,11 +8,15 @@ */ const path = require('path'); -const { validateTemplates } = require('../src/template-validation'); +const { validateTemplates, formatValidationReport } = require('../src/template-validation'); const TEMPLATES_DIR = path.join(__dirname, '../cluster-templates'); function parseArgs(argv) { + const changedArg = argv.find((arg) => arg.startsWith('--changed=')); + const changedFiles = changedArg + ? new Set(changedArg.split('=').slice(1).join('=').split(',').filter(Boolean)) + : null; const simModeArg = argv.find((arg) => arg.startsWith('--sim=')); const simMode = simModeArg ? simModeArg.split('=')[1] : process.env.ZEROSHOT_TEMPLATE_SIM; const randomScopeArg = argv.find((arg) => arg.startsWith('--random-scope=')); @@ -49,13 +53,16 @@ function parseArgs(argv) { maxSteps: Number.isFinite(sampleSteps) && sampleSteps > 0 ? sampleSteps : undefined, maxScenarioMs: Number.isFinite(sampleMs) && sampleMs > 0 ? sampleMs : undefined, }, + changedFiles, }; } async function main() { console.log('Validating cluster templates...\n'); - const { deep, randomSampling, randomScope, randomOptions } = parseArgs(process.argv.slice(2)); + const { deep, randomSampling, randomScope, randomOptions, changedFiles } = parseArgs( + process.argv.slice(2) + ); const report = await validateTemplates({ templatesDir: TEMPLATES_DIR, deep, @@ -64,33 +71,8 @@ async function main() { randomOptions, }); - let hasErrors = false; - - for (const { filePath, result } of report.results) { - const relativePath = path.relative(process.cwd(), filePath); - - if (!result.valid) { - hasErrors = true; - console.error(`\n❌ ${relativePath}`); - for (const error of result.errors) { - console.error(` ERROR: ${error}`); - } - continue; - } - - if (result.warnings.length > 0) { - console.warn(`\n⚠️ ${relativePath}`); - for (const warning of result.warnings) { - console.warn(` WARN: ${warning}`); - } - continue; - } - - console.log(`βœ“ ${relativePath}`); - } - - console.log(`\n${'='.repeat(60)}`); - console.log(`Validated: ${report.validated} templates, Skipped: ${report.skipped} files`); + const { lines, hasErrors } = formatValidationReport(report, { changedFiles }); + console.log(lines.join('\n')); if (hasErrors) { console.error('\n❌ VALIDATION FAILED - Fix errors above before merging\n'); diff --git a/src/agent/agent-hook-executor.js b/src/agent/agent-hook-executor.js index e6793b41..32f06591 100644 --- a/src/agent/agent-hook-executor.js +++ b/src/agent/agent-hook-executor.js @@ -141,7 +141,7 @@ async function executeHook(params) { } if (hook.action === 'verify_pull_request') { - await verifyPullRequest({ result, agent }); + await verifyPullRequest({ result, agent, autoMerge: hook.config?.autoMerge }); return; } diff --git a/src/agent/agent-task-executor.js b/src/agent/agent-task-executor.js index 91f9aee9..91dfd4b3 100644 --- a/src/agent/agent-task-executor.js +++ b/src/agent/agent-task-executor.js @@ -118,7 +118,7 @@ function buildClaudeEnv(modelSpec, options = {}) { env.ANTHROPIC_MODEL = modelSpec.model; } - // Activate AskUserQuestion blocking hook (see hooks/block-ask-user-question.py) + // Activate AskUserQuestion blocking hook (see cluster-hooks/block-ask-user-question.py) env.ZEROSHOT_BLOCK_ASK_USER = '1'; return env; @@ -446,7 +446,7 @@ function ensureAskUserQuestionHook(targetClaudeDir = null) { } // Copy hook script if not present or outdated - const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName); + const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName); if (fs.existsSync(hookScriptSrc)) { // Always copy to ensure latest version fs.copyFileSync(hookScriptSrc, hookScriptDst); @@ -524,7 +524,7 @@ function ensureDangerousGitHook(targetClaudeDir = null) { } // Copy hook script if not present or outdated - const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName); + const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName); if (fs.existsSync(hookScriptSrc)) { // Always copy to ensure latest version fs.copyFileSync(hookScriptSrc, hookScriptDst); @@ -834,6 +834,7 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnv, + windowsHide: true, }); // NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately. diff --git a/src/agent/pr-verification.js b/src/agent/pr-verification.js index 43664a2b..f2d52777 100644 --- a/src/agent/pr-verification.js +++ b/src/agent/pr-verification.js @@ -579,7 +579,9 @@ function shouldSkipVerification(adapter) { return skipVars.some((name) => process.env[name] === '1'); } -async function verifyPullRequest({ result, agent }) { +async function verifyPullRequest({ result, agent, autoMerge }) { + // Default to true (merge required) so existing callers/tests and --ship semantics stay fail-closed. + const requireMerge = autoMerge !== false; const platform = resolveVerificationPlatform(agent); const adapter = getVerificationAdapter(platform); const providerName = @@ -622,6 +624,30 @@ async function verifyPullRequest({ result, agent }) { ); } + if (!requireMerge) { + // --pr mode: the PR/MR was created and verified to exist. It's left OPEN for human + // review - do NOT poll for merge or treat an unmerged PR as a failure. + if (adapter.isMerged(prData)) { + throw new Error( + `VERIFICATION FAILED: ${adapter.itemName} #${prData.number} is already merged, ` + + 'but this run was started in review mode (autoMerge=false).' + ); + } + + agent._log( + `βœ… VERIFICATION PASSED: ${adapter.itemName} #${prData.number} created (open for human review)` + ); + publishClusterComplete(agent, { + ...buildVerificationPayload({ + platform, + prData, + reason: 'git-pusher-complete-verified', + }), + merged: adapter.isMerged(prData), + }); + return; + } + if (!adapter.isMerged(prData)) { prData = await pollForMerge({ adapter, prData, agent, cwd: agent.workingDirectory }); } diff --git a/src/agents/git-pusher-template.js b/src/agents/git-pusher-template.js index 30be4529..f0a45f17 100644 --- a/src/agents/git-pusher-template.js +++ b/src/agents/git-pusher-template.js @@ -341,7 +341,13 @@ function resolveGitHubConfig(options = {}) { (parseBool(repoGithub.closeIssue) === true ? 'always' : null) || 'never'; - return { prBase, useMergeQueue, closeIssueMode }; + // --ship (or explicit autoMerge) merges automatically; --pr alone stops after PR creation + // for human review. Repo settings can opt in to auto-merge when the caller hasn't decided. + const autoMerge = + options.autoMerge === true || + (options.autoMerge !== false && parseBool(repoGithub.autoMerge) === true); + + return { prBase, useMergeQueue, closeIssueMode, autoMerge }; } /** @@ -352,7 +358,7 @@ function resolveGitHubConfig(options = {}) { * @returns {Object|null} Platform configuration or null if unsupported */ function getPlatformConfig(platform, config = {}) { - const { prBase, useMergeQueue, closeIssueMode } = config; + const { prBase, useMergeQueue, closeIssueMode, autoMerge } = config; const PLATFORM_CONFIGS = { github: { @@ -373,6 +379,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt rebaseBranch: prBase || 'main', usesMergeQueue: useMergeQueue, closeIssueMode: closeIssueMode || 'never', + autoMerge: Boolean(autoMerge), }, gitlab: { prName: 'MR', @@ -384,6 +391,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt prUrlExample: 'https://gitlab.com/owner/repo/-/merge_requests/123', outputFields: { urlField: 'mr_url', numberField: 'mr_number', mergedField: 'merged' }, closeIssueMode: closeIssueMode || 'never', + autoMerge: Boolean(autoMerge), }, 'azure-devops': { prName: 'PR', @@ -402,6 +410,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt // Azure requires extracting PR ID from create output requiresPrIdExtraction: true, closeIssueMode: closeIssueMode || 'never', + autoMerge: Boolean(autoMerge), }, }; @@ -414,6 +423,107 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt */ const SUPPORTED_PLATFORMS = ['github', 'gitlab', 'azure-devops']; +/** + * Generate the review-mode prompt (--pr without --ship): create the PR/MR and STOP. + * No merge step, no issue-closing - the PR is left open for human review. + * @param {Object} config - Platform configuration from PLATFORM_CONFIGS + * @returns {string} The complete review-mode prompt + */ +function generateReviewModePrompt(config) { + const { prName, prNameLower, createCmd, prUrlExample, outputFields, requiresPrIdExtraction } = + config; + + return `CRITICAL: ALL VALIDATORS APPROVED. YOU ARE A TRANSPORT-ONLY GIT PUSHER. + +Your job is to preserve validator ownership: stage, commit, push, and create the ${prName} for HUMAN REVIEW. + +Do NOT edit source files, tests, configs, generated artifacts, or lockfiles. +Do NOT inspect CI logs to debug product code. +Do NOT resolve merge conflicts or rebase conflicts. +Do NOT run implementation/debugging workflows after validators hand off. +Do NOT merge the ${prName} - it is left OPEN for human review. +Do NOT close the linked issue - it stays open until a human merges the ${prName}. + +Allowed after validation: +- git add/status/commit/push +- ${createCmd.split(' ').slice(0, 3).join(' ')} +- status-only commands such as ${prName === 'PR' ? 'gh pr view/gh pr checks' : 'the platform PR/MR status command'} + +If commit hooks, push, ${prName} creation, or conflict handling requires code changes, STOP and report the blocked state in JSON. The implementation and validator agents must fix code and rerun quality gates. + +## MANDATORY STEPS - EXECUTE EACH ONE IN ORDER - DO NOT SKIP ANY STEP + +### STEP 1: Stage ALL changes (MANDATORY) +\`\`\`bash +git add -A +\`\`\` +Run this command. Do not skip it. If commit fails because hooks/checks fail, do not edit files. Output blocked JSON with the failure summary. + +### STEP 2: Check what's staged +\`\`\`bash +git status +\`\`\` +Run this. If nothing to commit, output JSON with ${outputFields.urlField}: null and stop. + +### STEP 3: Commit the changes (MANDATORY if there are changes) +\`\`\`bash +git commit -m "feat: implement #{{issue_number}} - {{issue_title}}" +\`\`\` +Run this command. Do not skip it. + +### STEP 4: Push to origin (MANDATORY) +\`\`\`bash +git push -u origin HEAD +\`\`\` +Run this. If it fails, do not edit files, rebase, or resolve conflicts. Output blocked JSON with the failure summary. + +⚠️ AFTER PUSH YOU ARE NOT DONE! CONTINUE TO STEP 5! ⚠️ + +### STEP 5: CREATE THE ${prName.toUpperCase()} (MANDATORY - YOU MUST RUN THIS COMMAND) +\`\`\`bash +${createCmd} +\`\`\` +🚨 YOU MUST RUN \`${createCmd.split(' ').slice(0, 3).join(' ')}\`! Outputting a link is NOT creating a ${prName}! 🚨 +The push output shows a "Create a ${prNameLower}" link - IGNORE IT. +You MUST run the \`${createCmd.split(' ').slice(0, 3).join(' ')}\` command above.${requiresPrIdExtraction ? '' : ` Save the actual ${prName} URL from the output.`} + +⚠️ AFTER THE ${prName} IS CREATED, YOU ARE DONE. DO NOT MERGE. DO NOT CLOSE THE ISSUE. ⚠️ + +## CRITICAL RULES +- Execute EVERY step in order (1, 2, 3, 4, 5) +- Do NOT skip git add -A +- Do NOT skip git commit +- Do NOT skip ${createCmd.split(' ').slice(0, 3).join(' ')} - THE TASK IS NOT DONE UNTIL ${prName} EXISTS +- Do NOT merge the ${prName} - this run is for human review only +- Do NOT close the issue - it stays open until a human merges the ${prName} +- Do NOT edit files after validator handoff +- Do NOT debug product failures after validator handoff +- If push or ${prName} creation fails, report it instead of fixing code +- Output JSON as soon as the ${prName} is created (OPEN, unmerged), or a non-code transport failure blocks progress +- A link from git push is NOT a ${prName} - you must run ${createCmd.split(' ').slice(0, 3).join(' ')} + +## Final Output +ONLY after the ${prName} is CREATED (left OPEN for review), output: +\`\`\`json +{"${outputFields.urlField}": "${prUrlExample}", "${outputFields.numberField}": 123, "merged": false} +\`\`\` + +If truly no changes exist, output: +\`\`\`json +{"${outputFields.urlField}": null, "${outputFields.numberField}": null, "merged": false} +\`\`\` + +If blocked after creating a ${prName}, output: +\`\`\`json +{"${outputFields.urlField}": "${prUrlExample}", "${outputFields.numberField}": 123, "merged": false, "blocked": true, "blocked_reason": "ci_failed: test job failed"} +\`\`\` + +If blocked before creating a ${prName}, output: +\`\`\`json +{"${outputFields.urlField}": null, "${outputFields.numberField}": null, "merged": false, "blocked": true, "blocked_reason": "commit_failed: pre-commit hook failed"} +\`\`\``; +} + /** * Generate the prompt for a specific platform * @param {Object} config - Platform configuration from PLATFORM_CONFIGS @@ -432,8 +542,13 @@ function generatePrompt(config) { rebaseBranch, usesMergeQueue, closeIssueMode, + autoMerge, } = config; + if (!autoMerge) { + return generateReviewModePrompt(config); + } + // Azure-specific instructions for PR ID extraction const azurePrIdNote = requiresPrIdExtraction ? `\n\nπŸ’‘ IMPORTANT: The output will contain the PR ID. You MUST extract it for the next step. @@ -614,6 +729,7 @@ If blocked before creating a ${prName}, output: * @param {boolean} [options.mergeQueue] - Use GitHub merge queue * @param {string} [options.closeIssue] - When to close issue: auto|always|never * @param {Array} [options.requiredQualityGates] - Required handoff quality gates + * @param {boolean} [options.autoMerge] - Merge the PR (--ship). False stops after PR creation (--pr). * @returns {Object} Agent configuration object * @throws {Error} If platform is not supported */ @@ -647,8 +763,9 @@ function generateGitPusherAgent(platform, options = {}) { hooks: { onComplete: { action: 'verify_pull_request', - // No config needed - verification reads from result.structured_output - // and publishes CLUSTER_COMPLETE only if verification passes + // Verification reads PR data from result.structured_output; autoMerge controls + // whether an OPEN unmerged PR counts as success (--pr) or must be merged (--ship). + config: { autoMerge: Boolean(platformConfig.autoMerge) }, }, }, output: { diff --git a/src/attach/socket-discovery.js b/src/attach/socket-discovery.js index 51000302..f7acd267 100644 --- a/src/attach/socket-discovery.js +++ b/src/attach/socket-discovery.js @@ -11,10 +11,10 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const net = require('net'); +const { readClustersFileSync } = require('../../lib/clusters-registry'); const ZEROSHOT_DIR = path.join(os.homedir(), '.zeroshot'); const SOCKET_DIR = path.join(ZEROSHOT_DIR, 'sockets'); -const CLUSTERS_FILE = path.join(ZEROSHOT_DIR, 'clusters.json'); /** * Check if an ID is a known cluster by looking up clusters.json @@ -24,8 +24,7 @@ const CLUSTERS_FILE = path.join(ZEROSHOT_DIR, 'clusters.json'); */ function isKnownCluster(id) { try { - if (!fs.existsSync(CLUSTERS_FILE)) return false; - const clusters = JSON.parse(fs.readFileSync(CLUSTERS_FILE, 'utf8')); + const clusters = readClustersFileSync(ZEROSHOT_DIR); return id in clusters; } catch { return false; diff --git a/src/claude-credentials.js b/src/claude-credentials.js new file mode 100644 index 00000000..6a3c2af9 --- /dev/null +++ b/src/claude-credentials.js @@ -0,0 +1,75 @@ +/** + * Provisions Claude Code credentials into an isolated CLAUDE_CONFIG_DIR. + * + * Claude Code only reads the macOS Keychain when using the DEFAULT config dir. + * Isolated agent runs (--worktree, --docker) use a custom CLAUDE_CONFIG_DIR, so + * a Keychain-only (OAuth subscription) login must be materialized to a file or + * every isolated agent fails auth even though the host is logged in. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('./lib/safe-exec'); + +const CREDENTIALS_BASENAME = '.credentials.json'; +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; + +/** + * Read the Claude Code OAuth credentials JSON from the macOS Keychain. + * @returns {string|null} Raw credentials JSON, or null if unavailable/invalid/non-darwin. + */ +function readKeychainCredentials() { + if (os.platform() !== 'darwin') { + return null; + } + + try { + const output = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }); + const trimmed = output.trim(); + if (!trimmed) { + return null; + } + JSON.parse(trimmed); // reject garbage output that isn't real credentials + return trimmed; + } catch { + return null; + } +} + +/** + * Copy an existing credentials file into an isolated config dir, or materialize + * one from the macOS Keychain when no source file exists. Resynced on every call + * so an expired-then-refreshed OAuth token in the Keychain is picked up per run. + * @param {{ sourceDir: string, destDir: string }} options + * @returns {boolean} true if a credentials file was written into destDir + */ +function provisionClaudeCredentials({ sourceDir, destDir }) { + fs.mkdirSync(destDir, { recursive: true }); + + const destPath = path.join(destDir, CREDENTIALS_BASENAME); + const sourcePath = path.join(sourceDir, CREDENTIALS_BASENAME); + + if (fs.existsSync(sourcePath)) { + fs.copyFileSync(sourcePath, destPath); + fs.chmodSync(destPath, 0o600); + return true; + } + + const keychainCredentials = readKeychainCredentials(); + if (keychainCredentials) { + fs.writeFileSync(destPath, keychainCredentials, { mode: 0o600 }); + return true; + } + + return false; +} + +module.exports = { + readKeychainCredentials, + provisionClaudeCredentials, +}; diff --git a/src/claude-task-runner.js b/src/claude-task-runner.js index 087d33b1..12b50784 100644 --- a/src/claude-task-runner.js +++ b/src/claude-task-runner.js @@ -287,6 +287,7 @@ class ClaudeTaskRunner extends TaskRunner { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnv, + windowsHide: true, }); let stdout = ''; diff --git a/src/isolation-manager.js b/src/isolation-manager.js index 9433b435..9926213d 100644 --- a/src/isolation-manager.js +++ b/src/isolation-manager.js @@ -20,6 +20,7 @@ const { normalizeProviderName } = require('../lib/provider-names'); const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config'); const { getProvider } = require('./providers'); const { readRepoSettings } = require('../lib/repo-settings'); +const { provisionClaudeCredentials } = require('./claude-credentials'); const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000; @@ -1036,14 +1037,12 @@ class IsolationManager { const projectsDir = path.join(configDir, 'projects'); fs.mkdirSync(projectsDir, { recursive: true }); - // Copy only credentials file (essential for auth) - const credentialsFile = path.join(sourceDir, '.credentials.json'); - if (fs.existsSync(credentialsFile)) { - fs.copyFileSync(credentialsFile, path.join(configDir, '.credentials.json')); - } + // Copy credentials file, or materialize from macOS Keychain if none exists + // (essential for auth; see src/claude-credentials.js) + provisionClaudeCredentials({ sourceDir, destDir: configDir }); // Copy hook script to block AskUserQuestion (CRITICAL for autonomous execution) - const hookScriptSrc = path.join(__dirname, '..', 'hooks', 'block-ask-user-question.py'); + const hookScriptSrc = path.join(__dirname, '..', 'cluster-hooks', 'block-ask-user-question.py'); const hookScriptDst = path.join(hooksDir, 'block-ask-user-question.py'); if (fs.existsSync(hookScriptSrc)) { fs.copyFileSync(hookScriptSrc, hookScriptDst); @@ -1651,10 +1650,14 @@ class IsolationManager { // Tear down any Docker Compose services that may have been started in this worktree. // Without this, containers keep running with host port mappings after the worktree is deleted, // blocking port allocation for the main project or other worktrees. - const composePath = path.join(worktreeInfo.path, 'docker-compose.yml'); - if (fs.existsSync(composePath)) { + // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared + // Compose project β€” only a project scoped to the worktree directory basename, which is + // the only kind zeroshot could itself have created, is touched. + const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils'); + const teardown = resolveWorktreeComposeTeardown(worktreeInfo.path); + if (teardown.shouldTeardown) { try { - runSync('docker', ['compose', 'down', '--remove-orphans', '--volumes', '--timeout', '10'], { + runSync('docker', teardown.args, { cwd: worktreeInfo.path, encoding: 'utf8', stdio: 'pipe', diff --git a/src/issue-providers/README.md b/src/issue-providers/README.md index e42651bd..32847db3 100644 --- a/src/issue-providers/README.md +++ b/src/issue-providers/README.md @@ -1,15 +1,16 @@ # Issue Providers -Multi-platform issue support for Zeroshot. Fetch issues from GitHub, GitLab, Jira, and Azure DevOps. +Multi-platform issue support for Zeroshot. Fetch issues from GitHub, GitLab, Jira, Azure DevOps, and Linear. ## Supported Providers -| Provider | CLI Tool | URL Pattern | Issue Key Format | -| ---------------- | -------- | -------------------------------------------- | --------------------- | -| **GitHub** | `gh` | `github.com/org/repo/issues/123` | `123`, `org/repo#123` | -| **GitLab** | `glab` | `gitlab.com/org/repo/-/issues/123` | `123`, `org/repo#123` | -| **Jira** | `jira` | `*.atlassian.net/browse/KEY-123` | `KEY-123` | -| **Azure DevOps** | `az` | `dev.azure.com/org/proj/_workitems/edit/123` | `123` | +| Provider | CLI Tool | URL Pattern | Issue Key Format | +| ---------------- | ---------------------- | -------------------------------------------- | --------------------- | +| **GitHub** | `gh` | `github.com/org/repo/issues/123` | `123`, `org/repo#123` | +| **GitLab** | `glab` | `gitlab.com/org/repo/-/issues/123` | `123`, `org/repo#123` | +| **Jira** | `jira` | `*.atlassian.net/browse/KEY-123` | `KEY-123` | +| **Azure DevOps** | `az` | `dev.azure.com/org/proj/_workitems/edit/123` | `123` | +| **Linear** | _(none β€” GraphQL API)_ | `linear.app/ws/issue/ENG-42` | `ENG-42` | ## Quick Start @@ -30,6 +31,11 @@ zeroshot run https://company.atlassian.net/browse/PROJ-123 # Azure DevOps zeroshot run https://dev.azure.com/org/project/_workitems/edit/123 zeroshot run 123 --devops + +# Linear +zeroshot run ENG-42 +zeroshot run https://linear.app/workspace/issue/ENG-42/some-title +zeroshot run 42 --linear ``` ## Automatic Git Remote Detection @@ -59,6 +65,7 @@ Override auto-detection with explicit provider flags: -L, --gitlab # Force GitLab as issue source -J, --jira # Force Jira as issue source -D, --devops # Force Azure DevOps as issue source +-N, --linear # Force Linear as issue source ``` **Example:** @@ -100,18 +107,24 @@ zeroshot settings set jiraProject MYPROJECT # Default project for bare numbers # Azure DevOps configuration zeroshot settings set azureOrg mycompany zeroshot settings set azureProject myproject # Default project for bare numbers + +# Linear configuration +zeroshot settings set linearApiKey lin_api_... # API key (falls back to LINEAR_API_KEY env var) +zeroshot settings set linearTeam ENG # Default team key for bare numbers (auto-derived on first use if unset) ``` ### Settings Reference -| Setting | Type | Default | Description | -| -------------------- | ------ | ------- | ------------------------------------------ | -| `defaultIssueSource` | string | github | Provider for bare numbers (123) | -| `gitlabInstance` | string | null | Self-hosted GitLab URL | -| `jiraInstance` | string | null | Self-hosted Jira URL | -| `jiraProject` | string | null | Default Jira project key for bare numbers | -| `azureOrg` | string | null | Azure DevOps organization name | -| `azureProject` | string | null | Azure DevOps project name for bare numbers | +| Setting | Type | Default | Description | +| -------------------- | ------ | ------- | --------------------------------------------------------------------------------------------------- | +| `defaultIssueSource` | string | github | Provider for bare numbers (123) | +| `gitlabInstance` | string | null | Self-hosted GitLab URL | +| `jiraInstance` | string | null | Self-hosted Jira URL | +| `jiraProject` | string | null | Default Jira project key for bare numbers | +| `azureOrg` | string | null | Azure DevOps organization name | +| `azureProject` | string | null | Azure DevOps project name for bare numbers | +| `linearApiKey` | string | null | Linear personal API key (falls back to `LINEAR_API_KEY` env var) | +| `linearTeam` | string | null | Default Linear team key for bare numbers (auto-derived from the workspace if unset and unambiguous) | ## CLI Tool Setup @@ -166,6 +179,23 @@ az login az devops configure --defaults organization=https://dev.azure.com/yourorg ``` +### Linear + +No CLI install needed β€” Linear is accessed directly via its GraphQL API. + +```bash +# Create a personal API key at https://linear.app/settings/api +zeroshot settings set linearApiKey lin_api_... + +# Or, as a fallback, export it as an env var (checked if linearApiKey is unset): +export LINEAR_API_KEY=lin_api_... +``` + +Note: `KEY-NUMBER` input (e.g. `ENG-42`) is ambiguous between Jira and Linear. +It's routed to Linear automatically when Linear is configured +(`linearApiKey`/`linearTeam`) and Jira isn't; otherwise Jira wins. Use +`--linear`/`--jira` to force a specific provider. + ## Self-Hosted Instances ### GitLab Self-Hosted diff --git a/src/issue-providers/index.js b/src/issue-providers/index.js index 5e400940..753b0730 100644 --- a/src/issue-providers/index.js +++ b/src/issue-providers/index.js @@ -7,6 +7,7 @@ const GitHubProvider = require('./github-provider'); const GitLabProvider = require('./gitlab-provider'); const JiraProvider = require('./jira-provider'); +const LinearProvider = require('./linear-provider'); const AzureDevOpsProvider = require('./azure-devops-provider'); const { detectGitContext } = require('../../lib/git-remote-utils'); @@ -180,6 +181,7 @@ function validateIssueProviderSetting(key, value) { registerProvider(GitHubProvider); registerProvider(GitLabProvider); registerProvider(JiraProvider); +registerProvider(LinearProvider); registerProvider(AzureDevOpsProvider); module.exports = { diff --git a/src/issue-providers/jira-provider.js b/src/issue-providers/jira-provider.js index b5de25b6..e1886504 100644 --- a/src/issue-providers/jira-provider.js +++ b/src/issue-providers/jira-provider.js @@ -44,8 +44,15 @@ class JiraProvider extends IssueProvider { return true; } - // Jira issue key pattern (KEY-123) + // Jira issue key pattern (KEY-123) - shared with Linear (ENG-42 vs PROJ-123). + // Defer to Linear when it's configured and Jira isn't, so the sole + // configured tracker wins instead of registration order. if (/^[A-Z][A-Z0-9]+-\d+$/.test(input)) { + const jiraConfigured = !!(settings.jiraInstance || settings.jiraProject); + const linearConfigured = !!(settings.linearApiKey || settings.linearTeam); + if (!jiraConfigured && linearConfigured) { + return false; + } return true; } diff --git a/src/issue-providers/linear-provider.js b/src/issue-providers/linear-provider.js new file mode 100644 index 00000000..13f192de --- /dev/null +++ b/src/issue-providers/linear-provider.js @@ -0,0 +1,405 @@ +/** + * Linear Provider - Fetch issues from Linear via GraphQL API + * Linear is not a git host; no CLI binary required (talks to api.linear.app directly) + */ + +const IssueProvider = require('./base-provider'); + +const AUTH_CHECK_TIMEOUT_MS = 2000; +const FETCH_TIMEOUT_MS = 10000; +const LINEAR_API_URL = 'https://api.linear.app/graphql'; +const LINEAR_URL_PATTERN = /linear\.app\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)/; +const LINEAR_KEY_PATTERN = /^[A-Z][A-Z0-9]*-\d+$/; + +class LinearProvider extends IssueProvider { + static id = 'linear'; + static displayName = 'Linear'; + + /** + * Detect Linear issue keys and URLs + * Matches: + * - Linear issue URLs (linear.app//issue/-/...) + * - Linear issue keys (TEAM-123 format) + * - Bare numbers when defaultIssueSource=linear (requires linearTeam setting) + * + * Note: Linear doesn't use git context since it's not a git hosting platform. + * Git context will never match 'linear', so this provider only activates via + * explicit Linear URLs, Linear issue keys, or settings. + * + * Note: Linear and Jira share the same KEY-NUMBER key format (e.g. ENG-42 vs + * PROJ-123), so a bare key is ambiguous between the two providers. This is + * resolved by configuration, not registration order: JiraProvider.detectIdentifier + * defers to Linear for KEY-NUMBER input when Linear is configured + * (linearApiKey/linearTeam) and Jira is not. If neither or both are configured, + * Jira wins by registration order. Use --linear or defaultIssueSource=linear + * for unambiguous Linear selection regardless of configuration. + * + * @param {string} input - Issue identifier (URL, key, or number) + * @param {Object} settings - User settings + * @param {Object|null} gitContext - Git context (unused for Linear, but kept for API consistency) + * @returns {boolean} True if this provider should handle the input + */ + static detectIdentifier(input, settings, gitContext = null) { + // Linear issue URLs + if (LINEAR_URL_PATTERN.test(input)) { + return true; + } + + // Linear issue key pattern (TEAM-123) + if (LINEAR_KEY_PATTERN.test(input)) { + return true; + } + + // Bare numbers - use shared priority cascade logic + // Linear requires linearTeam setting to convert bare numbers to issue keys + // Note: gitContext check will never match 'linear' since Linear isn't a git host + return IssueProvider.detectBareNumber(input, settings, gitContext, 'linear', { + requiredSettings: ['linearTeam'], + }); + } + + static getRequiredTool() { + // No CLI binary required - talks to Linear's GraphQL API directly via fetch + return { + name: null, + checkCmd: null, + installHint: null, + }; + } + + /** + * Validate response status and parse GraphQL JSON body. + * Shared by checkAuth and fetchIssue so both surface 401/429/non-OK/invalid-body + * failures the same way instead of falling through to a generic JSON-parse error. + * @private + */ + static async _handleGraphQLResponse(response) { + if (response.status === 401) { + throw new Error('Linear API key invalid'); + } + + if (response.status === 429) { + throw new Error('Linear API rate limit exceeded, try again later'); + } + + if (!response.ok) { + throw new Error(`Linear API request failed with status ${response.status}`); + } + + try { + return await response.json(); + } catch { + throw new Error(`Linear API returned an invalid response (status ${response.status})`); + } + } + + /** + * Resolve the Linear API key: settings-owned, with env var fallback. + * @param {Object} [settings] - User settings (may be undefined/null) + * @returns {string|null} + */ + static _resolveApiKey(settings) { + return settings?.linearApiKey || process.env.LINEAR_API_KEY || null; + } + + /** + * Recovery hints pointing at the settings-based fix for a missing/invalid key. + * @returns {string[]} + */ + static _recoveryHints() { + return [ + 'Set it: zeroshot settings set linearApiKey ', + 'Get a key at https://linear.app/settings/api', + 'Or export LINEAR_API_KEY=lin_api_...', + ]; + } + + /** + * Check Linear API key authentication via a minimal GraphQL query + */ + static async checkAuth() { + // Lazy require avoids circular dependency (settings.js lazy-loads issue-providers). + const { loadSettings } = require('../../lib/settings'); + const apiKey = LinearProvider._resolveApiKey(loadSettings()); + + if (!apiKey) { + return { + authenticated: false, + error: 'Linear API key not configured', + recovery: LinearProvider._recoveryHints(), + }; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), AUTH_CHECK_TIMEOUT_MS); + + let response; + try { + response = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey, + }, + body: JSON.stringify({ query: '{ viewer { id } }' }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + const json = await LinearProvider._handleGraphQLResponse(response); + if (json.errors) { + return { + authenticated: false, + error: 'Linear API key invalid', + recovery: ['Verify LINEAR_API_KEY at https://linear.app/settings/api'], + }; + } + + return { authenticated: true, error: null, recovery: [] }; + } catch (err) { + if (err.message === 'Linear API key invalid') { + return { + authenticated: false, + error: err.message, + recovery: ['Verify LINEAR_API_KEY at https://linear.app/settings/api'], + }; + } + + return { + authenticated: false, + error: err.message, + recovery: ['Check network connectivity', 'Verify https://api.linear.app is reachable'], + }; + } + } + + /** + * Linear-specific settings schema + */ + static getSettingsSchema() { + return { + linearTeam: { + type: 'string', + nullable: true, + default: null, + description: "Default Linear team key for bare numbers (e.g., 'ENG')", + pattern: /^[A-Z][A-Z0-9]*$/, + patternMsg: 'linearTeam must be a valid Linear team key (e.g., ENG, PROJ)', + }, + linearApiKey: { + type: 'string', + nullable: true, + default: null, + description: 'Linear personal API key (get one at https://linear.app/settings/api)', + }, + }; + } + + /** + * Fetch the workspace's Linear teams and, if there's exactly one, persist its + * key as `linearTeam` so future bare-number runs skip this round-trip. + * @private + */ + static async _resolveTeamKey(apiKey) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + let response; + try { + response = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey, + }, + body: JSON.stringify({ query: '{ teams { nodes { key } } }' }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + const json = await LinearProvider._handleGraphQLResponse(response); + if (json.errors) { + throw new Error(json.errors.map((e) => e.message).join('; ')); + } + + const teams = json.data?.teams?.nodes || []; + if (teams.length === 0) { + throw new Error('No Linear teams found for this API key.'); + } + if (teams.length > 1) { + const keys = teams.map((t) => t.key).join(', '); + throw new Error( + `Multiple Linear teams found (${keys}). Set one: zeroshot settings set linearTeam ` + ); + } + + const key = teams[0].key; + const { loadSettings, saveSettings } = require('../../lib/settings'); + const current = loadSettings(); + current.linearTeam = key; + saveSettings(current); + return key; + } + + /** + * Resolve the issue key, auto-deriving `linearTeam` for bare numbers when it + * isn't configured yet. + * @private + */ + async _resolveIssueKey(identifier, settings, apiKey) { + if (/^\d+$/.test(identifier) && !settings.linearTeam) { + const team = await LinearProvider._resolveTeamKey(apiKey); + return `${team}-${identifier}`; + } + return this._extractIssueKey(identifier, settings); + } + + async fetchIssue(identifier, settings) { + const apiKey = LinearProvider._resolveApiKey(settings); + if (!apiKey) { + throw new Error( + `Failed to fetch Linear issue: Linear API key not configured. ${LinearProvider._recoveryHints().join(' ')}` + ); + } + + try { + const issueKey = await this._resolveIssueKey(identifier, settings, apiKey); + + const query = ` + query($id: String!) { + issue(id: $id) { + identifier + number + title + description + url + # Linear defaults to a 50-node first page with no pagination here; long + # label sets or comment threads beyond that are silently truncated. + labels { + nodes { + name + } + } + comments { + nodes { + user { + name + } + createdAt + body + } + } + } + } + `; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + let response; + try { + response = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey, + }, + body: JSON.stringify({ query, variables: { id: issueKey } }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + const json = await LinearProvider._handleGraphQLResponse(response); + + if (json.errors) { + throw new Error(json.errors.map((e) => e.message).join('; ')); + } + + if (!json.data?.issue) { + throw new Error(`Linear issue not found: ${issueKey}`); + } + + return this._parseIssue(json.data.issue); + } catch (error) { + throw new Error(`Failed to fetch Linear issue: ${error.message}`); + } + } + + /** + * Extract Linear issue key from URL or return as-is + * @private + */ + _extractIssueKey(identifier, settings) { + // URL format: https://linear.app//issue/TEAM-123/... + const urlMatch = identifier.match(LINEAR_URL_PATTERN); + if (urlMatch) { + return urlMatch[1]; + } + + // Bare number: construct from linearTeam setting + if (/^\d+$/.test(identifier) && settings.linearTeam) { + return `${settings.linearTeam}-${identifier}`; + } + + // Assume it's already a key + return identifier; + } + + /** + * Parse Linear issue into standardized InputData format + * @private + */ + _parseIssue(issue) { + const labels = issue.labels?.nodes || []; + const comments = issue.comments?.nodes || []; + const description = issue.description || ''; + + let context = `# Linear Issue ${issue.identifier}\n\n`; + context += `## Title\n${issue.title}\n\n`; + + if (description) { + context += `## Description\n${description}\n\n`; + } + + if (labels.length > 0) { + context += `## Labels\n`; + context += labels.map((l) => `- ${l.name}`).join('\n'); + context += '\n\n'; + } + + if (comments.length > 0) { + context += `## Comments\n\n`; + for (const comment of comments) { + const author = comment.user?.name || 'unknown'; + context += `### ${author} (${comment.createdAt})\n`; + context += `${comment.body}\n\n`; + } + } + + const mappedLabels = labels.map((l) => ({ name: l.name })); + const mappedComments = comments.map((c) => ({ + author: { login: c.user?.name || 'unknown' }, + createdAt: c.createdAt, + body: c.body, + })); + + return { + number: issue.number, + title: issue.title, + body: description, + labels: mappedLabels, + comments: mappedComments, + url: issue.url || null, + context, + }; + } +} + +module.exports = LinearProvider; diff --git a/src/ledger.js b/src/ledger.js index bf25695a..5afa6b62 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -18,9 +18,10 @@ const { } = require('./guidance-topics'); class Ledger extends EventEmitter { - constructor(dbPath = ':memory:') { + constructor(dbPath = ':memory:', options = {}) { super(); this.dbPath = dbPath; + this.readonly = options.readonly === true; const busyTimeoutMs = (() => { const raw = process.env.ZEROSHOT_SQLITE_BUSY_TIMEOUT_MS; if (!raw) return 5000; @@ -28,12 +29,23 @@ class Ledger extends EventEmitter { return Number.isFinite(value) && value >= 0 ? value : 5000; })(); - this.db = new Database(dbPath, { timeout: busyTimeoutMs }); + // Read-only connections (CLI list/status/logs) never take a write lock on + // another process's live database and skip schema DDL entirely - the schema + // is guaranteed to already exist for any cluster with a live daemon. + this.db = this.readonly + ? new Database(dbPath, { readonly: true, fileMustExist: true, timeout: busyTimeoutMs }) + : new Database(dbPath, { timeout: busyTimeoutMs }); this.cache = new Map(); // LRU cache for queries this.cacheLimit = 1000; this._closed = false; // Track closed state to prevent write-after-close this._lastTimestamp = 0; - this._initSchema(); + + if (this.readonly) { + this._prepareStatements(); + this._loadLastTimestamp(); + } else { + this._initSchema(); + } } _initSchema() { @@ -475,7 +487,13 @@ class Ledger extends EventEmitter { if (!cluster_id) { throw new Error('cluster_id is required for getTokensByRole'); } + return this._computeTokensByRole(cluster_id); + } + /** + * @private + */ + _computeTokensByRole(cluster_id) { // Query all TOKEN_USAGE messages for this cluster const sql = `SELECT * FROM messages WHERE cluster_id = ? AND topic = 'TOKEN_USAGE' ORDER BY timestamp ASC`; const stmt = this.db.prepare(sql); @@ -531,6 +549,27 @@ class Ledger extends EventEmitter { return byRole; } + /** + * Read messageCount and tokensByRole as one consistent point-in-time view. + * Runs both queries inside a single BEGIN DEFERRED transaction so a concurrent + * writer's commit can never be observed by one query but not the other + * (the exact straddle that produced the msgs=0/$0 phantom in `zeroshot list`). + * @param {String} cluster_id - Cluster ID + * @returns {{ messageCount: number, tokensByRole: Object }} + */ + readSnapshot(cluster_id) { + if (!cluster_id) { + throw new Error('cluster_id is required for readSnapshot'); + } + if (!this._snapshotTxn) { + this._snapshotTxn = this.db.transaction((id) => ({ + messageCount: this.stmts.count.get(id).count, + tokensByRole: this._computeTokensByRole(id), + })).deferred; + } + return this._snapshotTxn(cluster_id); + } + /** * Subscribe to new messages * @param {Function} callback - Called with each new message diff --git a/src/lib/gc.js b/src/lib/gc.js index bf49b8d4..d0ce8d6d 100644 --- a/src/lib/gc.js +++ b/src/lib/gc.js @@ -9,6 +9,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const { readClustersFileSync } = require('../../lib/clusters-registry'); const DEFAULT_STORAGE_DIR = path.join(os.homedir(), '.zeroshot'); @@ -35,10 +36,8 @@ function resolveActiveClusterIdFromEnv() { */ function readKnownClusterIds(storageDir) { const ids = new Set(); - const clustersFile = path.join(storageDir, 'clusters.json'); try { - if (!fs.existsSync(clustersFile)) return ids; - const raw = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + const raw = readClustersFileSync(storageDir); for (const id of Object.keys(raw)) ids.add(id); } catch { // Corrupt/missing β€” treat as empty (safe: nothing deleted incorrectly) diff --git a/src/message-bus.js b/src/message-bus.js index 3f0dc104..15bb2663 100644 --- a/src/message-bus.js +++ b/src/message-bus.js @@ -180,6 +180,16 @@ class MessageBus extends EventEmitter { return this.ledger.getTokensByRole(cluster_id); } + /** + * Read messageCount and tokensByRole as one consistent point-in-time view + * (passthrough to ledger) + * @param {String} cluster_id - Cluster ID + * @returns {{ messageCount: number, tokensByRole: Object }} + */ + readSnapshot(cluster_id) { + return this.ledger.readSnapshot(cluster_id); + } + /** * Register a WebSocket client for broadcasts * @param {WebSocket} ws - WebSocket connection diff --git a/src/orchestrator.js b/src/orchestrator.js index eb1d4a0f..74951bd1 100644 --- a/src/orchestrator.js +++ b/src/orchestrator.js @@ -33,6 +33,7 @@ function cleanStaleLock(lockPath) { // Ignore - another process may have cleaned it } } +const { readClustersFileSync, writeClustersFileAtomic } = require('../lib/clusters-registry'); const AgentWrapper = require('./agent-wrapper'); const SubClusterWrapper = require('./sub-cluster-wrapper'); const MessageBus = require('./message-bus'); @@ -56,6 +57,24 @@ const { } = require('./command-proofs'); const crypto = require('crypto'); +/** + * Thrown when a run is rejected because the issue already has an active cluster. + * This is expected control flow (a benign guard), not a crash - callers should + * check `error.code === 'DUPLICATE_CLUSTER'` and present it without a stack trace. + */ +class DuplicateClusterError extends Error { + constructor(message, { issueNumber, existingClusterId, existingState, existingPid, ageMinutes }) { + super(message); + this.name = 'DuplicateClusterError'; + this.code = 'DUPLICATE_CLUSTER'; + this.issueNumber = issueNumber; + this.existingClusterId = existingClusterId; + this.existingState = existingState; + this.existingPid = existingPid; + this.ageMinutes = ageMinutes; + } +} + function applyModelOverride(agentConfig, modelOverride) { if (!modelOverride) return; @@ -193,20 +212,20 @@ function applyPushBlockedRepairTriggers(config) { } } +function resolveAutoMerge(options) { + return Boolean(options.ship) || Boolean(options.autoMerge); +} + function buildPrOptions(options, requiredQualityGates) { - if ( - !options.prBase && - !options.mergeQueue && - !options.closeIssue && - requiredQualityGates.length === 0 - ) { - return null; - } + // autoMerge must always be persisted (even when no other PR fields are set) so that + // `zeroshot run --pr` (autoMerge=false) vs `--ship` (autoMerge=true) survives resume. + const autoMerge = resolveAutoMerge(options); return { prBase: options.prBase || null, mergeQueue: options.mergeQueue || false, closeIssue: options.closeIssue || null, + autoMerge, ...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}), cwd: options.cwd || process.cwd(), }; @@ -216,6 +235,11 @@ class Orchestrator { constructor(options = {}) { this.clusters = new Map(); // cluster_id -> cluster object this.quiet = options.quiet || false; // Suppress verbose logging + // Read-only mode: opens ledgers without write access and skips schema DDL/ + // side-effecting bootstrap (state snapshotter). Used by CLI read commands + // (list/status/logs) so they can never contend with a live daemon's writer + // connection or mutate shared state as a side effect of a plain read. + this.readonly = options.readonly === true; // TaskRunner DI - allows injecting MockTaskRunner for testing // When set, passed to all AgentWrappers to control task execution @@ -336,14 +360,12 @@ class Orchestrator { }, }); - const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + const data = readClustersFileSync(this.storageDir); const clusterIds = Object.keys(data); this._log(`[Orchestrator] Found ${clusterIds.length} clusters in file:`, clusterIds); - // Track clusters to remove (missing .db files or 0 messages) + // Track clusters to remove (missing .db files) const clustersToRemove = []; - // Track clusters with 0 messages (corrupted from SIGINT race condition) - const corruptedClusters = []; for (const [clusterId, clusterData] of Object.entries(data)) { if (clusterData?.state === 'setup' || clusterData?.provisional === true) { @@ -362,58 +384,29 @@ class Orchestrator { } this._log(`[Orchestrator] Loading cluster: ${clusterId}`); - let cluster; try { - cluster = this._loadSingleCluster(clusterId, clusterData); + this._loadSingleCluster(clusterId, clusterData); } catch (error) { console.warn( `[Orchestrator] Skipping cluster ${clusterId}: ${error.message || String(error)}` ); continue; } - - // VALIDATION: Detect 0-message clusters (corrupted from SIGINT during initialization) - // These clusters were created before the initCompletePromise fix was applied - if (cluster && cluster.messageBus) { - const messageCount = cluster.messageBus.count({ cluster_id: clusterId }); - if (messageCount === 0) { - console.warn(`[Orchestrator] ⚠️ Cluster ${clusterId} has 0 messages (corrupted)`); - console.warn( - `[Orchestrator] This likely occurred from SIGINT during initialization.` - ); - console.warn( - `[Orchestrator] Marking as 'corrupted' - use 'zeroshot kill ${clusterId}' to remove.` - ); - corruptedClusters.push(clusterId); - // Mark cluster as corrupted for visibility in status/list commands - cluster.state = 'corrupted'; - cluster.corruptedReason = 'SIGINT during initialization (0 messages in ledger)'; - } - } } - // Clean up orphaned entries from clusters.json - if (clustersToRemove.length > 0) { + // Clean up orphaned entries from clusters.json. This is a write side effect, + // so it must never run for read-only (list/status/logs) instances - only the + // writable orchestrator that owns the registry performs cleanup. + if (!this.readonly && clustersToRemove.length > 0) { for (const clusterId of clustersToRemove) { delete data[clusterId]; } - fs.writeFileSync(clustersFile, JSON.stringify(data, null, 2)); + writeClustersFileAtomic(this.storageDir, data); this._log( `[Orchestrator] Removed ${clustersToRemove.length} orphaned cluster(s) from registry` ); } - // Log summary of corrupted clusters - if (corruptedClusters.length > 0) { - console.warn( - `\n[Orchestrator] ⚠️ Found ${corruptedClusters.length} corrupted cluster(s):` - ); - for (const clusterId of corruptedClusters) { - console.warn(` - ${clusterId}`); - } - console.warn(`[Orchestrator] Run 'zeroshot clear' to remove all corrupted clusters.\n`); - } - this._log(`[Orchestrator] Total clusters loaded: ${this.clusters.size}`); } catch (error) { console.error('[Orchestrator] Failed to load clusters:', error.message); @@ -462,6 +455,10 @@ class Orchestrator { getTokensByRole: () => ({ _total: { count: 0, inputTokens: 0, outputTokens: 0, totalCostUsd: 0 }, }), + readSnapshot: () => ({ + messageCount: 0, + tokensByRole: { _total: { count: 0, inputTokens: 0, outputTokens: 0, totalCostUsd: 0 } }, + }), pollForMessages: () => noop, on: noop, off: noop, @@ -488,6 +485,7 @@ class Orchestrator { since: (params) => ledger.since(params), getAll: (clusterId) => ledger.getAll(clusterId), getTokensByRole: (clusterId) => ledger.getTokensByRole(clusterId), + readSnapshot: (clusterId) => ledger.readSnapshot(clusterId), addWebSocketClient: noop, removeWebSocketClient: noop, on: noop, @@ -527,9 +525,11 @@ class Orchestrator { return this.clusters.get(clusterId); } - // Restore ledger and message bus + // Restore ledger and message bus. Read-only orchestrators (CLI list/status/logs) + // open the ledger without write access so they can never contend with the live + // daemon's writer connection or take a write lock on another process's database. const dbPath = path.join(this.storageDir, `${clusterId}.db`); - const ledger = new Ledger(dbPath); + const ledger = new Ledger(dbPath, { readonly: this.readonly }); const messageBus = new MessageBus(ledger); // Restore isolation manager FIRST if cluster was running in isolation mode @@ -573,7 +573,12 @@ class Orchestrator { Object.assign(clusterContext, cluster); this.clusters.set(clusterId, clusterContext); - this._startSnapshotter(clusterContext); + // The snapshotter bootstraps by publishing a STATE_SNAPSHOT message if one is + // missing - a ledger write. Read-only orchestrators must never write, so they + // skip it; they only need to read existing snapshots, not produce new ones. + if (!this.readonly) { + this._startSnapshotter(clusterContext); + } this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`); return clusterContext; @@ -720,7 +725,7 @@ class Orchestrator { const clustersFile = path.join(this.storageDir, 'clusters.json'); if (!fs.existsSync(clustersFile)) { - fs.writeFileSync(clustersFile, '{}'); + writeClustersFileAtomic(this.storageDir, {}); } return clustersFile; } @@ -781,6 +786,11 @@ class Orchestrator { return; } + // Read-only orchestrators (CLI list/status/logs) must never write the registry. + if (this.readonly) { + return; + } + const clustersFile = this._ensureClustersFile(); if (!clustersFile) { return; @@ -807,8 +817,7 @@ class Orchestrator { // Read existing clusters from file (other processes may have added clusters) let existingClusters = {}; try { - const content = fs.readFileSync(clustersFile, 'utf8'); - existingClusters = JSON.parse(content); + existingClusters = readClustersFileSync(this.storageDir); } catch (error) { console.error('[Orchestrator] Failed to read existing clusters:', error.message); } @@ -845,6 +854,8 @@ class Orchestrator { failureInfo: cluster.failureInfo || null, // Persist PR mode for completion agent selection autoPr: cluster.autoPr || false, + // Persist normalized run mode for status/list display + runMode: cluster.runMode || null, // Persist PR options for resume prOptions: cluster.prOptions || null, // Persist cluster-scoped command proof configuration for resume and dynamic agents @@ -880,8 +891,9 @@ class Orchestrator { }; } - // Write merged data - fs.writeFileSync(clustersFile, JSON.stringify(existingClusters, null, 2)); + // Write merged data atomically (temp file + rename) so no reader can ever + // observe a partially-written file. + writeClustersFileAtomic(this.storageDir, existingClusters); this._log( `[Orchestrator] Saved ${this.clusters.size} cluster(s), file now has ${Object.keys(existingClusters).length} total` ); @@ -933,7 +945,7 @@ class Orchestrator { throw lockErr; } - const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + const data = readClustersFileSync(this.storageDir); for (const [clusterId, clusterData] of Object.entries(data)) { if (!knownClusterIds.has(clusterId)) { @@ -1029,6 +1041,69 @@ class Orchestrator { }); } + /** + * Resolve input (issue from provider, file, or text) and reject duplicate active + * runs on the same issue. Deliberately allocates NOTHING (no ledger, no isolation, + * no cluster registration) so a rejection - duplicate or otherwise - has zero side + * effects on disk or in-memory state. + * @private + * @returns {Promise<{ inputData: Object, issueProviderId: string|null }>} + */ + async _resolveClusterInput(input, options, clusterId) { + if (input.issue) { + const ProviderClass = detectProvider( + input.issue, + options.settings || {}, + options.forceProvider + ); + if (!ProviderClass) { + throw new Error(`No issue provider matched input: ${input.issue}`); + } + + const provider = new ProviderClass(); + const inputData = await provider.fetchIssue(input.issue, options.settings || {}); + const issueNumber = inputData.number || null; + + // Check for duplicate active runs on same issue (unless --force) + if (issueNumber && !options.force) { + const activeClusters = this._getActiveClustersForIssue(issueNumber, clusterId); + if (activeClusters.length > 0) { + const existing = activeClusters[0]; + const age = Math.round((Date.now() - existing.createdAt) / 1000 / 60); + throw new DuplicateClusterError( + `Issue #${issueNumber} already has an active cluster:\n` + + ` Cluster: ${existing.id} (state: ${existing.state}, running for ${age}min, pid: ${existing.pid})\n\n` + + `Options:\n` + + ` 1. Kill existing: zeroshot kill ${existing.id}\n` + + ` 2. Override check: zeroshot run ${input.issue} --force\n` + + ` 3. View status: zeroshot status ${existing.id}`, + { + issueNumber, + existingClusterId: existing.id, + existingState: existing.state, + existingPid: existing.pid, + ageMinutes: age, + } + ); + } + } + + // Log clickable issue link + if (inputData.url) { + this._log(`[Orchestrator] Issue (${ProviderClass.displayName}): ${inputData.url}`); + } + + return { inputData, issueProviderId: ProviderClass.id }; + } else if (input.file) { + this._log(`[Orchestrator] File: ${input.file}`); + return { inputData: InputHelpers.createFileInput(input.file), issueProviderId: null }; + } else if (input.text) { + return { inputData: InputHelpers.createTextInput(input.text), issueProviderId: null }; + } + + throw new Error('Either issue, file, or text input is required'); + } + /** * Internal start implementation (shared by start and startWithMock) * @private @@ -1044,6 +1119,15 @@ class Orchestrator { config?.dbPath || null ); + // Resolve input (issue/file/text) and reject duplicate active runs on the same issue + // BEFORE allocating any resources. A duplicate-run rejection must have zero side + // effects: no ledger db file, no worktree/container, no clusters.json entry. + const { inputData, issueProviderId } = await this._resolveClusterInput( + input, + options, + clusterId + ); + // Create ledger and message bus with persistent storage const dbPath = config.dbPath || path.join(this.storageDir, `${clusterId}.db`); const ledger = new Ledger(dbPath); @@ -1077,10 +1161,13 @@ class Orchestrator { createdAt: Date.now(), // Track PID for zombie detection (this process owns the cluster) pid: process.pid, + // Issue number for heroshot/external tools (avoids log parsing) + issue: inputData.number || null, // Initialization completion tracking (for safe SIGINT handling) initCompletePromise, _resolveInitComplete: resolveInitComplete, autoPr: options.autoPr || false, + runMode: options.runMode || null, requiredQualityGates, commandProofs, // PR configuration options (persisted for resume) @@ -1088,7 +1175,7 @@ class Orchestrator { // Model override for all agents (applied to dynamically added agents) modelOverride: options.modelOverride || null, // Issue provider tracking (where issue was fetched from) - issueProvider: null, // Set after fetching issue (github, gitlab, jira, azure-devops) + issueProvider: issueProviderId, // null unless input.issue (github, gitlab, jira, azure-devops) // Git platform tracking (where PR/MR will be created - independent of issue provider) gitPlatform: null, // Set when --pr mode is active // Isolation state (only if enabled) @@ -1126,63 +1213,8 @@ class Orchestrator { }); try { - // Fetch input (issue from provider, file, or text) - let inputData; - if (input.issue) { - // Detect provider and fetch issue - const ProviderClass = detectProvider( - input.issue, - options.settings || {}, - options.forceProvider - ); - if (!ProviderClass) { - throw new Error(`No issue provider matched input: ${input.issue}`); - } - - const provider = new ProviderClass(); - inputData = await provider.fetchIssue(input.issue, options.settings || {}); - - // Store issue provider for logging/debugging and cross-provider workflows - cluster.issueProvider = ProviderClass.id; - - // Store issue number for heroshot/external tools (avoids log parsing) - cluster.issue = inputData.number || null; - - // Persist issue number early so supervisors can associate this cluster with the issue even if start fails. - await this._saveClusters().catch((err) => { - console.warn(`[Orchestrator] Failed to persist issue number for ${clusterId}:`, err); - }); - - // Check for duplicate active runs on same issue (unless --force) - if (cluster.issue && !options.force) { - const activeClusters = this._getActiveClustersForIssue(cluster.issue, clusterId); - if (activeClusters.length > 0) { - const existing = activeClusters[0]; - const age = Math.round((Date.now() - existing.createdAt) / 1000 / 60); - throw new Error( - `Issue #${cluster.issue} already has an active cluster:\n` + - ` Cluster: ${existing.id} (state: ${existing.state}, running for ${age}min, pid: ${existing.pid})\n\n` + - `Options:\n` + - ` 1. Kill existing: zeroshot kill ${existing.id}\n` + - ` 2. Override check: zeroshot run ${input.issue} --force\n` + - ` 3. View status: zeroshot status ${existing.id}` - ); - } - } - - // Log clickable issue link - if (inputData.url) { - this._log(`[Orchestrator] Issue (${ProviderClass.displayName}): ${inputData.url}`); - } - } else if (input.file) { - inputData = InputHelpers.createFileInput(input.file); - this._log(`[Orchestrator] File: ${input.file}`); - } else if (input.text) { - inputData = InputHelpers.createTextInput(input.text); - } else { - throw new Error('Either issue, file, or text input is required'); - } - + // Input (issue/file/text) was already resolved and duplicate-checked in + // _resolveClusterInput() before any resource was allocated (see above). commandProofs = mergeCommandProofs( commandProofs, resolveClusterCommandProofs(config, options, inputData.context) @@ -1793,6 +1825,7 @@ class Orchestrator { mergeQueue: options.mergeQueue, closeIssue: options.closeIssue, requiredQualityGates: options.requiredQualityGates, + autoMerge: resolveAutoMerge(options), cwd: options.cwd, }); @@ -1908,22 +1941,29 @@ class Orchestrator { * @private */ _teardownWorktreeCompose(worktreePath) { - const composePath = path.join(worktreePath, 'docker-compose.yml'); - if (!fs.existsSync(composePath)) return; + // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared + // Compose project β€” only a project scoped to the worktree directory basename, which is + // the only kind zeroshot could itself have created, is touched. + const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils'); + const teardown = resolveWorktreeComposeTeardown(worktreePath); + if (!teardown.shouldTeardown) { + if (teardown.composePath) { + this._log( + `[Orchestrator] Skipping Docker Compose teardown in ${worktreePath}: ${teardown.reason}` + ); + } + return; + } try { this._log(`[Orchestrator] Tearing down Docker Compose services in ${worktreePath}...`); const { spawnSync } = require('child_process'); - const result = spawnSync( - 'docker', - ['compose', 'down', '--remove-orphans', '--volumes', '--timeout', '10'], - { - cwd: worktreePath, - encoding: 'utf8', - stdio: 'pipe', - timeout: 30000, - } - ); + const result = spawnSync('docker', teardown.args, { + cwd: worktreePath, + encoding: 'utf8', + stdio: 'pipe', + timeout: 30000, + }); if (result.status !== 0 || result.error) { const detail = result.error?.message || result.stderr || 'no stderr'; throw new Error(`Docker Compose teardown failed in ${worktreePath}: ${detail}`); @@ -3583,6 +3623,24 @@ Continue from where you left off. Review your previous output to understand what } } + /** + * Read messageCount for a cluster without fabricating a value on failure. + * A confirmed zero (ledger read succeeded, cluster has no messages) and a + * failed read (ledger unavailable) must stay distinguishable - returning 0 + * for both is what produced the phantom "failed/msgs=0" state in list/status. + * @private + */ + _readMessageCount(cluster, clusterId) { + try { + return cluster.messageBus.readSnapshot(clusterId).messageCount; + } catch (error) { + console.error( + `[Orchestrator] Failed to read message count for cluster ${clusterId}: ${error.message || String(error)}` + ); + return null; + } + } + /** * Get cluster status * @param {String} clusterId - Cluster ID @@ -3625,18 +3683,11 @@ Continue from where you left off. Review your previous output to understand what pid: cluster.pid || null, createdAt: cluster.createdAt, agents: cluster.agents.map((a) => a.getState()), - messageCount: (() => { - try { - return cluster.messageBus.count({ cluster_id: clusterId }); - } catch { - // Cluster may have closed its ledger during startup failure cleanup. - // Status/list should remain safe to call for visibility + supervisor cleanup. - return 0; - } - })(), + messageCount: this._readMessageCount(cluster, clusterId), setupLogPath: cluster.setupLogPath || null, setupStage: cluster.setupStage || null, failureInfo: cluster.failureInfo || null, + runMode: cluster.runMode || null, }; } @@ -3665,19 +3716,12 @@ Continue from where you left off. Review your previous output to understand what createdAt: cluster.createdAt, issue: cluster.issue || null, agentCount: cluster.agents.length, - messageCount: (() => { - try { - return cluster.messageBus.count({ cluster_id: cluster.id }); - } catch { - // Cluster may have closed its ledger during startup failure cleanup. - // List should remain safe to call for cleanup routines. - return 0; - } - })(), + messageCount: this._readMessageCount(cluster, cluster.id), pid: cluster.pid || null, setupLogPath: cluster.setupLogPath || null, setupStage: cluster.setupStage || null, failureInfo: cluster.failureInfo || null, + runMode: cluster.runMode || null, }; }); } @@ -3984,6 +4028,82 @@ Continue from where you left off. Review your previous output to understand what dryRun: options.dryRun || false, }); } + + /** + * Explicitly detect clusters corrupted by SIGINT during initialization + * (state='running' but the ledger never received its first message). + * + * This replaces the old unconditional 0-message check that used to run inside + * _loadClusters() on every list/status call: that check mutated cluster.state + * as a side effect of a plain read, and a single racy count() (e.g. against a + * writer's just-opened connection) was enough to misclassify a healthy running + * cluster as corrupted. This method is only ever invoked explicitly (from the + * `gc` CLI flow), never from getStatus()/listClusters(), and requires: + * - the cluster has been running longer than `graceMs` (default 60s), so a + * brand-new cluster whose first message hasn't landed yet isn't flagged, and + * - two independent reads 250ms apart both observe 0 messages, so a single + * transient/racy read can't trigger a false positive. + * + * @param {object} [options] + * @param {number} [options.graceMs=60000] + * @returns {Promise} IDs newly marked corrupted + */ + async detectCorruptedClusters(options = {}) { + const graceMs = typeof options.graceMs === 'number' ? options.graceMs : 60000; + const now = Date.now(); + const corrupted = []; + + for (const cluster of this.clusters.values()) { + if (this._isSetupCluster(cluster)) continue; + if (cluster.state !== 'running') continue; + if (!cluster.createdAt || now - cluster.createdAt < graceMs) continue; + if (!cluster.messageBus) continue; + + let firstCount; + try { + firstCount = cluster.messageBus.count({ cluster_id: cluster.id }); + } catch (error) { + console.warn( + `[Orchestrator] Skipping corruption check for ${cluster.id}: ${error.message || String(error)}` + ); + continue; + } + if (firstCount !== 0) continue; + + await new Promise((resolve) => setTimeout(resolve, 250)); + + let secondCount; + try { + secondCount = cluster.messageBus.count({ cluster_id: cluster.id }); + } catch (error) { + console.warn( + `[Orchestrator] Skipping corruption check for ${cluster.id}: ${error.message || String(error)}` + ); + continue; + } + if (secondCount !== 0) continue; + + console.warn(`[Orchestrator] ⚠️ Cluster ${cluster.id} has 0 messages (corrupted)`); + console.warn(`[Orchestrator] This likely occurred from SIGINT during initialization.`); + console.warn( + `[Orchestrator] Marking as 'corrupted' - use 'zeroshot kill ${cluster.id}' to remove.` + ); + cluster.state = 'corrupted'; + cluster.corruptedReason = 'SIGINT during initialization (0 messages in ledger)'; + corrupted.push(cluster.id); + } + + if (corrupted.length > 0) { + await this._saveClusters(); + } + + return corrupted; + } } +// Exported for testing (PR options persistence, e.g. autoMerge for --pr vs --ship). +Orchestrator.buildPrOptions = buildPrOptions; +Orchestrator.resolveAutoMerge = resolveAutoMerge; + module.exports = Orchestrator; +module.exports.DuplicateClusterError = DuplicateClusterError; diff --git a/src/preflight.js b/src/preflight.js index cd14da6a..f0d257f7 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -21,6 +21,7 @@ const { const { loadSettings, getClaudeCommand } = require('../lib/settings.js'); const { normalizeProviderName } = require('../lib/provider-names'); const { detectGitContext } = require('../lib/git-remote-utils'); +const { readKeychainCredentials } = require('./claude-credentials'); /** * Validation result @@ -133,17 +134,12 @@ function checkMacOsKeychain() { return { authenticated: false, error: 'Not macOS' }; } - try { - // Check if Claude Code credentials exist in Keychain - execSync('security find-generic-password -s "Claude Code-credentials"', { - encoding: 'utf8', - stdio: 'pipe', - timeout: 2000, - }); + // Reuses the same Keychain read that provisions isolated agent configs, so a + // green preflight here genuinely predicts runtime auth availability. + if (readKeychainCredentials()) { return { authenticated: true, error: null }; - } catch { - return { authenticated: false, error: 'No credentials in Keychain' }; } + return { authenticated: false, error: 'No credentials in Keychain' }; } /** @@ -523,7 +519,7 @@ function validateGitRequirement() { * @param {string} options.provider - Provider override * @returns {ValidationResult} */ -function runPreflight(options = {}) { +async function runPreflight(options = {}) { const errors = []; const warnings = []; @@ -562,8 +558,8 @@ function runPreflight(options = {}) { if (ProviderClass) { const tool = ProviderClass.getRequiredTool(); - // Check if tool is installed - if (!commandExists(tool.name)) { + // Check if tool is installed (providers with no CLI binary set tool.name to null) + if (tool.name && !commandExists(tool.name)) { errors.push( formatError( `${ProviderClass.displayName} CLI (${tool.name}) not installed`, @@ -577,7 +573,7 @@ function runPreflight(options = {}) { // This ensures we check auth for the actual target, not the current repo const targetHost = options.targetHost || detectGitContext(options.cwd || process.cwd())?.host; - const authResult = ProviderClass.checkAuth(targetHost); + const authResult = await ProviderClass.checkAuth(targetHost); if (!authResult.authenticated) { errors.push( formatError( @@ -628,7 +624,7 @@ function runPreflight(options = {}) { } else if (tool && ProviderClass) { // Check provider authentication (abstracted per provider) // Pass hostname for multi-instance providers (e.g., GitLab with self-hosted) - const authResult = ProviderClass.checkAuth(prGitContext?.host); + const authResult = await ProviderClass.checkAuth(prGitContext?.host); if (!authResult.authenticated) { errors.push( formatError( @@ -673,8 +669,8 @@ function runPreflight(options = {}) { * @param {boolean} options.quiet - Suppress success messages * @param {string} options.provider - Provider override */ -function requirePreflight(options = {}) { - const result = runPreflight(options); +async function requirePreflight(options = {}) { + const result = await runPreflight(options); // Print warnings regardless of success if (result.warnings.length > 0) { diff --git a/src/providers/base-provider.js b/src/providers/base-provider.js index eb11ea40..ad69c31a 100644 --- a/src/providers/base-provider.js +++ b/src/providers/base-provider.js @@ -248,16 +248,17 @@ class BaseProvider { ); } + // minLevel/maxLevel are the user's floor/ceiling cost guardrails, not a hard + // constraint on template-pinned levels. Clamp an out-of-range level into range + // rather than throwing: rejecting made a single-level clamp (min === max) + // unusable with any template that pins a different level, and broke read-only + // commands like `zeroshot logs` that resolve model specs for display (#162). if (maxLevel && rank(level) > rank(maxLevel)) { - throw new Error( - `Level "${level}" exceeds maxLevel "${maxLevel}" for provider "${this.name}"` - ); + return maxLevel; } if (minLevel && rank(level) < rank(minLevel)) { - throw new Error( - `Level "${level}" is below minLevel "${minLevel}" for provider "${this.name}"` - ); + return minLevel; } return level; diff --git a/src/status-footer.js b/src/status-footer.js index 51052d53..2fb94fd9 100644 --- a/src/status-footer.js +++ b/src/status-footer.js @@ -118,6 +118,7 @@ class StatusFooter { this.scrollRegionSet = false; this.clusterId = null; this.clusterState = 'initializing'; + this.runMode = null; this.startTime = Date.now(); this.messageBus = null; // MessageBus for token usage tracking @@ -392,6 +393,14 @@ class StatusFooter { this.clusterState = state; } + /** + * Set the armed run mode (e.g. 'ship', 'pr', 'worktree', 'docker') + * @param {string|null} mode + */ + setRunMode(mode) { + this.runMode = mode; + } + /** * Register an agent for monitoring * @param {AgentState} agentState @@ -677,6 +686,10 @@ class StatusFooter { content += ` ${COLORS.cyan}${COLORS.bold}${shortId}${COLORS.reset} `; } + if (this.runMode) { + content += `${COLORS.dim}[${this.runMode}]${COLORS.reset} `; + } + // Fill with border const contentLen = this.stripAnsi(content).length; const padding = Math.max(0, width - contentLen - 1); @@ -879,7 +892,6 @@ class StatusFooter { * @returns {string} */ stripAnsi(str) { - // eslint-disable-next-line no-control-regex return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); } diff --git a/src/template-validation/index.js b/src/template-validation/index.js index 6766bffa..d6a93759 100644 --- a/src/template-validation/index.js +++ b/src/template-validation/index.js @@ -332,7 +332,10 @@ function ensureCompletionHandler(config, options = {}) { }; } +const { formatValidationReport } = require('./report-formatter'); + module.exports = { validateTemplates, validateTemplateConfig, + formatValidationReport, }; diff --git a/src/template-validation/report-formatter.js b/src/template-validation/report-formatter.js new file mode 100644 index 00000000..8359319c --- /dev/null +++ b/src/template-validation/report-formatter.js @@ -0,0 +1,55 @@ +const path = require('node:path'); + +function formatEntry({ relativePath, result, changedFiles, lines, suppressed }) { + if (!result.valid) { + lines.push(`\n❌ ${relativePath}`); + for (const error of result.errors) { + lines.push(` ERROR: ${error}`); + } + return true; + } + + if (result.warnings.length > 0) { + if (changedFiles === null || changedFiles.has(relativePath)) { + lines.push(`\n⚠️ ${relativePath}`); + for (const warning of result.warnings) { + lines.push(` WARN: ${warning}`); + } + } else { + suppressed.count += result.warnings.length; + suppressed.fileCount += 1; + } + return false; + } + + lines.push(`βœ“ ${relativePath}`); + return false; +} + +function formatValidationReport(report, { changedFiles = null, cwd = process.cwd() } = {}) { + const lines = []; + const suppressed = { count: 0, fileCount: 0 }; + let hasErrors = false; + + for (const { filePath, result } of report.results) { + const relativePath = path.relative(cwd, filePath); + if (formatEntry({ relativePath, result, changedFiles, lines, suppressed })) { + hasErrors = true; + } + } + + if (suppressed.count > 0) { + lines.push( + `\n${suppressed.count} pre-existing warning(s) across ${suppressed.fileCount} unrelated template(s) β€” run \`npm run validate:templates\` for detail` + ); + } + + lines.push(`\n${'='.repeat(60)}`); + lines.push(`Validated: ${report.validated} templates, Skipped: ${report.skipped} files`); + + return { lines, hasErrors }; +} + +module.exports = { + formatValidationReport, +}; diff --git a/src/worktree-claude-config.js b/src/worktree-claude-config.js index 25ac2edc..da420462 100644 --- a/src/worktree-claude-config.js +++ b/src/worktree-claude-config.js @@ -3,6 +3,7 @@ const os = require('os'); const path = require('path'); const { resolveWorktreeRoot } = require('./worktree-tooling-env'); +const { provisionClaudeCredentials } = require('./claude-credentials'); const CLAUDE_DIRNAME = '.claude'; const SETTINGS_BASENAME = 'settings.json'; @@ -60,15 +61,6 @@ function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } -function copyIfExists(sourcePath, destPath) { - if (!fs.existsSync(sourcePath)) { - return; - } - - ensureDir(path.dirname(destPath)); - fs.copyFileSync(sourcePath, destPath); -} - function resolveRepoClaudeConfig(worktreeRoot) { const configDir = path.join(worktreeRoot, CLAUDE_DIRNAME); const settingsPath = path.join(configDir, SETTINGS_BASENAME); @@ -105,10 +97,7 @@ function prepareClaudeConfigDir(options = {}) { ensureDir(path.join(overlayDir, 'hooks')); ensureDir(path.join(overlayDir, 'projects')); - copyIfExists( - path.join(sourceDir, '.credentials.json'), - path.join(overlayDir, '.credentials.json') - ); + provisionClaudeCredentials({ sourceDir, destDir: overlayDir }); const sourceSettings = readJsonIfExists(path.join(sourceDir, SETTINGS_BASENAME)) || {}; const repoSettings = readJsonIfExists(repoConfig.settingsPath) || {}; diff --git a/task-lib/runner.js b/task-lib/runner.js index 6c766d37..fb525b78 100644 --- a/task-lib/runner.js +++ b/task-lib/runner.js @@ -162,6 +162,7 @@ function spawnWatcher({ watcherScript, id, cwd, logFile, finalArgs, watcherConfi { detached: true, stdio: 'ignore', + windowsHide: true, } ); diff --git a/task-lib/watcher.js b/task-lib/watcher.js index 12b8451f..25038438 100644 --- a/task-lib/watcher.js +++ b/task-lib/watcher.js @@ -45,6 +45,7 @@ const child = spawn(command, finalArgs, { cwd: commandSpec.cwd || cwd, env, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }); updateTask(taskId, { pid: child.pid }); diff --git a/tests/cli-run-mode.test.js b/tests/cli-run-mode.test.js new file mode 100644 index 00000000..d445f26c --- /dev/null +++ b/tests/cli-run-mode.test.js @@ -0,0 +1,39 @@ +const assert = require('assert'); +const { resolveRunMode } = require('../cli/index.js'); + +describe('resolveRunMode', () => { + it('returns "ship" when options.ship is set', () => { + assert.strictEqual(resolveRunMode({ ship: true }), 'ship'); + }); + + it('returns "ship+docker" when ship and docker are both set', () => { + assert.strictEqual(resolveRunMode({ ship: true, docker: true }), 'ship+docker'); + }); + + it('returns "pr" when options.pr is set', () => { + assert.strictEqual(resolveRunMode({ pr: true }), 'pr'); + }); + + it('returns "pr+docker" when pr and docker are both set', () => { + assert.strictEqual(resolveRunMode({ pr: true, docker: true }), 'pr+docker'); + }); + + it('returns "docker" when only options.docker is set', () => { + assert.strictEqual(resolveRunMode({ docker: true }), 'docker'); + }); + + it('returns "worktree" when only options.worktree is set', () => { + assert.strictEqual(resolveRunMode({ worktree: true }), 'worktree'); + }); + + it('returns null when no mode flags are set', () => { + assert.strictEqual(resolveRunMode({}), null); + }); + + it('prioritizes ship over pr, docker, and worktree', () => { + assert.strictEqual( + resolveRunMode({ ship: true, pr: true, docker: true, worktree: true }), + 'ship+docker' + ); + }); +}); diff --git a/tests/compose-utils.test.js b/tests/compose-utils.test.js new file mode 100644 index 00000000..cc421d9a --- /dev/null +++ b/tests/compose-utils.test.js @@ -0,0 +1,114 @@ +/** + * Regression tests for lib/compose-utils.js β€” resolveWorktreeComposeTeardown() + * + * Bug (issue #543): worktree/PR cleanup ran `docker compose down --remove-orphans --volumes` + * unconditionally. When the target repo's compose file (or COMPOSE_PROJECT_NAME) pinned a + * project name, Compose resolved to the user's real, already-running project and --volumes + * permanently deleted its named volumes. + * + * These tests would FAIL against the original behavior (which always returned a teardown + * command including --volumes, regardless of project-name pinning) and PASS against the fix. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + resolveWorktreeComposeTeardown, + normalizeComposeProjectName, +} = require('../lib/compose-utils'); + +describe('resolveWorktreeComposeTeardown', function () { + let tmpDir; + let origComposeProjectName; + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-compose-utils-')); + origComposeProjectName = process.env.COMPOSE_PROJECT_NAME; + delete process.env.COMPOSE_PROJECT_NAME; + }); + + afterEach(function () { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (origComposeProjectName === undefined) { + delete process.env.COMPOSE_PROJECT_NAME; + } else { + process.env.COMPOSE_PROJECT_NAME = origComposeProjectName; + } + }); + + it('returns shouldTeardown:false when no compose file exists', function () { + const result = resolveWorktreeComposeTeardown(tmpDir); + assert.strictEqual(result.shouldTeardown, false); + assert.strictEqual(result.reason, 'no compose file'); + }); + + it('returns shouldTeardown:true with no --volumes for an unpinned compose file', function () { + fs.writeFileSync( + path.join(tmpDir, 'docker-compose.yml'), + 'services:\n db:\n image: postgres\n' + ); + + const result = resolveWorktreeComposeTeardown(tmpDir); + assert.strictEqual(result.shouldTeardown, true); + assert.ok(!result.args.includes('--volumes'), 'must never include --volumes'); + assert.ok(result.args.includes('--remove-orphans')); + assert.strictEqual( + result.args[result.args.indexOf('-p') + 1], + normalizeComposeProjectName(path.basename(tmpDir)) + ); + }); + + it('normalizes an uppercase/special-char worktree basename to match Docker Compose default project naming', function () { + const worktreeDir = path.join(tmpDir, 'My.Worktree!'); + fs.mkdirSync(worktreeDir); + fs.writeFileSync( + path.join(worktreeDir, 'docker-compose.yml'), + 'services:\n db:\n image: postgres\n' + ); + + const result = resolveWorktreeComposeTeardown(worktreeDir); + assert.strictEqual(result.shouldTeardown, true); + assert.strictEqual(result.args[result.args.indexOf('-p') + 1], 'myworktree'); + }); + + it('returns shouldTeardown:false when the compose file pins a top-level project name', function () { + fs.writeFileSync( + path.join(tmpDir, 'docker-compose.yml'), + 'name: myproj\nservices:\n db:\n image: postgres\n' + ); + + const result = resolveWorktreeComposeTeardown(tmpDir); + assert.strictEqual(result.shouldTeardown, false); + assert.strictEqual(result.reason, 'pinned compose project name (shared host project)'); + }); + + it('returns shouldTeardown:false when COMPOSE_PROJECT_NAME env var is set', function () { + fs.writeFileSync( + path.join(tmpDir, 'docker-compose.yml'), + 'services:\n db:\n image: postgres\n' + ); + process.env.COMPOSE_PROJECT_NAME = 'shared-host-project'; + + const result = resolveWorktreeComposeTeardown(tmpDir); + assert.strictEqual(result.shouldTeardown, false); + assert.strictEqual(result.reason, 'pinned compose project name (shared host project)'); + }); + + it('finds alternate compose filenames (compose.yaml)', function () { + fs.writeFileSync(path.join(tmpDir, 'compose.yaml'), 'services:\n db:\n image: postgres\n'); + + const result = resolveWorktreeComposeTeardown(tmpDir); + assert.strictEqual(result.shouldTeardown, true); + assert.ok(result.composePath.endsWith('compose.yaml')); + }); + + it('fails safe (shouldTeardown:false) when the compose file is malformed YAML', function () { + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), ':\n - this is not: valid: yaml: ['); + + const result = resolveWorktreeComposeTeardown(tmpDir); + assert.strictEqual(result.shouldTeardown, false); + }); +}); diff --git a/tests/event-copy.test.js b/tests/event-copy.test.js new file mode 100644 index 00000000..31c5a05d --- /dev/null +++ b/tests/event-copy.test.js @@ -0,0 +1,105 @@ +const assert = require('assert'); +const { EVENT_COPY, formatMergeStatus } = require('../cli/event-copy'); +const { + formatImplementationReady: formatImplementationReadyNormal, + formatPrCreated: formatPrCreatedNormal, +} = require('../cli/message-formatters-normal'); +const { + formatImplementationReady: formatImplementationReadyWatch, + formatPrCreated: formatPrCreatedWatch, +} = require('../cli/message-formatters-watch'); + +describe('formatMergeStatus', () => { + it('returns "merged" for true (boolean or string)', () => { + assert.strictEqual(formatMergeStatus(true), 'merged'); + assert.strictEqual(formatMergeStatus('true'), 'merged'); + }); + + it('returns "auto-merge pending approval" for false (boolean or string)', () => { + assert.strictEqual(formatMergeStatus(false), 'auto-merge pending approval'); + assert.strictEqual(formatMergeStatus('false'), 'auto-merge pending approval'); + }); + + it('returns null for undefined/unknown values', () => { + assert.strictEqual(formatMergeStatus(undefined), null); + assert.strictEqual(formatMergeStatus('unknown'), null); + }); +}); + +describe('EVENT_COPY wording shared across normal and watch formatters', () => { + it('normal formatImplementationReady includes uppercased EVENT_COPY text', () => { + const lines = []; + formatImplementationReadyNormal({ content: {} }, 'prefix', 'ts', (line) => lines.push(line)); + const joined = lines.join('\n'); + assert.ok(joined.includes(EVENT_COPY.IMPLEMENTATION_READY.toUpperCase())); + }); + + it('watch formatImplementationReady includes lowercased EVENT_COPY text', () => { + const lines = []; + const origLog = console.log; + console.log = (line) => lines.push(line); + try { + formatImplementationReadyWatch({ sender: 'worker', content: {} }, 'prefix'); + } finally { + console.log = origLog; + } + const joined = lines.join('\n'); + assert.ok(joined.includes(EVENT_COPY.IMPLEMENTATION_READY.toLowerCase())); + }); + + it('normal formatPrCreated shows "auto-merge pending approval" when merged is false', () => { + const lines = []; + formatPrCreatedNormal( + { content: { data: { pr_number: 1, merged: false } } }, + 'prefix', + 'ts', + (line) => lines.push(line) + ); + const joined = lines.join('\n'); + assert.ok(joined.includes('auto-merge pending approval')); + }); + + it('normal formatPrCreated shows "merged" when merged is true', () => { + const lines = []; + formatPrCreatedNormal( + { content: { data: { pr_number: 1, merged: true } } }, + 'prefix', + 'ts', + (line) => lines.push(line) + ); + const joined = lines.join('\n'); + assert.ok(joined.includes('merged')); + }); + + it('watch formatPrCreated shows "auto-merge pending approval" when merged is false', () => { + const lines = []; + const origLog = console.log; + console.log = (line) => lines.push(line); + try { + formatPrCreatedWatch( + { sender: 'git-pusher', content: { data: { pr_number: 1, merged: false } } }, + 'prefix' + ); + } finally { + console.log = origLog; + } + const joined = lines.join('\n'); + assert.ok(joined.includes('auto-merge pending approval')); + }); + + it('watch formatPrCreated shows "merged" when merged is true', () => { + const lines = []; + const origLog = console.log; + console.log = (line) => lines.push(line); + try { + formatPrCreatedWatch( + { sender: 'git-pusher', content: { data: { pr_number: 1, merged: true } } }, + 'prefix' + ); + } finally { + console.log = origLog; + } + const joined = lines.join('\n'); + assert.ok(joined.includes('merged')); + }); +}); diff --git a/tests/export-no-puppeteer.test.js b/tests/export-no-puppeteer.test.js new file mode 100644 index 00000000..beaf8bb6 --- /dev/null +++ b/tests/export-no-puppeteer.test.js @@ -0,0 +1,43 @@ +/** + * Regression: the `export` command used to depend on puppeteer purely to + * convert an already-built HTML transcript into a PDF. That pulled a ~150MB + * headless Chrome into every install (and frequently failed to download). + * + * Export now writes print-ready HTML directly (open in a browser and + * Print -> Save as PDF). puppeteer must not return as a dependency, and the + * export path must not launch a browser at runtime. + */ +const { expect } = require('chai'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..'); + +describe('export command has no puppeteer footprint', () => { + it('does not declare puppeteer in any dependency bucket', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + for (const bucket of [ + 'dependencies', + 'optionalDependencies', + 'devDependencies', + 'peerDependencies', + ]) { + expect(pkg[bucket] || {}, `${bucket} should not contain puppeteer`).to.not.have.property( + 'puppeteer' + ); + } + }); + + it('does not launch a browser in the CLI export path', () => { + const cli = fs.readFileSync(path.join(repoRoot, 'cli', 'index.js'), 'utf8'); + expect(cli, 'export must not launch puppeteer').to.not.match(/puppeteer\.launch/); + expect(cli, "export must not import('puppeteer') at runtime").to.not.match( + /import\(\s*['"]puppeteer['"]\s*\)/ + ); + }); + + it('defaults the export format to html', () => { + const cli = fs.readFileSync(path.join(repoRoot, 'cli', 'index.js'), 'utf8'); + expect(cli).to.match(/Export format: json, markdown, html/); + }); +}); diff --git a/tests/git-pusher-pr-mode.test.js b/tests/git-pusher-pr-mode.test.js new file mode 100644 index 00000000..d4a4fcbf --- /dev/null +++ b/tests/git-pusher-pr-mode.test.js @@ -0,0 +1,144 @@ +/** + * Regression test for issue #452: + * `zeroshot run --pr` created a PR and immediately auto-merged it, + * instead of stopping at PR creation for human review. + * + * The fix threads a single `autoMerge` boolean end-to-end: + * --ship (or repo settings github.autoMerge=true) -> autoMerge=true -> create + merge + * --pr alone -> autoMerge=false -> create + STOP + */ +const assert = require('node:assert'); +const os = require('node:os'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execSync } = require('node:child_process'); + +const { generateGitPusherAgent } = require('../src/agents/git-pusher-template'); +const Orchestrator = require('../src/orchestrator'); + +function withTmpCwd(fn) { + // Avoid picking up repo settings (.zeroshot/settings.json) from this repo's own cwd. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-git-pusher-pr-mode-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function writeRepoSettings(cwd, settings) { + execSync('git init', { cwd, stdio: 'ignore' }); + const settingsDir = path.join(cwd, '.zeroshot'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify(settings, null, 2)); +} + +describe('git-pusher --pr vs --ship (autoMerge)', function () { + it('review mode (autoMerge=false): prompt has no merge step, output template is merged:false', function () { + withTmpCwd((cwd) => { + const agentConfig = generateGitPusherAgent('github', { autoMerge: false, cwd }); + + assert.strictEqual(agentConfig.hooks.onComplete.config.autoMerge, false); + assert(!/gh pr merge/i.test(agentConfig.prompt), 'prompt must not instruct gh pr merge'); + assert( + !/MERGE THE PR \(MANDATORY/i.test(agentConfig.prompt), + 'prompt must not mandate merging the PR' + ); + assert( + /Do NOT merge the PR/i.test(agentConfig.prompt), + 'prompt must explicitly say do not merge' + ); + assert( + agentConfig.prompt.includes('"merged": false'), + 'final output template must report merged:false' + ); + assert( + !agentConfig.prompt.includes('"merged": true'), + 'review-mode prompt must never claim merged:true' + ); + }); + }); + + it('ship mode (autoMerge=true): prompt still merges, config carries autoMerge=true', function () { + withTmpCwd((cwd) => { + const agentConfig = generateGitPusherAgent('github', { autoMerge: true, cwd }); + + assert.strictEqual(agentConfig.hooks.onComplete.config.autoMerge, true); + assert(/gh pr merge/i.test(agentConfig.prompt), 'ship-mode prompt must merge the PR'); + assert( + /MERGE THE PR \(MANDATORY/i.test(agentConfig.prompt), + 'ship-mode prompt must mandate merging' + ); + assert( + agentConfig.prompt.includes('"merged": true'), + 'ship-mode final output template must report merged:true' + ); + }); + }); + + it('default (no autoMerge option, no repo setting) behaves as review mode', function () { + withTmpCwd((cwd) => { + const agentConfig = generateGitPusherAgent('github', { cwd }); + assert.strictEqual(agentConfig.hooks.onComplete.config.autoMerge, false); + assert(!/gh pr merge/i.test(agentConfig.prompt)); + }); + }); + + it('--pr review mode overrides repo github.autoMerge=true', function () { + withTmpCwd((cwd) => { + writeRepoSettings(cwd, { github: { autoMerge: true } }); + + const agentConfig = generateGitPusherAgent('github', { autoMerge: false, cwd }); + + assert.strictEqual(agentConfig.hooks.onComplete.config.autoMerge, false); + assert(!/gh pr merge/i.test(agentConfig.prompt), '--pr must not inherit repo auto-merge'); + assert( + /Do NOT merge the PR/i.test(agentConfig.prompt), + '--pr must remain a human-review prompt even when repo settings enable auto-merge' + ); + }); + }); + + it('repo github.autoMerge=true only applies when the caller has not selected --pr', function () { + withTmpCwd((cwd) => { + writeRepoSettings(cwd, { github: { autoMerge: true } }); + + const agentConfig = generateGitPusherAgent('github', { cwd }); + + assert.strictEqual(agentConfig.hooks.onComplete.config.autoMerge, true); + assert(/gh pr merge/i.test(agentConfig.prompt)); + }); + }); + + it('buildPrOptions persists autoMerge=false for --pr and autoMerge=true for --ship (resume-safe)', function () { + const prOptionsForPr = Orchestrator.buildPrOptions({ ship: false }, []); + const prOptionsForShip = Orchestrator.buildPrOptions({ ship: true }, []); + + assert.strictEqual(prOptionsForPr.autoMerge, false); + assert.strictEqual(prOptionsForShip.autoMerge, true); + + // Persisted even when no other PR fields were passed (--pr with all defaults), + // otherwise the distinction is lost on `zeroshot resume`. + assert.notStrictEqual(prOptionsForPr, null); + }); + + it('resolveAutoMerge is the single source for the autoMerge decision', function () { + assert.strictEqual(Orchestrator.resolveAutoMerge({ ship: true }), true); + assert.strictEqual(Orchestrator.resolveAutoMerge({ pr: true }), false); + assert.strictEqual(Orchestrator.resolveAutoMerge({ pr: true, autoMerge: true }), true); + }); + + it('buildPrOptions and git-pusher config cannot diverge (both derive from resolveAutoMerge)', function () { + const shipOptions = { ship: true }; + const prOptions = { pr: true }; + + assert.strictEqual( + Orchestrator.buildPrOptions(shipOptions, []).autoMerge, + Orchestrator.resolveAutoMerge(shipOptions) + ); + assert.strictEqual( + Orchestrator.buildPrOptions(prOptions, []).autoMerge, + Orchestrator.resolveAutoMerge(prOptions) + ); + }); +}); diff --git a/tests/issue-providers.test.js b/tests/issue-providers.test.js index e8fdf5bb..8a86dfb4 100644 --- a/tests/issue-providers.test.js +++ b/tests/issue-providers.test.js @@ -7,6 +7,7 @@ const { detectProvider, getProvider, listProviders } = require('../src/issue-pro const GitHubProvider = require('../src/issue-providers/github-provider'); const GitLabProvider = require('../src/issue-providers/gitlab-provider'); const JiraProvider = require('../src/issue-providers/jira-provider'); +const LinearProvider = require('../src/issue-providers/linear-provider'); const AzureDevOpsProvider = require('../src/issue-providers/azure-devops-provider'); describe('Provider Registry', () => { @@ -142,6 +143,20 @@ describe('Jira Provider', () => { const settings = { defaultIssueSource: 'jira' }; expect(JiraProvider.detectIdentifier('123', settings)).to.be.false; }); + + it('defers KEY-NUMBER to Linear when Linear is configured and Jira is not', () => { + expect(JiraProvider.detectIdentifier('ENG-42', { linearApiKey: 'lin_x' })).to.be.false; + expect(JiraProvider.detectIdentifier('ENG-42', { linearTeam: 'ENG' })).to.be.false; + }); + + it('keeps Jira for KEY-NUMBER when both Jira and Linear are configured', () => { + const settings = { jiraProject: 'ENG', linearApiKey: 'lin_x' }; + expect(JiraProvider.detectIdentifier('ENG-42', settings)).to.be.true; + }); + + it('keeps Jira for KEY-NUMBER when neither is configured', () => { + expect(JiraProvider.detectIdentifier('ENG-42', {})).to.be.true; + }); }); it('getRequiredTool returns jira', () => { @@ -404,6 +419,21 @@ describe('detectProvider', () => { expect(ProviderClass).to.equal(JiraProvider); }); + it('resolves ambiguous KEY-NUMBER to Linear when Linear is configured and Jira is not', () => { + const ProviderClass = detectProvider('ENG-42', { linearApiKey: 'lin_x' }, null, null); + expect(ProviderClass).to.equal(LinearProvider); + }); + + it('resolves ambiguous KEY-NUMBER to Jira when Jira is configured', () => { + const ProviderClass = detectProvider('ENG-42', { jiraProject: 'ENG' }, null, null); + expect(ProviderClass).to.equal(JiraProvider); + }); + + it('resolves ambiguous KEY-NUMBER to Jira when neither is configured', () => { + const ProviderClass = detectProvider('ENG-42', {}, null, null); + expect(ProviderClass).to.equal(JiraProvider); + }); + it('detects Azure DevOps by URL', () => { const ProviderClass = detectProvider('https://dev.azure.com/org/proj/_workitems/edit/123', {}); expect(ProviderClass).to.equal(AzureDevOpsProvider); diff --git a/tests/linear-provider.test.js b/tests/linear-provider.test.js new file mode 100644 index 00000000..838ca2bd --- /dev/null +++ b/tests/linear-provider.test.js @@ -0,0 +1,566 @@ +/** + * Tests for Linear issue provider + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { expect } = require('chai'); +const { detectProvider, getProvider, listProviders } = require('../src/issue-providers'); +const JiraProvider = require('../src/issue-providers/jira-provider'); +const LinearProvider = require('../src/issue-providers/linear-provider'); + +const TEST_SETTINGS_FILE = path.join( + os.tmpdir(), + `zeroshot-linear-settings-test-${process.pid}.json` +); + +function withSettingsFile(contents, fn) { + const originalEnv = process.env.ZEROSHOT_SETTINGS_FILE; + fs.writeFileSync(TEST_SETTINGS_FILE, JSON.stringify(contents), 'utf8'); + process.env.ZEROSHOT_SETTINGS_FILE = TEST_SETTINGS_FILE; + return Promise.resolve() + .then(fn) + .finally(() => { + if (originalEnv === undefined) { + delete process.env.ZEROSHOT_SETTINGS_FILE; + } else { + process.env.ZEROSHOT_SETTINGS_FILE = originalEnv; + } + fs.rmSync(TEST_SETTINGS_FILE, { force: true }); + }); +} + +describe('Provider Registry (Linear)', () => { + it('listProviders includes linear', () => { + expect(listProviders()).to.include('linear'); + }); + + it('getProvider returns LinearProvider', () => { + expect(getProvider('linear')).to.equal(LinearProvider); + }); +}); + +describe('Linear Provider', () => { + describe('detectIdentifier', () => { + it('detects Linear issue URLs', () => { + expect( + LinearProvider.detectIdentifier( + 'https://linear.app/my-workspace/issue/ENG-42/some-title', + {} + ) + ).to.be.true; + }); + + it('detects Linear issue keys', () => { + expect(LinearProvider.detectIdentifier('ENG-42', {})).to.be.true; + expect(LinearProvider.detectIdentifier('A1-789', {})).to.be.true; + }); + + it('rejects invalid key formats', () => { + expect(LinearProvider.detectIdentifier('eng-42', {})).to.be.false; // lowercase + expect(LinearProvider.detectIdentifier('123', {})).to.be.false; // no key, no settings + expect(LinearProvider.detectIdentifier('ENG42', {})).to.be.false; // no dash + }); + + it('rejects non-Linear URLs', () => { + expect(LinearProvider.detectIdentifier('https://gitlab.com/org/repo/-/issues/123', {})).to.be + .false; + }); + + it('detects bare numbers when Linear is default with linearTeam', () => { + const settings = { defaultIssueSource: 'linear', linearTeam: 'ENG' }; + expect(LinearProvider.detectIdentifier('42', settings)).to.be.true; + }); + + it('rejects bare numbers when Linear is default without linearTeam', () => { + const settings = { defaultIssueSource: 'linear' }; + expect(LinearProvider.detectIdentifier('42', settings)).to.be.false; + }); + + it('rejects bare numbers with no settings at all', () => { + expect(LinearProvider.detectIdentifier('42', {})).to.be.false; + }); + }); + + describe('Jira/Linear key ambiguity', () => { + it('resolves ambiguous KEY-NUMBER to Jira by registration order', () => { + const ProviderClass = detectProvider('ENG-42', {}, null, null); + expect(ProviderClass).to.equal(JiraProvider); + }); + + it('force flag selects Linear despite ambiguity', () => { + const ProviderClass = detectProvider('ENG-42', {}, 'linear', null); + expect(ProviderClass).to.equal(LinearProvider); + }); + }); + + it('getRequiredTool signals no CLI binary required', () => { + const tool = LinearProvider.getRequiredTool(); + expect(tool.name).to.be.null; + expect(tool.checkCmd).to.be.null; + }); + + describe('_extractIssueKey', () => { + const provider = new LinearProvider(); + + it('extracts key from URL', () => { + expect( + provider._extractIssueKey('https://linear.app/ws/issue/ENG-42/some-title', {}) + ).to.equal('ENG-42'); + }); + + it('builds key from bare number + linearTeam', () => { + expect(provider._extractIssueKey('42', { linearTeam: 'ENG' })).to.equal('ENG-42'); + }); + + it('passes through an already-formed key', () => { + expect(provider._extractIssueKey('ENG-42', {})).to.equal('ENG-42'); + }); + }); + + describe('fetchIssue', () => { + let originalFetch; + let originalApiKey; + + beforeEach(() => { + originalFetch = global.fetch; + originalApiKey = process.env.LINEAR_API_KEY; + process.env.LINEAR_API_KEY = 'lin_api_test'; + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalApiKey === undefined) { + delete process.env.LINEAR_API_KEY; + } else { + process.env.LINEAR_API_KEY = originalApiKey; + } + }); + + it('returns the standardized InputData shape', async () => { + global.fetch = () => ({ + status: 200, + ok: true, + json: () => ({ + data: { + issue: { + identifier: 'ENG-42', + number: 42, + title: 'Fix the thing', + description: 'Detailed description', + url: 'https://linear.app/ws/issue/ENG-42', + labels: { nodes: [{ name: 'bug' }] }, + comments: { + nodes: [ + { + user: { name: 'Alice' }, + createdAt: '2026-01-01T00:00:00.000Z', + body: 'Looks good', + }, + ], + }, + }, + }, + }), + }); + + const provider = new LinearProvider(); + const result = await provider.fetchIssue('ENG-42', {}); + + expect(result.number).to.equal(42); + expect(result.title).to.equal('Fix the thing'); + expect(result.body).to.equal('Detailed description'); + expect(result.labels).to.deep.equal([{ name: 'bug' }]); + expect(result.comments).to.deep.equal([ + { author: { login: 'Alice' }, createdAt: '2026-01-01T00:00:00.000Z', body: 'Looks good' }, + ]); + expect(result.url).to.equal('https://linear.app/ws/issue/ENG-42'); + expect(result.context).to.include('# Linear Issue ENG-42'); + expect(result.context).to.include('## Title'); + expect(result.context).to.include('## Description'); + expect(result.context).to.include('## Labels'); + expect(result.context).to.include('## Comments'); + }); + + it('sends the human identifier as-is in the query variables (pinned regression test)', async () => { + let capturedBody; + global.fetch = (_url, options) => { + capturedBody = JSON.parse(options.body); + return { + status: 200, + ok: true, + json: () => ({ + data: { + issue: { + identifier: 'ENG-42', + number: 42, + title: 'T', + description: '', + url: null, + labels: { nodes: [] }, + comments: { nodes: [] }, + }, + }, + }), + }; + }; + + const provider = new LinearProvider(); + await provider.fetchIssue('ENG-42', {}); + + // Linear's documented GraphQL contract accepts either the issue UUID or + // the human-readable identifier (e.g. ENG-42) for the `id` argument. + expect(capturedBody.variables.id).to.equal('ENG-42'); + expect(capturedBody.query).to.include('issue(id: $id)'); + }); + + it('throws a descriptive error on GraphQL errors', async () => { + global.fetch = () => ({ + status: 200, + ok: true, + json: () => ({ errors: [{ message: 'Entity not found' }] }), + }); + + const provider = new LinearProvider(); + try { + await provider.fetchIssue('ENG-999', {}); + expect.fail('should have thrown'); + } catch (err) { + expect(err.message).to.include('Failed to fetch Linear issue'); + expect(err.message).to.include('Entity not found'); + } + }); + + it('throws a clear "not found" error when data.issue is null with no errors array', async () => { + global.fetch = () => ({ + status: 200, + ok: true, + json: () => ({ data: { issue: null } }), + }); + + const provider = new LinearProvider(); + try { + await provider.fetchIssue('ENG-404', {}); + expect.fail('should have thrown'); + } catch (err) { + expect(err.message).to.include('Linear issue not found: ENG-404'); + expect(err.message).to.not.include('Cannot read properties of null'); + } + }); + + it('throws a rate-limit error on a 429 response', async () => { + global.fetch = () => ({ + status: 429, + ok: false, + json: () => ({}), + }); + + const provider = new LinearProvider(); + try { + await provider.fetchIssue('ENG-42', {}); + expect.fail('should have thrown'); + } catch (err) { + expect(err.message).to.include('rate limit'); + } + }); + + it('throws with recovery guidance when no key is configured, without calling fetch', async () => { + delete process.env.LINEAR_API_KEY; + let fetchCalled = false; + global.fetch = () => { + fetchCalled = true; + return { status: 200, ok: true, json: () => ({}) }; + }; + + const provider = new LinearProvider(); + try { + await provider.fetchIssue('ENG-42', {}); + expect.fail('should have thrown'); + } catch (err) { + expect(err.message).to.include('Linear API key not configured'); + expect(err.message).to.include('zeroshot settings set linearApiKey'); + expect(err.message).to.include('https://linear.app/settings/api'); + } + expect(fetchCalled).to.be.false; + }); + + it('uses settings.linearApiKey when LINEAR_API_KEY env is unset', async () => { + delete process.env.LINEAR_API_KEY; + let capturedAuth; + global.fetch = (_url, options) => { + capturedAuth = options.headers.Authorization; + return { + status: 200, + ok: true, + json: () => ({ + data: { + issue: { + identifier: 'ENG-42', + number: 42, + title: 'T', + description: '', + url: null, + labels: { nodes: [] }, + comments: { nodes: [] }, + }, + }, + }), + }; + }; + + const provider = new LinearProvider(); + await provider.fetchIssue('ENG-42', { linearApiKey: 'lin_from_settings' }); + + expect(capturedAuth).to.equal('lin_from_settings'); + }); + }); + + describe('linearTeam auto-derivation for bare numbers', () => { + let originalFetch; + let originalApiKey; + + beforeEach(() => { + originalFetch = global.fetch; + originalApiKey = process.env.LINEAR_API_KEY; + process.env.LINEAR_API_KEY = 'lin_api_test'; + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalApiKey === undefined) { + delete process.env.LINEAR_API_KEY; + } else { + process.env.LINEAR_API_KEY = originalApiKey; + } + }); + + it('derives TEAM-42 from the workspace sole team and persists linearTeam', async () => { + await withSettingsFile({}, async () => { + let capturedIssueId; + global.fetch = (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('teams')) { + return { + status: 200, + ok: true, + json: () => ({ data: { teams: { nodes: [{ key: 'ENG' }] } } }), + }; + } + capturedIssueId = body.variables.id; + return { + status: 200, + ok: true, + json: () => ({ + data: { + issue: { + identifier: 'ENG-42', + number: 42, + title: 'T', + description: '', + url: null, + labels: { nodes: [] }, + comments: { nodes: [] }, + }, + }, + }), + }; + }; + + const provider = new LinearProvider(); + await provider.fetchIssue('42', {}); + + expect(capturedIssueId).to.equal('ENG-42'); + const persisted = JSON.parse(fs.readFileSync(TEST_SETTINGS_FILE, 'utf8')); + expect(persisted.linearTeam).to.equal('ENG'); + }); + }); + + it('throws listing team keys when multiple teams exist', async () => { + await withSettingsFile({}, async () => { + global.fetch = () => ({ + status: 200, + ok: true, + json: () => ({ data: { teams: { nodes: [{ key: 'ENG' }, { key: 'OPS' }] } } }), + }); + + const provider = new LinearProvider(); + try { + await provider.fetchIssue('42', {}); + expect.fail('should have thrown'); + } catch (err) { + expect(err.message).to.include('ENG'); + expect(err.message).to.include('OPS'); + expect(err.message).to.include('linearTeam'); + } + }); + }); + + it('skips team resolution when linearTeam is already set', async () => { + let teamsQueried = false; + global.fetch = (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('teams')) { + teamsQueried = true; + } + return { + status: 200, + ok: true, + json: () => ({ + data: { + issue: { + identifier: 'OPS-42', + number: 42, + title: 'T', + description: '', + url: null, + labels: { nodes: [] }, + comments: { nodes: [] }, + }, + }, + }), + }; + }; + + const provider = new LinearProvider(); + await provider.fetchIssue('42', { linearTeam: 'OPS' }); + + expect(teamsQueried).to.be.false; + }); + }); + + describe('checkAuth', () => { + let originalFetch; + let originalApiKey; + let originalSettingsFile; + + beforeEach(() => { + originalFetch = global.fetch; + originalApiKey = process.env.LINEAR_API_KEY; + originalSettingsFile = process.env.ZEROSHOT_SETTINGS_FILE; + // Point at a non-existent settings file so loadSettings() returns + // deterministic defaults (linearApiKey: null) instead of the real + // machine's ~/.zeroshot/settings.json. + process.env.ZEROSHOT_SETTINGS_FILE = path.join( + os.tmpdir(), + `zeroshot-linear-checkauth-missing-${process.pid}.json` + ); + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalApiKey === undefined) { + delete process.env.LINEAR_API_KEY; + } else { + process.env.LINEAR_API_KEY = originalApiKey; + } + if (originalSettingsFile === undefined) { + delete process.env.ZEROSHOT_SETTINGS_FILE; + } else { + process.env.ZEROSHOT_SETTINGS_FILE = originalSettingsFile; + } + }); + + it('fails fast with recovery steps when no key is configured', async () => { + delete process.env.LINEAR_API_KEY; + const result = await LinearProvider.checkAuth(); + expect(result.authenticated).to.be.false; + expect(result.error).to.equal('Linear API key not configured'); + expect(result.recovery.join(' ')).to.include('zeroshot settings set linearApiKey'); + expect(result.recovery.join(' ')).to.include('https://linear.app/settings/api'); + }); + + it('uses settings.linearApiKey when LINEAR_API_KEY env is unset', async () => { + delete process.env.LINEAR_API_KEY; + await withSettingsFile({ linearApiKey: 'lin_from_settings' }, async () => { + let capturedAuth; + global.fetch = (_url, options) => { + capturedAuth = options.headers.Authorization; + return { + status: 200, + ok: true, + json: () => ({ data: { viewer: { id: 'user_1' } } }), + }; + }; + + const result = await LinearProvider.checkAuth(); + expect(result.authenticated).to.be.true; + expect(capturedAuth).to.equal('lin_from_settings'); + }); + }); + + it('succeeds with a valid key', async () => { + process.env.LINEAR_API_KEY = 'lin_api_valid'; + global.fetch = () => ({ + status: 200, + ok: true, + json: () => ({ data: { viewer: { id: 'user_1' } } }), + }); + + const result = await LinearProvider.checkAuth(); + expect(result.authenticated).to.be.true; + expect(result.error).to.be.null; + }); + + it('fails on 401 response', async () => { + process.env.LINEAR_API_KEY = 'lin_api_invalid'; + global.fetch = () => ({ + status: 401, + ok: false, + json: () => ({}), + }); + + const result = await LinearProvider.checkAuth(); + expect(result.authenticated).to.be.false; + expect(result.error).to.equal('Linear API key invalid'); + }); + + it('fails on GraphQL errors', async () => { + process.env.LINEAR_API_KEY = 'lin_api_invalid'; + global.fetch = () => ({ + status: 200, + ok: true, + json: () => ({ errors: [{ message: 'Authentication required' }] }), + }); + + const result = await LinearProvider.checkAuth(); + expect(result.authenticated).to.be.false; + expect(result.error).to.equal('Linear API key invalid'); + }); + + it('fails with a clear rate-limit message on a 429 response', async () => { + process.env.LINEAR_API_KEY = 'lin_api_test'; + global.fetch = () => ({ + status: 429, + ok: false, + json: () => ({}), + }); + + const result = await LinearProvider.checkAuth(); + expect(result.authenticated).to.be.false; + expect(result.error).to.include('rate limit'); + }); + }); + + describe('getSettingsSchema / validateSetting', () => { + it('accepts a valid linearTeam key', () => { + expect(LinearProvider.validateSetting('linearTeam', 'ENG')).to.be.null; + }); + + it('rejects a lowercase linearTeam key', () => { + expect(LinearProvider.validateSetting('linearTeam', 'eng')).to.be.a('string'); + }); + + it('rejects a numeric linearTeam key', () => { + expect(LinearProvider.validateSetting('linearTeam', '123')).to.be.a('string'); + }); + + it('accepts any non-empty linearApiKey string (no pattern)', () => { + expect(LinearProvider.validateSetting('linearApiKey', 'lin_api_anything')).to.be.null; + }); + + it('accepts null for linearApiKey (nullable)', () => { + expect(LinearProvider.validateSetting('linearApiKey', null)).to.be.null; + }); + }); +}); diff --git a/tests/list-status-consistency.test.js b/tests/list-status-consistency.test.js new file mode 100644 index 00000000..c81ba229 --- /dev/null +++ b/tests/list-status-consistency.test.js @@ -0,0 +1,288 @@ +/** + * Regression test for issue #556: `zeroshot list --json` disagreeing with + * `zeroshot status ` for a running cluster (phantom failed/msgs=0, WAL race). + * + * Root causes fixed (src/orchestrator.js, src/ledger.js, cli/index.js): + * - _loadClusters() used to mutate cluster.state='corrupted' as a side effect of + * a single 0-message read on every list/status call, with no grace period and + * no confirmation - any running cluster whose first message hadn't landed yet + * (or whose count() read raced a writer) got flipped and reported as failed. + * - getStatus()/listClusters() caught ledger read failures and fabricated + * messageCount=0, making a transient read failure indistinguishable from a + * genuinely empty cluster - the exact "msgs=0 $0" phantom from the bug report. + * - list/status opened a fresh read-write Ledger connection per call, taking a + * write lock and re-running schema DDL against a live daemon's connection. + * + * The first two sub-tests below are deterministic: they construct the exact + * conditions that used to trigger each bug (no timing/race required) and fail + * reliably against the pre-fix code. The final sub-test is a concurrency smoke + * test that mirrors real CLI process behavior (fresh read-only Orchestrator + * instances polling while a second connection writes). + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const Orchestrator = require('../src/orchestrator.js'); +const Ledger = require('../src/ledger.js'); +const MockTaskRunner = require('./helpers/mock-task-runner.js'); + +function createTempDir() { + const tmpBase = path.join(os.tmpdir(), 'zeroshot-test'); + if (!fs.existsSync(tmpBase)) { + fs.mkdirSync(tmpBase, { recursive: true }); + } + return fs.mkdtempSync(path.join(tmpBase, 'list-status-consistency-')); +} + +function cleanupTempDir(tmpDir) { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function closeAllLedgers(orchestratorInstance) { + for (const cluster of orchestratorInstance.clusters.values()) { + try { + cluster.ledger?.close(); + } catch { + // Already closed - fine. + } + } +} + +describe('list/status consistency (issue #556)', function () { + this.timeout(20000); + + it('does not mark a running, 0-message cluster as corrupted merely from a load (list/status never mutates state as a side effect of a plain read)', async function () { + const storageDir = createTempDir(); + const clusterId = 'zero-msg-running-1'; + const dbPath = path.join(storageDir, `${clusterId}.db`); + + // 0-message ledger: the real window between cluster creation and its + // first ISSUE_OPENED message landing (or a transient read race) - not + // itself evidence of corruption. + const seedLedger = new Ledger(dbPath); + seedLedger.close(); + + fs.writeFileSync( + path.join(storageDir, 'clusters.json'), + JSON.stringify({ + [clusterId]: { + id: clusterId, + config: { agents: [] }, + state: 'running', + createdAt: Date.now(), + pid: process.pid, // our own test process - genuinely alive, not a zombie + }, + }) + ); + + const orchestrator = await Orchestrator.create({ storageDir, quiet: true }); + try { + const status = orchestrator.getStatus(clusterId); + assert.strictEqual( + status.state, + 'running', + `Expected state to remain "running" for a freshly-created 0-message cluster; ` + + `got "${status.state}". The old unconditional 0-message heuristic in ` + + `_loadClusters() must not run inside the list/status read path.` + ); + + const listEntry = orchestrator.listClusters().find((c) => c.id === clusterId); + assert.ok(listEntry, 'listClusters() must not omit the cluster'); + assert.strictEqual(listEntry.state, 'running'); + } finally { + orchestrator.close(); + cleanupTempDir(storageDir); + } + }); + + it('does not fabricate messageCount=0 when a ledger read fails (surfaces as null instead)', async function () { + const storageDir = createTempDir(); + const mockRunner = new MockTaskRunner(); + mockRunner.when('worker').returns({ done: true }); + + const orchestrator = new Orchestrator({ + taskRunner: mockRunner, + storageDir, + skipLoad: true, + quiet: true, + }); + + try { + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + outputFormat: 'text', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + prompt: 'You are a worker agent.', + }, + ], + }; + const { id: clusterId } = await orchestrator.start(config, { text: 'test issue' }); + const cluster = orchestrator.getCluster(clusterId); + + assert.ok( + cluster.messageBus.count({ cluster_id: clusterId }) > 0, + 'sanity check: cluster should have messages before simulating a read failure' + ); + + // Simulate a transient ledger read failure (closed connection, corrupted + // file, busy timeout, etc - any of which can happen mid-flight). + cluster.ledger.close(); + + const status = orchestrator.getStatus(clusterId); + assert.notStrictEqual( + status.messageCount, + 0, + 'A failed ledger read must not report messageCount=0 - that is indistinguishable ' + + 'from "confirmed empty" and is exactly the phantom "msgs=0 $0" from the bug report' + ); + assert.strictEqual( + status.messageCount, + null, + 'Failed reads should surface as null, not a fabricated zero' + ); + } finally { + orchestrator.close(); + cleanupTempDir(storageDir); + } + }); + + it('[concurrency smoke test] never reports a running cluster as failed/corrupted, omits it, or fabricates messageCount=0 while a writer is actively appending', async function () { + const storageDir = createTempDir(); + const mockRunner = new MockTaskRunner(); + // Keep the worker "in flight" for the whole poll loop so the owning + // orchestrator's cluster.state stays 'running' throughout the test. + mockRunner.when('worker').delays(15000, { done: true }); + + const owner = new Orchestrator({ + taskRunner: mockRunner, + storageDir, + skipLoad: true, + quiet: true, + }); + + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + outputFormat: 'text', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + prompt: 'You are a worker agent.', + }, + ], + }; + + let writerLedger; + let writerStopped = false; + let writerLoop = Promise.resolve(); + + try { + const { id: clusterId } = await owner.start(config, { text: 'test issue' }); + const dbPath = path.join(storageDir, `${clusterId}.db`); + + // Concurrent writer on a second connection, independent of the read-only + // pollers below - simulates a live daemon appending progress messages. + writerLedger = new Ledger(dbPath); + let appended = 0; + writerLoop = (async () => { + while (!writerStopped) { + writerLedger.append({ + cluster_id: clusterId, + topic: 'WORKER_PROGRESS', + sender: 'worker', + receiver: 'broadcast', + content: { text: `progress ${appended}` }, + }); + appended += 1; + await sleep(10); + } + })(); + + const violations = []; + let sawNonZero = false; + + for (let i = 0; i < 40; i++) { + // Mirror real CLI process behavior: `zeroshot list` and `zeroshot status` + // each construct their own fresh, read-only Orchestrator instance. + const [listOrch, statusOrch] = await Promise.all([ + Orchestrator.create({ storageDir, quiet: true, readonly: true }), + Orchestrator.create({ storageDir, quiet: true, readonly: true }), + ]); + + const listEntry = listOrch.listClusters().find((c) => c.id === clusterId); + let status = null; + try { + status = statusOrch.getStatus(clusterId); + } catch (error) { + violations.push(`iteration ${i}: status threw: ${error.message}`); + } + + if (!listEntry) { + violations.push(`iteration ${i}: list omitted a live running cluster entirely`); + } else { + if (status && status.messageCount > 0) { + sawNonZero = true; + } + + if (sawNonZero) { + if (listEntry.messageCount === 0) { + violations.push( + `iteration ${i}: list reported messageCount=0 after messages were already observed` + ); + } + if (status && status.messageCount === 0) { + violations.push( + `iteration ${i}: status reported messageCount=0 after messages were already observed` + ); + } + } + + if (listEntry.state !== 'running') { + violations.push( + `iteration ${i}: list reported state="${listEntry.state}", expected "running"` + ); + } + if (status && status.state !== 'running') { + violations.push( + `iteration ${i}: status reported state="${status.state}", expected "running"` + ); + } + } + + closeAllLedgers(listOrch); + closeAllLedgers(statusOrch); + + await sleep(15); + } + + assert.deepStrictEqual( + violations, + [], + `list/status disagreed with reality (${violations.length} violation(s)):\n${violations.join('\n')}` + ); + } finally { + writerStopped = true; + await writerLoop; + try { + writerLedger?.close(); + } catch { + // Already closed - fine. + } + owner.close(); + cleanupTempDir(storageDir); + } + }); +}); diff --git a/tests/orchestrator.test.js b/tests/orchestrator.test.js index ecbc9384..75d139da 100644 --- a/tests/orchestrator.test.js +++ b/tests/orchestrator.test.js @@ -848,7 +848,13 @@ function defineLifecycleGetStatusTests() { const config = createSimpleConfig(); lifecycleMockRunner.when('worker').returns({ done: true }); - const result = await lifecycleOrchestrator.start(config, { text: 'Task' }); + const result = await lifecycleOrchestrator.start( + config, + { text: 'Task' }, + { + runMode: 'worktree', + } + ); const clusterId = result.id; const status = lifecycleOrchestrator.getStatus(clusterId); @@ -857,6 +863,7 @@ function defineLifecycleGetStatusTests() { assert.strictEqual(status.state, 'running', 'Status should show running'); assert.strictEqual(status.agents.length, 1, 'Status should show 1 agent'); assert.ok(status.messageCount >= 1, 'Status should show message count'); + assert.strictEqual(status.runMode, 'worktree', 'Status should reflect runMode'); }); it('should fail if cluster does not exist', function () { @@ -883,7 +890,7 @@ function defineLifecycleListClustersTests() { lifecycleMockRunner.when('worker').returns({ done: true }); await lifecycleOrchestrator.start(config, { text: 'Task 1' }); - await lifecycleOrchestrator.start(config, { text: 'Task 2' }); + await lifecycleOrchestrator.start(config, { text: 'Task 2' }, { runMode: 'pr' }); const clusters = lifecycleOrchestrator.listClusters(); assert.strictEqual(clusters.length, 2, 'Should return 2 clusters'); @@ -894,6 +901,11 @@ function defineLifecycleListClustersTests() { assert.ok(cluster.state, 'Cluster should have state'); assert.ok(typeof cluster.agentCount === 'number', 'Cluster should have agentCount'); } + + const withoutRunMode = clusters.find((c) => !c.runMode); + const withRunMode = clusters.find((c) => c.runMode === 'pr'); + assert.ok(withoutRunMode, 'Cluster started without runMode should have runMode null'); + assert.ok(withRunMode, 'Cluster started with runMode option should reflect it'); }); }); } @@ -926,7 +938,7 @@ describe('Orchestrator - Crash Recovery (CRITICAL)', function () { }); const config = createSimpleConfig(); - const result = await orchestrator.start(config, { text: 'Task' }); + const result = await orchestrator.start(config, { text: 'Task' }, { runMode: 'ship' }); clusterId = result.id; // Publish additional message @@ -959,6 +971,11 @@ describe('Orchestrator - Crash Recovery (CRITICAL)', function () { const loadedCluster = clusters.find((c) => c.id === clusterId); assert.ok(loadedCluster, 'Should load the created cluster'); + assert.strictEqual( + loadedCluster.runMode, + 'ship', + 'runMode should round-trip through save/reload' + ); // Verify: Ledger restored const cluster = orchestrator2.getCluster(clusterId); diff --git a/tests/package-smoke.test.js b/tests/package-smoke.test.js new file mode 100644 index 00000000..f3e37981 --- /dev/null +++ b/tests/package-smoke.test.js @@ -0,0 +1,61 @@ +/** + * Packaging smoke tests for the npm artifact. + * + * These run `npm pack --dry-run --json` so they validate the publish file list + * without relying on the network or installing dependencies. + */ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..'); + +function runNpmPackDryRun() { + const result = spawnSync('npm', ['pack', '--dry-run', '--json'], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_loglevel: 'silent', + }, + }); + + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + const parsed = JSON.parse(result.stdout); + assert.ok(Array.isArray(parsed) && parsed.length === 1, 'expected one packed artifact entry'); + return parsed[0]; +} + +describe('npm package smoke', function () { + this.timeout(30000); + + it('publishes the CLI bin and first-run/auth/runtime support files', function () { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const pack = runNpmPackDryRun(); + const files = new Set(pack.files.map((file) => file.path)); + + assert.strictEqual(pkg.bin.zeroshot, './cli/index.js'); + assert.strictEqual( + pkg.bin['zeroshot-agent-provider'], + './lib/agent-cli-provider/executable.js' + ); + + for (const requiredFile of [ + 'cli/index.js', + 'lib/start-cluster.js', + 'lib/path-check.js', + 'scripts/check-path.js', + 'src/claude-credentials.js', + 'src/worktree-claude-config.js', + 'src/agent/pr-verification.js', + 'src/agents/git-pusher-template.js', + 'cluster-hooks/block-ask-user-question.py', + 'cluster-hooks/block-dangerous-git.py', + ]) { + assert.ok(files.has(requiredFile), `npm package must include ${requiredFile}`); + } + }); +}); diff --git a/tests/path-check.test.js b/tests/path-check.test.js new file mode 100644 index 00000000..d3673afa --- /dev/null +++ b/tests/path-check.test.js @@ -0,0 +1,93 @@ +/** + * Test: PATH check utility + * + * Verifies detection of whether the npm global bin dir is on PATH. + */ + +const path = require('path'); +const assert = require('assert'); +const { + getGlobalBinDir, + isDirOnPath, + getPathExportLine, + checkBinDirOnPath, +} = require('../lib/path-check'); + +describe('path-check', function () { + describe('isDirOnPath', function () { + it('detects an exact match', function () { + assert.strictEqual(isDirOnPath('/foo/bar', '/foo/bar:/usr/bin'), true); + }); + + it('normalizes trailing slashes in PATH entries', function () { + assert.strictEqual(isDirOnPath('/foo/bar', '/foo/bar/:/usr/bin'), true); + }); + + it('returns false when dir is not present', function () { + assert.strictEqual(isDirOnPath('/foo/bar', '/usr/bin:/usr/local/bin'), false); + }); + + it('returns false for an empty PATH', function () { + assert.strictEqual(isDirOnPath('/foo/bar', ''), false); + }); + }); + + describe('getGlobalBinDir', function () { + const originalPlatform = process.platform; + + afterEach(function () { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('appends bin on posix platforms', function () { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + assert.strictEqual(getGlobalBinDir('/usr/local'), path.join('/usr/local', 'bin')); + }); + + it('returns the prefix unchanged on win32', function () { + Object.defineProperty(process, 'platform', { value: 'win32' }); + assert.strictEqual(getGlobalBinDir('C:\\nvm\\node'), 'C:\\nvm\\node'); + }); + }); + + describe('getPathExportLine', function () { + it('formats an export line', function () { + assert.strictEqual(getPathExportLine('/foo/bar'), 'export PATH="/foo/bar:$PATH"'); + }); + }); + + describe('checkBinDirOnPath', function () { + const originalPlatform = process.platform; + + afterEach(function () { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('short-circuits to onPath:true on win32', function () { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const result = checkBinDirOnPath({ installPrefix: 'C:\\nvm\\node' }); + assert.deepStrictEqual(result, { onPath: true, binDir: null }); + }); + + it('returns onPath:true when the bin dir is in PATH', function () { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const binDir = path.join('/opt/node', 'bin'); + const result = checkBinDirOnPath({ + installPrefix: '/opt/node', + pathEnv: `${binDir}:/usr/bin`, + }); + assert.strictEqual(result.onPath, true); + assert.strictEqual(result.binDir, binDir); + }); + + it('returns onPath:false when the bin dir is missing from PATH', function () { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const result = checkBinDirOnPath({ + installPrefix: '/opt/node', + pathEnv: '/usr/bin:/usr/local/bin', + }); + assert.strictEqual(result.onPath, false); + assert.strictEqual(result.binDir, path.join('/opt/node', 'bin')); + }); + }); +}); diff --git a/tests/preflight.test.js b/tests/preflight.test.js index e3f9398e..83bcddcb 100644 --- a/tests/preflight.test.js +++ b/tests/preflight.test.js @@ -314,11 +314,11 @@ function defineDockerTests() { function defineRunPreflightTests() { describe('runPreflight()', () => { - it('should fail when Claude CLI is missing', () => { + it('should fail when Claude CLI is missing', async () => { const originalPath = process.env.PATH; process.env.PATH = '/nonexistent'; - const result = runPreflight({ + const result = await runPreflight({ requireGh: false, requireDocker: false, quiet: true, @@ -331,11 +331,11 @@ function defineRunPreflightTests() { expect(result.errors.join('')).to.include('Claude command not available'); }); - it('should fail when Codex CLI is missing', () => { + it('should fail when Codex CLI is missing', async () => { const originalPath = process.env.PATH; process.env.PATH = '/nonexistent'; - const result = runPreflight({ + const result = await runPreflight({ requireGh: false, requireDocker: false, quiet: true, @@ -348,11 +348,11 @@ function defineRunPreflightTests() { expect(result.errors.join('')).to.include('Codex CLI not available'); }); - it('should fail when Gemini CLI is missing', () => { + it('should fail when Gemini CLI is missing', async () => { const originalPath = process.env.PATH; process.env.PATH = '/nonexistent'; - const result = runPreflight({ + const result = await runPreflight({ requireGh: false, requireDocker: false, quiet: true, @@ -365,7 +365,7 @@ function defineRunPreflightTests() { expect(result.errors.join('')).to.include('Gemini CLI not available'); }); - it('should not require Claude auth when CLI is installed', function () { + it('should not require Claude auth when CLI is installed', async function () { try { execSync(`${whichCmd} claude`, { stdio: 'pipe' }); } catch { @@ -378,7 +378,7 @@ function defineRunPreflightTests() { const originalDir = process.env.CLAUDE_CONFIG_DIR; process.env.CLAUDE_CONFIG_DIR = '/nonexistent'; - const result = runPreflight({ + const result = await runPreflight({ requireGh: false, requireDocker: false, quiet: true, @@ -390,8 +390,8 @@ function defineRunPreflightTests() { expect(result.valid).to.be.true; }); - it('should include warnings array in result', function () { - const result = runPreflight({ + it('should include warnings array in result', async function () { + const result = await runPreflight({ requireGh: false, requireDocker: false, quiet: true, diff --git a/tests/provider-level-clamp.test.js b/tests/provider-level-clamp.test.js new file mode 100644 index 00000000..7c47864d --- /dev/null +++ b/tests/provider-level-clamp.test.js @@ -0,0 +1,42 @@ +const assert = require('assert'); +const { getProvider } = require('../src/providers'); + +// Regression coverage for #162: `zeroshot logs` (and any model-spec resolution) +// crashed with `Level "level1" is below minLevel "level3"` when a provider's +// min/max level guardrails were clamped to a single level. minLevel/maxLevel are +// the user's floor/ceiling cost guardrails, so an out-of-range template-pinned +// level must clamp into range instead of throwing. +describe('base provider validateLevel clamping (#162)', function () { + const provider = getProvider('claude'); + + it('clamps a below-floor level up to minLevel', function () { + assert.strictEqual(provider.validateLevel('level1', 'level3', 'level3'), 'level3'); + assert.strictEqual(provider.validateLevel('level1', 'level2', 'level3'), 'level2'); + }); + + it('clamps an above-ceiling level down to maxLevel', function () { + assert.strictEqual(provider.validateLevel('level3', 'level1', 'level1'), 'level1'); + assert.strictEqual(provider.validateLevel('level3', 'level1', 'level2'), 'level2'); + }); + + it('single-level clamp (min === max) forces that level for any input', function () { + for (const level of ['level1', 'level2', 'level3']) { + assert.strictEqual(provider.validateLevel(level, 'level2', 'level2'), 'level2'); + } + }); + + it('returns an in-range level unchanged', function () { + assert.strictEqual(provider.validateLevel('level2', 'level1', 'level3'), 'level2'); + }); + + it('still throws on an unknown level key', function () { + assert.throws(() => provider.validateLevel('level9', 'level1', 'level3'), /Invalid level/); + }); + + it('still throws when minLevel exceeds maxLevel (genuine misconfiguration)', function () { + assert.throws( + () => provider.validateLevel('level2', 'level3', 'level1'), + /minLevel "level3" exceeds maxLevel "level1"/ + ); + }); +}); diff --git a/tests/run-mode.test.js b/tests/run-mode.test.js new file mode 100644 index 00000000..f9b47e6c --- /dev/null +++ b/tests/run-mode.test.js @@ -0,0 +1,46 @@ +const assert = require('assert'); +const { describeRunMode, resolveRunMode } = require('../lib/run-mode'); + +describe('describeRunMode', () => { + it('describes ship', () => { + assert.strictEqual(describeRunMode('ship'), 'ship (worktree + PR + auto-merge)'); + }); + + it('describes ship+docker', () => { + assert.strictEqual(describeRunMode('ship+docker'), 'ship (docker + PR + auto-merge)'); + }); + + it('describes pr', () => { + assert.strictEqual(describeRunMode('pr'), 'pr (worktree + PR)'); + }); + + it('describes pr+docker', () => { + assert.strictEqual(describeRunMode('pr+docker'), 'pr (docker + PR)'); + }); + + it('describes docker', () => { + assert.strictEqual(describeRunMode('docker'), 'docker (isolated container)'); + }); + + it('describes worktree', () => { + assert.strictEqual(describeRunMode('worktree'), 'worktree (isolated branch)'); + }); + + it('describes null as local (no isolation)', () => { + assert.strictEqual(describeRunMode(null), 'local (no isolation)'); + }); + + it('describes undefined as local (no isolation)', () => { + assert.strictEqual(describeRunMode(undefined), 'local (no isolation)'); + }); +}); + +describe('resolveRunMode (lib/run-mode)', () => { + it('returns "ship" when options.ship is set', () => { + assert.strictEqual(resolveRunMode({ ship: true }), 'ship'); + }); + + it('returns null when no mode flags are set', () => { + assert.strictEqual(resolveRunMode({}), null); + }); +}); diff --git a/tests/unit/claude-credentials.test.js b/tests/unit/claude-credentials.test.js new file mode 100644 index 00000000..efba67d0 --- /dev/null +++ b/tests/unit/claude-credentials.test.js @@ -0,0 +1,138 @@ +/** + * Regression tests for macOS Keychain credential propagation into isolated + * CLAUDE_CONFIG_DIRs (GitHub issue #544). + * + * Without this module, a Keychain-only (OAuth subscription) login on macOS + * with no ~/.claude/.credentials.json file left every isolated agent + * (--worktree / --docker) credential-less, failing with "Not logged in". + */ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const sinon = require('sinon'); + +const safeExec = require('../../src/lib/safe-exec'); + +function withPlatform(value, fn) { + const original = Object.getOwnPropertyDescriptor(os, 'platform'); + Object.defineProperty(os, 'platform', { value: () => value, configurable: true }); + try { + return fn(); + } finally { + Object.defineProperty(os, 'platform', original); + } +} + +describe('claude-credentials', function () { + /** @type {sinon.SinonStub} */ + let execSyncStub; + let claudeCredentials; + /** @type {string[]} */ + let tempDirs = []; + + beforeEach(function () { + // Stub before requiring so the module's destructured `execSync` is the stub. + execSyncStub = sinon.stub(safeExec, 'execSync'); + delete require.cache[require.resolve('../../src/claude-credentials.js')]; + claudeCredentials = require('../../src/claude-credentials.js'); + }); + + afterEach(function () { + sinon.restore(); + delete require.cache[require.resolve('../../src/claude-credentials.js')]; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + describe('readKeychainCredentials()', function () { + it('returns null on non-darwin without invoking the Keychain', function () { + const result = withPlatform('linux', () => claudeCredentials.readKeychainCredentials()); + assert.strictEqual(result, null); + assert.strictEqual(execSyncStub.called, false); + }); + + it('returns the credentials JSON when the Keychain has a valid entry', function () { + execSyncStub.returns('{"claudeAiOauth":{"accessToken":"tok"}}\n'); + const result = withPlatform('darwin', () => claudeCredentials.readKeychainCredentials()); + assert.strictEqual(result, '{"claudeAiOauth":{"accessToken":"tok"}}'); + }); + + it('returns null when the Keychain command throws (no item / locked)', function () { + execSyncStub.throws(new Error('security: item not found')); + const result = withPlatform('darwin', () => claudeCredentials.readKeychainCredentials()); + assert.strictEqual(result, null); + }); + + it('returns null when the Keychain returns non-JSON garbage', function () { + execSyncStub.returns('not json'); + const result = withPlatform('darwin', () => claudeCredentials.readKeychainCredentials()); + assert.strictEqual(result, null); + }); + }); + + describe('provisionClaudeCredentials()', function () { + function makeDirs() { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-creds-source-')); + const destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-creds-dest-')); + tempDirs.push(sourceDir, destDir); + return { sourceDir, destDir }; + } + + it('copies an existing source credentials file without consulting the Keychain', function () { + const { sourceDir, destDir } = makeDirs(); + fs.writeFileSync(path.join(sourceDir, '.credentials.json'), '{"apiKey":"file-creds"}\n'); + + const result = withPlatform('darwin', () => + claudeCredentials.provisionClaudeCredentials({ sourceDir, destDir }) + ); + + assert.strictEqual(result, true); + assert.strictEqual(execSyncStub.called, false); + assert.strictEqual( + fs.readFileSync(path.join(destDir, '.credentials.json'), 'utf8'), + '{"apiKey":"file-creds"}\n' + ); + }); + + it('materializes Keychain credentials into destDir when no source file exists', function () { + const { sourceDir, destDir } = makeDirs(); + execSyncStub.returns('{"claudeAiOauth":{"accessToken":"tok"}}'); + + const result = withPlatform('darwin', () => + claudeCredentials.provisionClaudeCredentials({ sourceDir, destDir }) + ); + + assert.strictEqual(result, true); + assert.strictEqual( + fs.readFileSync(path.join(destDir, '.credentials.json'), 'utf8'), + '{"claudeAiOauth":{"accessToken":"tok"}}' + ); + }); + + it('writes nothing and returns false when source is absent and Keychain lookup fails', function () { + const { sourceDir, destDir } = makeDirs(); + execSyncStub.throws(new Error('security: item not found')); + + const result = withPlatform('darwin', () => + claudeCredentials.provisionClaudeCredentials({ sourceDir, destDir }) + ); + + assert.strictEqual(result, false); + assert.strictEqual(fs.existsSync(path.join(destDir, '.credentials.json')), false); + }); + + it('returns false on non-darwin when no source credentials file exists', function () { + const { sourceDir, destDir } = makeDirs(); + + const result = withPlatform('linux', () => + claudeCredentials.provisionClaudeCredentials({ sourceDir, destDir }) + ); + + assert.strictEqual(result, false); + assert.strictEqual(execSyncStub.called, false); + assert.strictEqual(fs.existsSync(path.join(destDir, '.credentials.json')), false); + }); + }); +}); diff --git a/tests/unit/cli-logs-history.test.js b/tests/unit/cli-logs-history.test.js new file mode 100644 index 00000000..bd5c22b8 --- /dev/null +++ b/tests/unit/cli-logs-history.test.js @@ -0,0 +1,91 @@ +const assert = require('assert'); + +const { renderRecentMessagesToTerminal } = require('../../cli/index.js'); + +function message(overrides) { + return { + id: overrides.id || `msg-${overrides.timestamp || 0}`, + timestamp: overrides.timestamp || 0, + topic: overrides.topic, + sender: overrides.sender || 'worker', + cluster_id: overrides.cluster_id || 'cluster-1', + content: overrides.content || {}, + ...overrides, + }; +} + +function codexLine(payload) { + return { + text: JSON.stringify(payload), + data: { + line: JSON.stringify(payload), + provider: 'codex', + fromTaskLog: true, + }, + }; +} + +describe('cli logs history regressions', function () { + it('limits after selecting printable history so noisy tails do not hide useful events', function () { + const messages = [ + message({ + topic: 'VALIDATION_RESULT', + timestamp: 1, + sender: 'validator', + content: { + data: { + approved: false, + summary: 'missing edge-case test', + issues: ['edge case missing'], + }, + }, + }), + ]; + + for (let i = 0; i < 60; i++) { + messages.push( + message({ + id: `empty-agent-output-${i}`, + topic: 'AGENT_OUTPUT', + timestamp: 2 + i, + content: { text: ' ' }, + }) + ); + } + + const rendered = renderRecentMessagesToTerminal(messages, 50, { isActive: false }); + + assert(rendered.includes('VALIDATION_RESULT')); + assert(rendered.includes('missing edge-case test')); + }); + + it('renders historical Codex AGENT_OUTPUT text and success result summaries', function () { + const messages = [ + message({ + id: 'codex-text', + topic: 'AGENT_OUTPUT', + timestamp: 1, + sender_provider: 'codex', + content: codexLine({ + type: 'item.completed', + item: { type: 'agent_message', id: 'item_1', text: 'hello from codex' }, + }), + }), + message({ + id: 'codex-result', + topic: 'AGENT_OUTPUT', + timestamp: 2, + sender_provider: 'codex', + content: codexLine({ + type: 'turn.completed', + usage: { input_tokens: 7, output_tokens: 3 }, + }), + }), + ]; + + const rendered = renderRecentMessagesToTerminal(messages, 50, { isActive: false }); + + assert(rendered.includes('hello from codex')); + assert(rendered.includes('completed (7 in, 3 out)')); + }); +}); diff --git a/tests/unit/cli-stdin-input.test.js b/tests/unit/cli-stdin-input.test.js new file mode 100644 index 00000000..5196bc19 --- /dev/null +++ b/tests/unit/cli-stdin-input.test.js @@ -0,0 +1,91 @@ +/** + * Test: stdin task input ('zeroshot run -') + * + * Ensures piped/redirected task bodies are preserved verbatim, avoiding + * shell-quoting breakage from inline args. + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { Readable } = require('stream'); +const { + isStdinInput, + readStdinText, + encodeStdinEnv, + decodeStdinEnv, + buildTextInput, +} = require('../../lib/start-cluster'); + +const repoRoot = path.join(__dirname, '..', '..'); + +describe('CLI stdin task input', function () { + describe('isStdinInput', function () { + it('recognizes the "-" marker', function () { + assert.strictEqual(isStdinInput('-'), true); + }); + + it('rejects issue numbers, files, and plain text', function () { + assert.strictEqual(isStdinInput('123'), false); + assert.strictEqual(isStdinInput('feature.md'), false); + assert.strictEqual(isStdinInput('some text'), false); + }); + }); + + describe('readStdinText', function () { + it('collects and trims piped chunks', async function () { + const stream = Readable.from(['line1\n', 'line2\n']); + const text = await readStdinText(stream); + assert.strictEqual(text, 'line1\nline2'); + }); + + it('resolves to empty string for empty stdin', async function () { + const stream = Readable.from([]); + const text = await readStdinText(stream); + assert.strictEqual(text, ''); + }); + }); + + describe('encodeStdinEnv / decodeStdinEnv', function () { + it('round-trips text containing shell metacharacters and unicode', function () { + const text = 'BrandContext.writingStyle `backtick` $(command) ΓΌber\nmultiline'; + const encoded = encodeStdinEnv(text); + const decoded = decodeStdinEnv(encoded); + assert.strictEqual(decoded, text); + }); + + it('produces unmodified text input after round-trip', function () { + const text = 'echo `whoami` $(rm -rf /)'; + const result = buildTextInput(decodeStdinEnv(encodeStdinEnv(text))); + assert.deepStrictEqual(result, { text }); + }); + }); + + it('preserves piped stdin through a real Node process boundary', function () { + const taskBody = [ + '# Task', + '', + 'Update `BrandContext.writingStyle` without evaluating $(shell) syntax.', + ].join('\n'); + const script = ` + const { readStdinText, buildTextInput } = require(${JSON.stringify( + path.join(repoRoot, 'lib/start-cluster') + )}); + readStdinText() + .then((text) => process.stdout.write(JSON.stringify(buildTextInput(text)))) + .catch((error) => { + console.error(error.stack || error.message); + process.exit(1); + }); + `; + + const result = spawnSync(process.execPath, ['-e', script], { + cwd: repoRoot, + input: taskBody, + encoding: 'utf8', + }); + + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { text: taskBody }); + }); +}); diff --git a/tests/unit/detached-startup.test.js b/tests/unit/detached-startup.test.js index eb59a1c4..989da5b6 100644 --- a/tests/unit/detached-startup.test.js +++ b/tests/unit/detached-startup.test.js @@ -10,6 +10,7 @@ const { isClusterRegistered, markDetachedSetupFailed, registerDetachedSetupCluster, + removeDetachedSetupCluster, resolveWaitTimeoutMs, waitForClusterRegistration, } = require('../../lib/detached-startup'); @@ -144,6 +145,27 @@ describe('detached-startup helpers', function () { assert.strictEqual(clusters['failed-cluster'].failureInfo.error, 'setup exploded'); }); + it('removes a provisional setup cluster without leaving a phantom entry', async function () { + const storageDir = createTempStorageDir(); + const clustersFile = getClustersFilePath(storageDir); + fs.mkdirSync(path.dirname(clustersFile), { recursive: true }); + fs.writeFileSync(clustersFile, JSON.stringify({ other: { id: 'other' } })); + + await registerDetachedSetupCluster({ + clusterId: 'rejected-cluster', + pid: 12345, + storageDir, + }); + assert.strictEqual(isClusterRegistered('rejected-cluster', storageDir), true); + + await removeDetachedSetupCluster({ clusterId: 'rejected-cluster', storageDir }); + + const clusters = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + assert.strictEqual(clusters['rejected-cluster'], undefined); + // Unrelated entries in the same file must be preserved. + assert.deepStrictEqual(clusters.other, { id: 'other' }); + }); + it('waits until cluster is registered', async function () { this.timeout(5000); const storageDir = createTempStorageDir(); diff --git a/tests/unit/detect-run-input.test.js b/tests/unit/detect-run-input.test.js new file mode 100644 index 00000000..8fa5628d --- /dev/null +++ b/tests/unit/detect-run-input.test.js @@ -0,0 +1,95 @@ +/** + * Regression test for issue #575: `zeroshot run ` was treated as + * manual text because detectRunInput() maintained its own private copy of + * provider URL/key regexes (no linear.app pattern) instead of delegating to + * the provider registry. detectRunInput() now delegates to detectProvider() + * so there is a single source of truth for "is this input a recognized issue". + */ + +const assert = require('assert'); + +const { + detectRunInput, + buildIssueInput, + buildTextInput, + buildFileInput, +} = require('../../lib/start-cluster'); +const { registerProvider, listProviders } = require('../../src/issue-providers'); +const IssueProvider = require('../../src/issue-providers/base-provider'); + +describe('detectRunInput()', function () { + describe('Linear routing (issue #575)', function () { + const linearUrl = 'https://linear.app/acme/issue/THE-5/some-title'; + + it('routes a linear.app issue URL to buildIssueInput, not buildTextInput', function () { + const result = detectRunInput(linearUrl, {}, null); + assert.deepStrictEqual(result, buildIssueInput(linearUrl)); + assert.strictEqual(result.text, undefined); + }); + + it('routes a linear.app issue URL to buildIssueInput when forceProvider="linear"', function () { + const result = detectRunInput(linearUrl, {}, 'linear'); + assert.deepStrictEqual(result, buildIssueInput(linearUrl)); + }); + }); + + describe('existing per-provider behavior is preserved', function () { + it('GitHub issue URL routes to issue input', function () { + const url = 'https://github.com/owner/repo/issues/123'; + assert.deepStrictEqual(detectRunInput(url, {}, null), buildIssueInput(url)); + }); + + it('GitHub bare number (no defaultIssueSource set) routes to issue input', function () { + // Resolved via git-context detection (this repo's origin is GitHub) or, + // absent that, the GitHub legacy fallback in base-provider.js. + assert.deepStrictEqual(detectRunInput('123', {}, null), buildIssueInput('123')); + }); + + it('GitLab issue URL routes to issue input', function () { + const url = 'https://gitlab.com/owner/repo/-/issues/456'; + assert.deepStrictEqual(detectRunInput(url, {}, null), buildIssueInput(url)); + }); + + it('Jira Cloud issue URL routes to issue input', function () { + const url = 'https://acme.atlassian.net/browse/ENG-42'; + assert.deepStrictEqual(detectRunInput(url, {}, null), buildIssueInput(url)); + }); + + it('Jira issue key routes to issue input', function () { + assert.deepStrictEqual(detectRunInput('ENG-42', {}, null), buildIssueInput('ENG-42')); + }); + + it('Azure DevOps work item URL routes to issue input', function () { + const url = 'https://dev.azure.com/org/project/_workitems/edit/789'; + assert.deepStrictEqual(detectRunInput(url, {}, null), buildIssueInput(url)); + }); + + it('markdown file routes to file input', function () { + assert.deepStrictEqual(detectRunInput('feature.md', {}, null), buildFileInput('feature.md')); + }); + + it('plain text routes to text input', function () { + const text = 'Implement dark mode toggle'; + assert.deepStrictEqual(detectRunInput(text, {}, null), buildTextInput(text)); + }); + }); + + describe('regression guard: detectRunInput stays in sync with the provider registry', function () { + it('recognizes a newly registered provider with zero changes to detectRunInput', function () { + class FakeProvider extends IssueProvider { + static id = 'fake-test-provider-575'; + static displayName = 'Fake Test Provider'; + static detectIdentifier(input) { + return input === 'FAKE-MARKER-575'; + } + } + registerProvider(FakeProvider); + + assert.ok(listProviders().includes(FakeProvider.id)); + assert.deepStrictEqual( + detectRunInput('FAKE-MARKER-575', {}, null), + buildIssueInput('FAKE-MARKER-575') + ); + }); + }); +}); diff --git a/tests/unit/git-safety-hook.test.js b/tests/unit/git-safety-hook.test.js index 91d56a6c..4b03dfc4 100644 --- a/tests/unit/git-safety-hook.test.js +++ b/tests/unit/git-safety-hook.test.js @@ -18,8 +18,8 @@ const { execFileSync, spawnSync } = require('child_process'); const path = require('path'); const fs = require('fs'); -// Path to the hook script -const HOOK_PATH = path.join(__dirname, '../../hooks/block-dangerous-git.py'); +// Path to the hook script used by runtime packaging/copy logic. +const HOOK_PATH = path.join(__dirname, '../../cluster-hooks/block-dangerous-git.py'); function resolvePython() { const candidates = [process.env.PYTHON, '/usr/bin/python3', 'python3', 'python'].filter(Boolean); diff --git a/tests/unit/orchestrator-issue-duplicate-check.test.js b/tests/unit/orchestrator-issue-duplicate-check.test.js index 00b2301d..73817075 100644 --- a/tests/unit/orchestrator-issue-duplicate-check.test.js +++ b/tests/unit/orchestrator-issue-duplicate-check.test.js @@ -4,6 +4,49 @@ const os = require('node:os'); const path = require('node:path'); const Orchestrator = require('../../src/orchestrator'); +const { registerProvider } = require('../../src/issue-providers'); +const IssueProvider = require('../../src/issue-providers/base-provider'); + +// Minimal in-process issue provider (no network/CLI dependency) so the duplicate-run +// integration test can exercise the real input.issue path via `options.forceProvider`. +class MockIssueProvider extends IssueProvider { + static id = 'test-mock-provider'; + static displayName = 'Test Mock Provider'; + + static detectIdentifier() { + return false; + } + + static getRequiredTool() { + return { name: 'mock', checkCmd: 'true', installHint: 'n/a' }; + } + + fetchIssue(identifier) { + return Promise.resolve({ + number: Number(identifier), + title: `Mock issue ${identifier}`, + body: '', + labels: [], + comments: [], + url: null, + context: `Mock issue ${identifier}`, + }); + } +} +registerProvider(MockIssueProvider); + +// Single agent with no triggers: subscribes on start() but never executes a task, +// so orchestrator.start() completes without spawning a real provider CLI process. +const NOOP_CONFIG = { + agents: [ + { + id: 'noop', + role: 'implementation', + triggers: [], + prompt: 'noop', + }, + ], +}; describe('Orchestrator duplicate issue check', function () { this.timeout(5000); @@ -16,7 +59,8 @@ describe('Orchestrator duplicate issue check', function () { orchestrator = new Orchestrator({ quiet: true, skipLoad: true, storageDir: tempDir }); }); - afterEach(() => { + afterEach(async () => { + await orchestrator.killAll(); if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -35,4 +79,44 @@ describe('Orchestrator duplicate issue check', function () { const active = orchestrator._getActiveClustersForIssue(1172, clusterId); assert.deepEqual(active, []); }); + + it('rejects a second start() for the same issue with zero side effects', async () => { + const clustersFilePath = path.join(tempDir, 'clusters.json'); + + const first = await orchestrator.start( + NOOP_CONFIG, + { issue: '4242' }, + { clusterId: 'first-cluster', forceProvider: 'test-mock-provider' } + ); + assert.strictEqual(first.state, 'running'); + assert.strictEqual(orchestrator.clusters.size, 1); + + const clustersFileBefore = fs.readFileSync(clustersFilePath, 'utf8'); + + let caughtError = null; + try { + await orchestrator.start( + NOOP_CONFIG, + { issue: '4242' }, + { clusterId: 'second-cluster', forceProvider: 'test-mock-provider' } + ); + } catch (err) { + caughtError = err; + } + + assert(caughtError, 'expected the duplicate start() call to reject'); + assert.strictEqual(caughtError.code, 'DUPLICATE_CLUSTER'); + assert.strictEqual(caughtError.existingClusterId, 'first-cluster'); + assert.strictEqual(caughtError.issueNumber, 4242); + + // No new cluster registered in memory - the guard rejected before allocation. + assert.strictEqual(orchestrator.clusters.size, 1); + assert.strictEqual(orchestrator.clusters.has('second-cluster'), false); + + // clusters.json was never touched for the rejected run. + assert.strictEqual(fs.readFileSync(clustersFilePath, 'utf8'), clustersFileBefore); + + // No ledger/worktree allocated for the rejected clusterId. + assert.strictEqual(fs.existsSync(path.join(tempDir, 'second-cluster.db')), false); + }); }); diff --git a/tests/unit/start-cluster-config.test.js b/tests/unit/start-cluster-config.test.js index e6de1201..c9074a21 100644 --- a/tests/unit/start-cluster-config.test.js +++ b/tests/unit/start-cluster-config.test.js @@ -1,6 +1,6 @@ const assert = require('assert'); -const { loadClusterConfig } = require('../../lib/start-cluster'); +const { loadClusterConfig, buildStartOptions } = require('../../lib/start-cluster'); function createOrchestrator(config) { const calls = { loadConfig: [] }; @@ -47,3 +47,44 @@ describe('start-cluster config loading', function () { assert.strictEqual(config.agents[0].prompt.system, 'Plan a TASK'); }); }); + +describe('buildStartOptions() runMode', function () { + it('derives runMode "ship" from pre-transform mergedOptions', function () { + const result = buildStartOptions({ clusterId: 'c1', options: { ship: true } }); + assert.strictEqual(result.runMode, 'ship'); + }); + + it('derives runMode "pr+docker" even though transformed output has no .pr/.docker keys', function () { + const result = buildStartOptions({ clusterId: 'c1', options: { pr: true, docker: true } }); + assert.strictEqual(result.runMode, 'pr+docker'); + assert.strictEqual(result.pr, undefined); + assert.strictEqual(result.docker, undefined); + }); + + it('returns null runMode when no isolation flags are set', function () { + const result = buildStartOptions({ clusterId: 'c1', options: {} }); + assert.strictEqual(result.runMode, null); + }); +}); + +describe('buildStartOptions() autoMerge', function () { + it('resolves autoMerge=true for --ship', function () { + const result = buildStartOptions({ clusterId: 'c1', options: { ship: true } }); + assert.strictEqual(result.autoMerge, true); + }); + + it('resolves autoMerge=false for --pr', function () { + const result = buildStartOptions({ clusterId: 'c1', options: { pr: true } }); + assert.strictEqual(result.autoMerge, false); + }); + + it('ignores the dead ZEROSHOT_MERGE env var (removed signal has no effect)', function () { + process.env.ZEROSHOT_MERGE = '1'; + try { + const result = buildStartOptions({ clusterId: 'c1', options: { pr: true } }); + assert.strictEqual(result.autoMerge, false); + } finally { + delete process.env.ZEROSHOT_MERGE; + } + }); +}); diff --git a/tests/unit/status-footer.test.js b/tests/unit/status-footer.test.js index 537dfd26..67610c39 100644 --- a/tests/unit/status-footer.test.js +++ b/tests/unit/status-footer.test.js @@ -31,3 +31,20 @@ describe('StatusFooter updateAgent', () => { assert.strictEqual(agent.pid, 2222); }); }); + +describe('StatusFooter runMode badge', () => { + it('shows the armed run mode in the header line', () => { + const footer = new StatusFooter({ enabled: false }); + footer.setCluster('cluster-test'); + footer.setRunMode('ship'); + const line = footer.stripAnsi(footer.buildHeaderLine(80)); + assert.ok(line.includes('[ship]'), `expected header to include [ship], got: ${line}`); + }); + + it('omits the badge when no run mode is set', () => { + const footer = new StatusFooter({ enabled: false }); + footer.setCluster('cluster-test'); + const line = footer.stripAnsi(footer.buildHeaderLine(80)); + assert.ok(!line.includes('['), `expected no badge, got: ${line}`); + }); +}); diff --git a/tests/unit/template-validation-report-formatter.test.js b/tests/unit/template-validation-report-formatter.test.js new file mode 100644 index 00000000..a5df2e5f --- /dev/null +++ b/tests/unit/template-validation-report-formatter.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert'); +const path = require('node:path'); + +const { formatValidationReport } = require('../../src/template-validation/report-formatter'); + +describe('formatValidationReport', function () { + const cwd = path.join(__dirname, '..', '..'); + const cleanPath = path.join(cwd, 'cluster-templates', 'base-templates', 'clean.json'); + const changedPath = path.join(cwd, 'cluster-templates', 'base-templates', 'changed.json'); + const unchangedPath = path.join(cwd, 'cluster-templates', 'base-templates', 'unchanged.json'); + const invalidPath = path.join(cwd, 'cluster-templates', 'base-templates', 'invalid.json'); + + function buildReport() { + return { + validated: 4, + skipped: 0, + results: [ + { filePath: cleanPath, result: { valid: true, errors: [], warnings: [] } }, + { + filePath: changedPath, + result: { valid: true, errors: [], warnings: ["Agent 'a': changed warning"] }, + }, + { + filePath: unchangedPath, + result: { + valid: true, + errors: [], + warnings: ["Agent 'b': unchanged warning 1", "Agent 'c': unchanged warning 2"], + }, + }, + { + filePath: invalidPath, + result: { valid: false, errors: ['broken schema'], warnings: [] }, + }, + ], + }; + } + + it('shows all warnings when changedFiles is null', function () { + const { lines, hasErrors } = formatValidationReport(buildReport(), { changedFiles: null, cwd }); + + const text = lines.join('\n'); + assert.ok(text.includes("WARN: Agent 'a': changed warning")); + assert.ok(text.includes("WARN: Agent 'b': unchanged warning 1")); + assert.ok(text.includes("WARN: Agent 'c': unchanged warning 2")); + assert.ok(text.includes('ERROR: broken schema')); + assert.strictEqual(hasErrors, true); + assert.ok(text.includes('Validated: 4 templates, Skipped: 0 files')); + }); + + it('collapses warnings on unchanged templates into a summary line', function () { + const changedRelative = path.relative(cwd, changedPath); + const { lines, hasErrors } = formatValidationReport(buildReport(), { + changedFiles: new Set([changedRelative]), + cwd, + }); + + const text = lines.join('\n'); + assert.ok(text.includes("WARN: Agent 'a': changed warning")); + assert.ok(!text.includes("WARN: Agent 'b': unchanged warning 1")); + assert.ok(!text.includes("WARN: Agent 'c': unchanged warning 2")); + assert.ok(text.includes('2 pre-existing warning(s) across 1 unrelated template(s)')); + + assert.ok(text.includes('ERROR: broken schema')); + assert.strictEqual(hasErrors, true); + assert.ok(text.includes('Validated: 4 templates, Skipped: 0 files')); + }); +}); diff --git a/tests/unit/windows-hide-spawn-options.test.js b/tests/unit/windows-hide-spawn-options.test.js new file mode 100644 index 00000000..85364ae4 --- /dev/null +++ b/tests/unit/windows-hide-spawn-options.test.js @@ -0,0 +1,85 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const { EventEmitter } = require('events'); +const childProcess = require('child_process'); + +const projectRoot = path.resolve(__dirname, '..', '..'); + +function readSource(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); +} + +// Regression guard for GitHub issue #459: on Windows, spawned child +// processes must not pop up a visible console window. Node suppresses +// this only when `windowsHide: true` is passed explicitly; it is a +// no-op (safe) on non-Windows platforms. +describe('windowsHide spawn options (Windows console window fix)', function () { + it('passes windowsHide: true when ClaudeTaskRunner spawns the zeroshot CLI', async function () { + const fakeChild = new EventEmitter(); + fakeChild.stdout = new EventEmitter(); + fakeChild.stderr = new EventEmitter(); + + const spawnStub = sinon.stub(childProcess, 'spawn').returns(fakeChild); + try { + // claude-task-runner.js destructures `spawn` from child_process at + // require time, so the module must be (re)loaded after stubbing. + const runnerPath = require.resolve('../../src/claude-task-runner'); + delete require.cache[runnerPath]; + const ClaudeTaskRunner = require(runnerPath); + + const runner = new ClaudeTaskRunner({ quiet: true }); + const pending = runner._spawnAndGetTaskId('zeroshot', ['task', 'run'], '/tmp', {}, 'agent-1'); + // spawn() is invoked synchronously inside the Promise executor. + assert.strictEqual(spawnStub.calledOnce, true); + const options = spawnStub.firstCall.args[2]; + assert.strictEqual(options.windowsHide, true); + + // Resolve the pending promise so it doesn't leak an unhandled rejection. + fakeChild.emit('close', 1); + await assert.rejects(pending); + } finally { + spawnStub.restore(); + // Force other tests/files to reload with the real (unstubbed) spawn. + const runnerPath = require.resolve('../../src/claude-task-runner'); + delete require.cache[runnerPath]; + } + }); + + it('adds windowsHide: true to fork() options in spawnWatcher (task-lib/runner.js)', function () { + const source = readSource('task-lib/runner.js'); + const match = source.match(/function spawnWatcher\([\s\S]*?\n\}/); + assert(match, 'spawnWatcher() not found in task-lib/runner.js'); + assert.match(match[0], /fork\(/); + assert.match( + match[0], + /windowsHide:\s*true/, + 'spawnWatcher() must pass windowsHide: true to fork()' + ); + }); + + it('adds windowsHide: true to the watcher child spawn (task-lib/watcher.js)', function () { + const source = readSource('task-lib/watcher.js'); + const match = source.match(/const child = spawn\([\s\S]*?\);/); + assert(match, 'watcher child spawn() call not found in task-lib/watcher.js'); + assert.match( + match[0], + /windowsHide:\s*true/, + 'watcher.js must pass windowsHide: true to spawn()' + ); + }); + + it('adds windowsHide: true to spawnTaskProcess (src/agent/agent-task-executor.js)', function () { + const source = readSource('src/agent/agent-task-executor.js'); + const match = source.match( + /function spawnTaskProcess\([\s\S]*?const proc = spawn\([\s\S]*?\);/ + ); + assert(match, 'spawnTaskProcess() spawn() call not found in agent-task-executor.js'); + assert.match( + match[0], + /windowsHide:\s*true/, + 'spawnTaskProcess() must pass windowsHide: true to spawn()' + ); + }); +}); diff --git a/tests/unit/worktree-claude-config.test.js b/tests/unit/worktree-claude-config.test.js index 19853feb..a37824c7 100644 --- a/tests/unit/worktree-claude-config.test.js +++ b/tests/unit/worktree-claude-config.test.js @@ -2,14 +2,36 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const sinon = require('sinon'); const { prepareClaudeConfigDir } = require('../../src/worktree-claude-config'); +const safeExec = require('../../src/lib/safe-exec'); + +function withPlatform(value, fn) { + const original = Object.getOwnPropertyDescriptor(os, 'platform'); + Object.defineProperty(os, 'platform', { value: () => value, configurable: true }); + try { + return fn(); + } finally { + Object.defineProperty(os, 'platform', original); + } +} + +function loadWorktreeClaudeConfigWithStubbedCredentials(execSyncStub) { + sinon.stub(safeExec, 'execSync').callsFake(execSyncStub); + delete require.cache[require.resolve('../../src/claude-credentials')]; + delete require.cache[require.resolve('../../src/worktree-claude-config')]; + return require('../../src/worktree-claude-config'); +} describe('worktree-claude-config', function () { /** @type {string[]} */ let tempDirs = []; afterEach(function () { + sinon.restore(); + delete require.cache[require.resolve('../../src/claude-credentials')]; + delete require.cache[require.resolve('../../src/worktree-claude-config')]; for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -117,4 +139,41 @@ describe('worktree-claude-config', function () { const mergedMcp = JSON.parse(fs.readFileSync(path.join(overlayDir, '.mcp.json'), 'utf8')); assert.deepStrictEqual(Object.keys(mergedMcp.mcpServers).sort(), ['repo', 'shared']); }); + + it('materializes macOS Keychain credentials into the isolated overlay config dir', function () { + const worktreeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-claude-worktree-')); + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-claude-source-no-creds-')); + tempDirs.push(worktreeRoot, sourceDir); + + fs.writeFileSync(path.join(worktreeRoot, '.git'), 'gitdir: test\n', 'utf8'); + fs.mkdirSync(path.join(worktreeRoot, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(worktreeRoot, '.claude', 'settings.json'), '{}\n', 'utf8'); + + const keychainJson = '{"claudeAiOauth":{"accessToken":"keychain-token"}}'; + const { prepareClaudeConfigDir: prepareWithStubbedKeychain } = + loadWorktreeClaudeConfigWithStubbedCredentials((command) => { + assert.strictEqual( + command, + 'security find-generic-password -s "Claude Code-credentials" -w' + ); + return `${keychainJson}\n`; + }); + + const overlayDir = withPlatform('darwin', () => + prepareWithStubbedKeychain({ worktreePath: worktreeRoot, sourceDir }) + ); + tempDirs.push(overlayDir); + + assert.ok(overlayDir, 'expected an overlay config dir'); + assert.strictEqual( + fs.readFileSync(path.join(overlayDir, '.credentials.json'), 'utf8'), + keychainJson, + 'isolated CLAUDE_CONFIG_DIR must receive materialized Keychain credentials' + ); + assert.strictEqual( + fs.statSync(path.join(overlayDir, '.credentials.json')).mode & 0o777, + 0o600, + 'materialized credentials should be owner-readable only' + ); + }); }); diff --git a/tests/verify-github-pr-hook.test.js b/tests/verify-github-pr-hook.test.js index f53ddc11..b6f81d47 100644 --- a/tests/verify-github-pr-hook.test.js +++ b/tests/verify-github-pr-hook.test.js @@ -5,7 +5,6 @@ * Part of issue #340 - Prevent git-pusher hallucination */ -/* global describe, beforeEach, afterEach, it */ const assert = require('assert'); const childProcess = require('child_process'); @@ -571,4 +570,156 @@ describe('verify_pull_request hook action', () => { assert.strictEqual(agent.lastPublished.content.data.mr_number, 42); assert.strictEqual(agent.lastPublished.content.data.verification_platform, 'gitlab'); }); + + // REGRESSION: issue #452 - `--pr` (autoMerge=false) must stop at PR creation. + // An OPEN, unmerged PR is the SUCCESS case for --pr mode, not a failure. + describe('review mode (autoMerge=false, --pr without --ship)', () => { + it('publishes CLUSTER_COMPLETE for an OPEN unmerged PR without polling or failing', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_pull_request', config: { autoMerge: false } }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/123', + pr_number: 123, + merged: false, + }), + }; + + let callCount = 0; + mockSpawnSyncFn = () => { + callCount++; + return spawnSuccess( + JSON.stringify({ + number: 123, + state: 'OPEN', + mergedAt: null, + url: 'https://github.com/org/repo/pull/123', + }) + ); + }; + + await executeHook({ hook, agent, result }); + + assert(agent.lastPublished, 'Expected CLUSTER_COMPLETE to be published'); + assert.strictEqual(agent.lastPublished.topic, 'CLUSTER_COMPLETE'); + assert.strictEqual(agent.lastPublished.content.data.reason, 'git-pusher-complete-verified'); + assert.strictEqual(agent.lastPublished.content.data.pr_number, 123); + assert.strictEqual(agent.lastPublished.content.data.merged, false); + assert.strictEqual( + agent.lastPublished.content.data.verification_pending, + undefined, + 'review mode is a final success, not a pending state' + ); + // Only the initial fetch should run - no merge-polling loop for autoMerge=false. + assert.strictEqual(callCount, 1, `Expected exactly 1 gh call (got ${callCount})`); + }); + + it('treats PR existence as success even if review-mode agent output claims merged:true', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_pull_request', config: { autoMerge: false } }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/124', + pr_number: 124, + merged: true, + }), + }; + + let callCount = 0; + mockSpawnSyncFn = () => { + callCount++; + return spawnSuccess( + JSON.stringify({ + number: 124, + state: 'OPEN', + mergedAt: null, + url: 'https://github.com/org/repo/pull/124', + }) + ); + }; + + await executeHook({ hook, agent, result }); + + assert.strictEqual(agent.lastPublished.topic, 'CLUSTER_COMPLETE'); + assert.strictEqual(agent.lastPublished.content.data.reason, 'git-pusher-complete-verified'); + assert.strictEqual(agent.lastPublished.content.data.pr_number, 124); + assert.strictEqual(agent.lastPublished.content.data.merged, false); + assert.strictEqual(callCount, 1, `Expected exactly 1 gh call (got ${callCount})`); + }); + + it('fails review mode when GitHub reports the PR is already merged', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_pull_request', config: { autoMerge: false } }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/125', + pr_number: 125, + merged: false, + }), + }; + + mockSpawnSyncFn = () => + spawnSuccess( + JSON.stringify({ + number: 125, + state: 'MERGED', + mergedAt: '2026-07-08T20:00:00Z', + url: 'https://github.com/org/repo/pull/125', + }) + ); + + await assert.rejects( + () => executeHook({ hook, agent, result }), + /already merged.*review mode \(autoMerge=false\)/i + ); + assert.strictEqual(agent.lastPublished, null, 'merged review-mode PR must not complete'); + }); + + it('still throws when the PR does not exist (hallucination check still applies)', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_pull_request', config: { autoMerge: false } }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/9999', + merged: false, + }), + }; + + mockSpawnSyncFn = () => spawnFailure('Could not resolve to a PullRequest'); + + await assert.rejects(() => executeHook({ hook, agent, result }), /DOES NOT EXIST/); + }); + + it('undefined autoMerge (existing callers) still defaults to merge-required (fail-closed)', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_pull_request' }; // no config at all + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/123', + pr_number: 123, + merged: true, + }), + }; + + // Always OPEN -> should hit the existing verification-pending path, not the + // review-mode short-circuit, proving the undefined -> true default held. + mockSpawnSyncFn = () => { + return spawnSuccess( + JSON.stringify({ + number: 123, + state: 'OPEN', + mergedAt: null, + url: 'https://github.com/org/repo/pull/123', + }) + ); + }; + + await executeHook({ hook, agent, result }); + assert.strictEqual( + agent.lastPublished.content.data.reason, + 'git-pusher-complete-verification-pending' + ); + assert.strictEqual(agent.lastPublished.content.data.verification_pending, true); + }); + }); }); diff --git a/tests/worktree-compose-cleanup.test.js b/tests/worktree-compose-cleanup.test.js index 4465ad92..7c6f7b58 100644 --- a/tests/worktree-compose-cleanup.test.js +++ b/tests/worktree-compose-cleanup.test.js @@ -1,18 +1,17 @@ /** * Worktree Docker Compose Cleanup Test Suite * - * Regression test for bug where `docker compose down` was never called when - * cleaning up zeroshot worktrees. Agents could run `docker compose up` inside - * worktrees, and those containers would keep running after session end, - * hogging host ports (5433, 6379, 3001, etc.) and blocking the main project. + * Regression tests for worktree Docker Compose cleanup. * - * Root cause: removeWorktree() only did `git worktree remove` + `fs.rmSync`, - * never tore down Docker Compose services. The orchestrator stop() path - * (Ctrl+C / SIGINT) preserved worktrees entirely, including running containers. + * Safety contract: + * - automatic cleanup must never pass `--volumes` + * - automatic cleanup must never touch a pinned/shared Compose project + * - automatic cleanup may only tear down a project explicitly scoped to the + * worktree directory basename * - * Fix: - * - isolation-manager.js: removeWorktree() now calls `docker compose down` first - * - orchestrator.js: stop() calls _teardownWorktreeCompose() even when preserving worktree + * These tests cover both cleanup entry points: + * - IsolationManager.removeWorktree() for completed --pr/--ship cleanup + * - Orchestrator._teardownWorktreeCompose() for stop/Ctrl+C cleanup */ const assert = require('assert'); @@ -26,7 +25,7 @@ function isComposeDownCall(call) { call.command === 'docker' && Array.isArray(call.args) && call.args[0] === 'compose' && - call.args[1] === 'down' + call.args.includes('down') ); } @@ -34,13 +33,23 @@ describe('Worktree Docker Compose Cleanup', function () { this.timeout(10000); let tmpDir; + let origComposeProjectName; beforeEach(function () { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-compose-cleanup-')); + // Isolate from any host-level COMPOSE_PROJECT_NAME (e.g. this repo's own dev setup + // may export one) so unpinned-project tests reflect the worktree-basename default. + origComposeProjectName = process.env.COMPOSE_PROJECT_NAME; + delete process.env.COMPOSE_PROJECT_NAME; }); afterEach(function () { fs.rmSync(tmpDir, { recursive: true, force: true }); + if (origComposeProjectName === undefined) { + delete process.env.COMPOSE_PROJECT_NAME; + } else { + process.env.COMPOSE_PROJECT_NAME = origComposeProjectName; + } }); describe('IsolationManager.removeWorktree', function () { @@ -89,8 +98,13 @@ describe('Worktree Docker Compose Cleanup', function () { 'docker compose down should use --remove-orphans' ); assert.ok( - composeDownCall.args.includes('--volumes'), - 'docker compose down should use --volumes to free disk' + !composeDownCall.args.includes('--volumes'), + 'docker compose down must NEVER use --volumes (irreversible data loss on shared projects)' + ); + assert.ok( + composeDownCall.args.includes('-p') && + composeDownCall.args[composeDownCall.args.indexOf('-p') + 1] === 'test-worktree', + 'docker compose down should pin the project to the worktree directory basename' ); // Verify ordering: compose down appears before git worktree remove @@ -153,6 +167,102 @@ describe('Worktree Docker Compose Cleanup', function () { } }); + it('should skip docker compose down when compose project name is pinned via top-level name', function () { + const origSpawnSync = childProcess.spawnSync; + const calls = []; + + childProcess.spawnSync = function (command, args, opts) { + calls.push({ command, args, cwd: opts?.cwd }); + if (command === 'git' && args[0] === 'worktree') { + return { status: 1, stdout: '', stderr: 'not a git repo' }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + try { + delete require.cache[require.resolve('../src/isolation-manager')]; + const IsolationManager = require('../src/isolation-manager'); + const manager = new IsolationManager(); + + const fakeWorktreePath = path.join(tmpDir, 'pinned-name-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync( + path.join(fakeWorktreePath, 'docker-compose.yml'), + 'name: myproj\nservices:\n db:\n image: postgres\n' + ); + + manager.worktrees.set('test-pinned-name', { + path: fakeWorktreePath, + branch: 'zeroshot/test-pinned-name', + repoRoot: tmpDir, + }); + + manager.cleanupWorktreeIsolation('test-pinned-name'); + + const composeCall = calls.find( + (c) => c.command === 'docker' && Array.isArray(c.args) && c.args[0] === 'compose' + ); + assert.strictEqual( + composeCall, + undefined, + 'docker compose down must NOT be invoked when the project name is pinned' + ); + } finally { + childProcess.spawnSync = origSpawnSync; + delete require.cache[require.resolve('../src/isolation-manager')]; + } + }); + + it('should skip docker compose down when COMPOSE_PROJECT_NAME env var is set', function () { + const origSpawnSync = childProcess.spawnSync; + const origEnv = process.env.COMPOSE_PROJECT_NAME; + const calls = []; + + childProcess.spawnSync = function (command, args, opts) { + calls.push({ command, args, cwd: opts?.cwd }); + if (command === 'git' && args[0] === 'worktree') { + return { status: 1, stdout: '', stderr: 'not a git repo' }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + process.env.COMPOSE_PROJECT_NAME = 'shared-host-project'; + try { + delete require.cache[require.resolve('../src/isolation-manager')]; + const IsolationManager = require('../src/isolation-manager'); + const manager = new IsolationManager(); + + const fakeWorktreePath = path.join(tmpDir, 'pinned-env-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync(path.join(fakeWorktreePath, 'docker-compose.yml'), 'version: "3"'); + + manager.worktrees.set('test-pinned-env', { + path: fakeWorktreePath, + branch: 'zeroshot/test-pinned-env', + repoRoot: tmpDir, + }); + + manager.cleanupWorktreeIsolation('test-pinned-env'); + + const composeCall = calls.find( + (c) => c.command === 'docker' && Array.isArray(c.args) && c.args[0] === 'compose' + ); + assert.strictEqual( + composeCall, + undefined, + 'docker compose down must NOT be invoked when COMPOSE_PROJECT_NAME pins a shared project' + ); + } finally { + if (origEnv === undefined) { + delete process.env.COMPOSE_PROJECT_NAME; + } else { + process.env.COMPOSE_PROJECT_NAME = origEnv; + } + childProcess.spawnSync = origSpawnSync; + delete require.cache[require.resolve('../src/isolation-manager')]; + } + }); + it('should not fail when docker compose down throws', function () { const origSpawnSync = childProcess.spawnSync; @@ -223,7 +333,72 @@ describe('Worktree Docker Compose Cleanup', function () { assert.ok(composeCall, '_teardownWorktreeCompose should call docker compose down'); assert.strictEqual(composeCall.cwd, fakeWorktreePath); assert.ok(composeCall.args.includes('--remove-orphans')); - assert.ok(composeCall.args.includes('--volumes')); + assert.ok( + !composeCall.args.includes('--volumes'), + 'docker compose down must NEVER use --volumes (irreversible data loss on shared projects)' + ); + assert.ok( + composeCall.args.includes('-p') && + composeCall.args[composeCall.args.indexOf('-p') + 1] === 'stop-test-worktree', + 'docker compose down should pin the project to the worktree directory basename' + ); + }); + + it('should skip docker compose down when compose project name is pinned via top-level name', function () { + const Orchestrator = require('../src/orchestrator'); + const orchestrator = new Orchestrator({ dataDir: tmpDir }); + + const fakeWorktreePath = path.join(tmpDir, 'pinned-name-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync( + path.join(fakeWorktreePath, 'docker-compose.yml'), + 'name: myproj\nservices:\n db:\n image: postgres\n' + ); + + const calls = []; + childProcess.spawnSync = function (command, args, opts) { + calls.push({ command, args, cwd: opts?.cwd }); + return { status: 0, stdout: '', stderr: '' }; + }; + + orchestrator._teardownWorktreeCompose(fakeWorktreePath); + assert.strictEqual( + calls.length, + 0, + 'docker compose down must NOT be invoked when the project name is pinned (shared host project)' + ); + }); + + it('should skip docker compose down when COMPOSE_PROJECT_NAME env var is set', function () { + const Orchestrator = require('../src/orchestrator'); + const orchestrator = new Orchestrator({ dataDir: tmpDir }); + + const fakeWorktreePath = path.join(tmpDir, 'pinned-env-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync(path.join(fakeWorktreePath, 'docker-compose.yml'), 'version: "3"'); + + const calls = []; + childProcess.spawnSync = function (command, args, opts) { + calls.push({ command, args, cwd: opts?.cwd }); + return { status: 0, stdout: '', stderr: '' }; + }; + + const origEnv = process.env.COMPOSE_PROJECT_NAME; + process.env.COMPOSE_PROJECT_NAME = 'shared-host-project'; + try { + orchestrator._teardownWorktreeCompose(fakeWorktreePath); + assert.strictEqual( + calls.length, + 0, + 'docker compose down must NOT be invoked when COMPOSE_PROJECT_NAME pins a shared project' + ); + } finally { + if (origEnv === undefined) { + delete process.env.COMPOSE_PROJECT_NAME; + } else { + process.env.COMPOSE_PROJECT_NAME = origEnv; + } + } }); it('should skip when no docker-compose.yml exists', function () {