Showing preview only (554K chars total). Download the full file or copy to clipboard to get everything.
Repository: aarondfrancis/counselors
Branch: main
Commit: ddf0a2476d95
Files: 117
Total size: 520.3 KB
Directory structure:
gitextract_xut__s4g/
├── .gitattributes
├── .github/
│ ├── scripts/
│ │ └── parse-changelog.sh
│ └── workflows/
│ ├── ci.yml
│ ├── release-binaries.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── README.md
├── assets/
│ ├── amp-deep-settings.json
│ ├── amp-readonly-settings.json
│ └── presets/
│ ├── bughunt.yml
│ ├── contracts.yml
│ ├── hotspots.yml
│ ├── invariants.yml
│ ├── regression.yml
│ └── security.yml
├── biome.json
├── install.sh
├── package.json
├── scripts/
│ └── build-binaries.ts
├── src/
│ ├── adapters/
│ │ ├── amp.ts
│ │ ├── base.ts
│ │ ├── claude.ts
│ │ ├── codex.ts
│ │ ├── custom.ts
│ │ ├── gemini.ts
│ │ └── index.ts
│ ├── cli.ts
│ ├── commands/
│ │ ├── _run-shared.ts
│ │ ├── agent.ts
│ │ ├── cleanup.ts
│ │ ├── config.ts
│ │ ├── doctor.ts
│ │ ├── groups/
│ │ │ ├── add.ts
│ │ │ ├── list.ts
│ │ │ └── remove.ts
│ │ ├── init.ts
│ │ ├── loop.ts
│ │ ├── make-dir.ts
│ │ ├── run.ts
│ │ ├── skill.ts
│ │ ├── tools/
│ │ │ ├── add.ts
│ │ │ ├── discover.ts
│ │ │ ├── list.ts
│ │ │ ├── remove.ts
│ │ │ ├── rename.ts
│ │ │ └── test.ts
│ │ └── upgrade.ts
│ ├── constants.ts
│ ├── core/
│ │ ├── amp-utils.ts
│ │ ├── boilerplate.ts
│ │ ├── cleanup.ts
│ │ ├── config.ts
│ │ ├── context.ts
│ │ ├── discovery.ts
│ │ ├── dispatcher.ts
│ │ ├── executor.ts
│ │ ├── fs-utils.ts
│ │ ├── loop.ts
│ │ ├── prompt-builder.ts
│ │ ├── prompt-writer.ts
│ │ ├── repo-discovery.ts
│ │ ├── synthesis.ts
│ │ ├── text-utils.ts
│ │ └── upgrade.ts
│ ├── presets/
│ │ ├── index.ts
│ │ └── types.ts
│ ├── types.ts
│ └── ui/
│ ├── agent-reporter.ts
│ ├── logger.ts
│ ├── output.ts
│ ├── prompts.ts
│ ├── reporter.ts
│ └── terminal-reporter.ts
├── tests/
│ ├── fixtures/
│ │ ├── bin/
│ │ │ ├── fake-amp
│ │ │ ├── fake-claude
│ │ │ └── fake-codex
│ │ └── configs/
│ │ └── valid.json
│ ├── integration/
│ │ └── cli.test.ts
│ └── unit/
│ ├── adapters/
│ │ ├── amp.test.ts
│ │ ├── claude.test.ts
│ │ ├── codex.test.ts
│ │ ├── custom.test.ts
│ │ ├── gemini.test.ts
│ │ └── resolve.test.ts
│ ├── agent-reporter.test.ts
│ ├── amp-utils.test.ts
│ ├── cleanup.test.ts
│ ├── config.test.ts
│ ├── constants.test.ts
│ ├── context.test.ts
│ ├── discovery.test.ts
│ ├── dispatcher.test.ts
│ ├── execute-test.test.ts
│ ├── executor.test.ts
│ ├── fs-utils.test.ts
│ ├── logger.test.ts
│ ├── loop-command.test.ts
│ ├── loop.test.ts
│ ├── output.test.ts
│ ├── presets.test.ts
│ ├── prompt-builder.test.ts
│ ├── prompt-writer.test.ts
│ ├── prompts.test.ts
│ ├── repo-discovery.test.ts
│ ├── reporter.test.ts
│ ├── run-shared.test.ts
│ ├── synthesis.test.ts
│ ├── terminal-reporter.test.ts
│ ├── text-utils.test.ts
│ ├── tools-add-custom-model.test.ts
│ ├── upgrade-exec.test.ts
│ ├── upgrade-standalone.test.ts
│ └── upgrade.test.ts
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
================================================
FILE: .github/scripts/parse-changelog.sh
================================================
#!/bin/bash
set -e
# parse-changelog.sh
# Parses and updates CHANGELOG.md following Keep a Changelog format
#
# Usage:
# ./parse-changelog.sh validate - Check changelog has unreleased content
# ./parse-changelog.sh release <version> - Move unreleased to version section
CHANGELOG_FILE="${CHANGELOG_FILE:-CHANGELOG.md}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
error() {
echo -e "${RED}Error: $1${NC}" >&2
exit 1
}
success() {
echo -e "${GREEN}$1${NC}"
}
warn() {
echo -e "${YELLOW}$1${NC}"
}
# Check if changelog exists
check_changelog_exists() {
if [[ ! -f "$CHANGELOG_FILE" ]]; then
error "CHANGELOG.md not found"
fi
}
# Validate that [Unreleased] section exists and has content
validate() {
check_changelog_exists
if ! grep -q "## \[Unreleased\]" "$CHANGELOG_FILE"; then
error "CHANGELOG.md does not contain an [Unreleased] section"
fi
# Extract content between [Unreleased] and the next version section
local unreleased_content
unreleased_content=$(awk '
/^## \[Unreleased\]/ { capture = 1; next }
/^## \[/ && capture { exit }
capture { print }
' "$CHANGELOG_FILE")
# Check if there's any actual content (not just whitespace)
local trimmed
trimmed=$(echo "$unreleased_content" | grep -v '^[[:space:]]*$' | grep -v '^###' || true)
if [[ -z "$trimmed" ]]; then
warn "No unreleased changes found. Will use default release notes."
else
success "Changelog validation passed. Found unreleased changes."
fi
}
# Move unreleased content to a new version section
release() {
local version="$1"
if [[ -z "$version" ]]; then
error "Version argument required. Usage: $0 release <version>"
fi
# Validate version format (X.Y.Z)
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
error "Invalid version format '$version'. Expected X.Y.Z (e.g., 1.2.0)"
fi
check_changelog_exists
validate
# If unreleased section is empty, inject default content
local unreleased_content
unreleased_content=$(awk '
/^## \[Unreleased\]/ { capture = 1; next }
/^## \[/ && capture { exit }
capture { print }
' "$CHANGELOG_FILE")
local trimmed
trimmed=$(echo "$unreleased_content" | grep -v '^[[:space:]]*$' | grep -v '^###' || true)
if [[ -z "$trimmed" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' '/^## \[Unreleased\]/a\
\
### Fixed\
- Various bug fixes\
' "$CHANGELOG_FILE"
else
sed -i '/^## \[Unreleased\]/a\\n### Fixed\n- Various bug fixes\n' "$CHANGELOG_FILE"
fi
fi
local date
date=$(date +%Y-%m-%d)
# Get repository info for links
local repo_url
repo_url=$(git config --get remote.origin.url 2>/dev/null | sed 's/\.git$//' | sed 's/git@github.com:/https:\/\/github.com\//')
if [[ -z "$repo_url" ]]; then
warn "Could not detect repository URL. Links may need manual update."
repo_url="https://github.com/OWNER/REPO"
fi
# Use awk to move content from [Unreleased] to the new version
awk -v version="$version" -v date="$date" '
BEGIN { in_unreleased = 0; content = "" }
/^## \[Unreleased\]/ {
print $0
print ""
in_unreleased = 1
next
}
/^## \[/ && in_unreleased {
# Found next version section, output new version with collected content
print "## [" version "] - " date
print content
in_unreleased = 0
}
# Stop capturing at link references section (lines starting with [)
/^\[/ && in_unreleased {
# Output new version with collected content before the links
print "## [" version "] - " date
print content
in_unreleased = 0
}
in_unreleased {
content = content $0 "\n"
next
}
{ print }
END {
# Handle case where unreleased section goes to end of file
if (in_unreleased && content != "") {
print "## [" version "] - " date
print content
}
}
' "$CHANGELOG_FILE" > "$CHANGELOG_FILE.tmp" && mv "$CHANGELOG_FILE.tmp" "$CHANGELOG_FILE"
# Update the [Unreleased] comparison link
if grep -q "\[Unreleased\]:.*compare" "$CHANGELOG_FILE"; then
sed -i.bak "s|\[Unreleased\]: \(.*\)/compare/v[0-9.]*\.\.\.HEAD|[Unreleased]: \1/compare/v$version...HEAD|" "$CHANGELOG_FILE"
rm -f "$CHANGELOG_FILE.bak"
fi
# Find previous version for the new link
local prev_version
prev_version=$(grep -oE '\[[0-9]+\.[0-9]+\.[0-9]+\](?=:)' "$CHANGELOG_FILE" 2>/dev/null | head -1 | tr -d '[]' || true)
# Add new version link
if [[ -n "$prev_version" ]] && [[ "$prev_version" != "$version" ]]; then
# Insert new version link before the previous version link
sed -i.bak "/^\[$prev_version\]:/i\\
[$version]: $repo_url/compare/v$prev_version...v$version
" "$CHANGELOG_FILE"
rm -f "$CHANGELOG_FILE.bak"
elif ! grep -q "^\[$version\]:" "$CHANGELOG_FILE"; then
# First release - add link at the end
echo "[$version]: $repo_url/releases/tag/v$version" >> "$CHANGELOG_FILE"
fi
success "Updated CHANGELOG.md for version $version"
}
# Show usage
usage() {
echo "Usage: $0 <command> [args]"
echo ""
echo "Commands:"
echo " validate Check changelog has unreleased content"
echo " release <version> Move unreleased content to version section"
echo ""
echo "Environment variables:"
echo " CHANGELOG_FILE Path to changelog (default: CHANGELOG.md)"
}
# Main
case "${1:-}" in
validate)
validate
;;
release)
release "$2"
;;
-h|--help|help)
usage
;;
*)
usage
exit 1
;;
esac
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
jobs:
autoformat:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Auto-fix formatting
run: npx biome check --write src/ tests/
- name: Commit formatting fixes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "style: auto-fix formatting [skip ci]"
git push
test:
runs-on: ${{ matrix.os }}
needs: [autoformat]
if: always() && (needs.autoformat.result == 'success' || needs.autoformat.result == 'skipped')
strategy:
matrix:
include:
- os: ubuntu-latest
node-version: 20
test-command: npm run test
- os: ubuntu-latest
node-version: 22
test-command: npm run test
- os: ubuntu-latest
node-version: 24
test-command: npm run test
- os: windows-latest
node-version: 20
test-command: npm run test -- tests/unit
- os: windows-latest
node-version: 22
test-command: npm run test -- tests/unit
- os: windows-latest
node-version: 24
test-command: npm run test -- tests/unit
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Test
run: ${{ matrix.test-command }}
================================================
FILE: .github/workflows/release-binaries.yml
================================================
name: Release Binaries
on:
workflow_call:
inputs:
tag:
description: 'Tag to build binaries for (e.g., v1.2.3)'
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: 'Tag to build binaries for (e.g., v1.2.3)'
required: true
type: string
permissions:
contents: write
jobs:
build-binaries:
runs-on: ubuntu-latest
steps:
- name: Normalize and validate tag
id: tag
run: |
TAG="${{ inputs.tag }}"
TAG="${TAG#refs/tags/}"
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid tag format '$TAG'. Expected vX.Y.Z"
exit 1
fi
echo "value=$TAG" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ steps.tag.outputs.value }}
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build binaries
run: bun run scripts/build-binaries.ts
- name: Generate checksums
run: |
cd release
for f in counselors-darwin-arm64 counselors-darwin-x64 counselors-linux-x64 counselors-linux-arm64; do
sha256sum "$f" > "$f.sha256"
done
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.value }}
files: |
release/counselors-darwin-arm64
release/counselors-darwin-x64
release/counselors-linux-x64
release/counselors-linux-arm64
release/counselors-darwin-arm64.sha256
release/counselors-darwin-x64.sha256
release/counselors-linux-x64.sha256
release/counselors-linux-arm64.sha256
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
run-name: "Release ${{ inputs.version }}"
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.2.0 or v1.2.0)'
required: true
type: string
permissions:
contents: write
id-token: write
jobs:
ci:
runs-on: ubuntu-latest
if: github.actor == 'aarondfrancis'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Test
run: npm run test
release:
needs: ci
runs-on: ubuntu-latest
if: github.actor == 'aarondfrancis'
outputs:
version: ${{ steps.version.outputs.number }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Normalize and validate version
id: version
run: |
VERSION="${{ inputs.version }}"
VERSION="${VERSION#v}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format '$VERSION'. Expected X.Y.Z or vX.Y.Z"
exit 1
fi
echo "number=$VERSION" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: npm
- name: Upgrade npm for OIDC support
run: npm install -g npm@latest
- name: Install dependencies
run: npm ci
- name: Validate changelog
run: |
chmod +x .github/scripts/parse-changelog.sh
.github/scripts/parse-changelog.sh validate
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump version in package.json
run: npm version ${{ steps.version.outputs.number }} --no-git-tag-version
- name: Update changelog
run: .github/scripts/parse-changelog.sh release ${{ steps.version.outputs.number }}
- name: Commit version bump and changelog
run: |
git add package.json package-lock.json CHANGELOG.md
git commit -m "Release v${{ steps.version.outputs.number }}"
- name: Create and push tag
run: |
git tag "v${{ steps.version.outputs.number }}"
git push origin "v${{ steps.version.outputs.number }}"
- name: Push to main
run: git push
- name: Build
run: npm run build
- name: Pack npm tarball and compute checksum
id: tarball
run: |
set -euo pipefail
PACK_OUTPUT_FILE="$(mktemp)"
npm pack --json > "$PACK_OUTPUT_FILE"
cat "$PACK_OUTPUT_FILE"
TARBALL="$(node -e "const fs = require('node:fs'); const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); if (!Array.isArray(data) || !data[0] || !data[0].filename) process.exit(1); process.stdout.write(data[0].filename);" "$PACK_OUTPUT_FILE")"
SHA256="$(sha256sum "$TARBALL" | awk '{print $1}')"
echo "filename=$TARBALL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
- name: Publish to npm
run: npm publish --provenance --access public "${{ steps.tarball.outputs.filename }}"
release-binaries:
needs: release
if: github.actor == 'aarondfrancis'
uses: ./.github/workflows/release-binaries.yml
with:
tag: v${{ needs.release.outputs.version }}
update-homebrew:
needs:
- release
- release-binaries
runs-on: ubuntu-latest
if: github.actor == 'aarondfrancis'
steps:
- name: Update Homebrew formula
env:
VERSION: ${{ needs.release.outputs.version }}
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
set -euo pipefail
if [ -z "${COMMITTER_TOKEN:-}" ]; then
echo "HOMEBREW_TAP_TOKEN is not configured."
exit 1
fi
TAP_DIR="$(mktemp -d)"
FORMULA_PATH="Formula/counselors.rb"
RELEASE_BASE_URL="https://github.com/aarondfrancis/counselors/releases/download/v${VERSION}"
read_sha() {
local name="$1"
local hash=""
for attempt in {1..8}; do
hash="$(curl -fsSL "${RELEASE_BASE_URL}/${name}.sha256" | awk '{print $1}' | tr -d '\r\n' || true)"
if [[ "$hash" =~ ^[a-f0-9]{64}$ ]]; then
echo "$hash"
return 0
fi
if [ "$attempt" -eq 8 ]; then
echo "Failed to fetch valid checksum for ${name} after retries."
exit 1
fi
sleep 10
done
}
DARWIN_ARM64_SHA="$(read_sha counselors-darwin-arm64)"
DARWIN_X64_SHA="$(read_sha counselors-darwin-x64)"
LINUX_ARM64_SHA="$(read_sha counselors-linux-arm64)"
LINUX_X64_SHA="$(read_sha counselors-linux-x64)"
for hash in "$DARWIN_ARM64_SHA" "$DARWIN_X64_SHA" "$LINUX_ARM64_SHA" "$LINUX_X64_SHA"; do
if ! [[ "$hash" =~ ^[a-f0-9]{64}$ ]]; then
echo "Invalid checksum fetched from release assets: $hash"
exit 1
fi
done
git clone "https://x-access-token:${COMMITTER_TOKEN}@github.com/aarondfrancis/homebrew-tap.git" "$TAP_DIR"
cd "$TAP_DIR"
cat > "$FORMULA_PATH" <<EOF
class Counselors < Formula
desc "Fan out prompts to multiple AI coding agents in parallel"
homepage "https://github.com/aarondfrancis/counselors"
version "${VERSION}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "${RELEASE_BASE_URL}/counselors-darwin-arm64"
sha256 "${DARWIN_ARM64_SHA}"
else
url "${RELEASE_BASE_URL}/counselors-darwin-x64"
sha256 "${DARWIN_X64_SHA}"
end
end
on_linux do
if Hardware::CPU.arm?
url "${RELEASE_BASE_URL}/counselors-linux-arm64"
sha256 "${LINUX_ARM64_SHA}"
else
url "${RELEASE_BASE_URL}/counselors-linux-x64"
sha256 "${LINUX_X64_SHA}"
end
end
def install
binary = Dir["counselors-*"].first || Dir["*"].find { |f| File.file?(f) }
raise "No counselors binary found in staging directory" unless binary
bin.install binary => "counselors"
end
test do
assert_match version.to_s, shell_output("#{bin}/counselors --version")
end
end
EOF
if git diff --quiet -- "$FORMULA_PATH"; then
echo "No Homebrew formula changes to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$FORMULA_PATH"
git commit -m "counselors ${VERSION}"
git push origin main
smoke-test-installs:
needs:
- release
- release-binaries
- update-homebrew
runs-on: ${{ matrix.os }}
if: github.actor == 'aarondfrancis'
strategy:
fail-fast: false
matrix:
include:
- method: npm
os: macos-latest
- method: npm
os: ubuntu-latest
- method: standalone
os: macos-latest
- method: standalone
os: ubuntu-latest
- method: homebrew
os: macos-latest
env:
VERSION: ${{ needs.release.outputs.version }}
steps:
- name: Smoke test npm install
if: matrix.method == 'npm'
run: |
set -euo pipefail
PREFIX="$RUNNER_TEMP/counselors-npm"
rm -rf "$PREFIX"
for attempt in {1..8}; do
if npm install -g "counselors@${VERSION}" --prefix "$PREFIX"; then
break
fi
if [ "$attempt" -eq 8 ]; then
echo "npm install failed after retries."
exit 1
fi
sleep 10
done
"$PREFIX/bin/counselors" --help >/dev/null
test "$("$PREFIX/bin/counselors" --version)" = "$VERSION"
- name: Smoke test standalone install
if: matrix.method == 'standalone'
run: |
set -euo pipefail
INSTALL_DIR="$RUNNER_TEMP/counselors-standalone"
SCRIPT="$RUNNER_TEMP/counselors-install.sh"
for attempt in {1..8}; do
if curl -fsSL "https://raw.githubusercontent.com/aarondfrancis/counselors/v${VERSION}/install.sh" -o "$SCRIPT"; then
break
fi
if [ "$attempt" -eq 8 ]; then
echo "failed to download install.sh after retries."
exit 1
fi
sleep 5
done
chmod +x "$SCRIPT"
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
for attempt in {1..8}; do
if INSTALL_DIR="$INSTALL_DIR" COUNSELORS_VERSION="$VERSION" GITHUB_TOKEN="${{ github.token }}" bash "$SCRIPT"; then
break
fi
if [ "$attempt" -eq 8 ]; then
echo "standalone install failed after retries."
exit 1
fi
rm -f "$INSTALL_DIR/counselors"
sleep 10
done
"$INSTALL_DIR/counselors" --help >/dev/null
test "$("$INSTALL_DIR/counselors" --version)" = "$VERSION"
- name: Smoke test Homebrew install
if: matrix.method == 'homebrew'
run: |
set -euo pipefail
brew tap aarondfrancis/homebrew-tap
for attempt in {1..8}; do
if brew list --versions counselors >/dev/null 2>&1; then
brew upgrade aarondfrancis/homebrew-tap/counselors || true
else
brew install aarondfrancis/homebrew-tap/counselors || true
fi
INSTALLED_VERSION="$(brew list --versions counselors 2>/dev/null | awk '{print $2}')"
if [ "$INSTALLED_VERSION" = "$VERSION" ]; then
break
fi
if [ "$attempt" -eq 8 ]; then
echo "Homebrew install failed after retries."
exit 1
fi
brew uninstall counselors --force >/dev/null 2>&1 || true
sleep 15
done
BREW_BIN="$(brew --prefix counselors)/bin/counselors"
"$BREW_BIN" --help >/dev/null
test "$("$BREW_BIN" --version)" = "$VERSION"
================================================
FILE: .gitignore
================================================
node_modules/
dist/
release/
agents/
*.tgz
ogimage.txt
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Standalone release binaries are now built into `release/` instead of `dist/`, decoupling binary artifacts from npm package contents
- Homebrew formula updates now target platform-specific GitHub release binaries directly (macOS/Linux, arm64/x64) instead of the npm tarball
### Fixed
- npm package publish size is dramatically reduced by excluding standalone compiled binaries from the published `files` list
- Release smoke tests now include Linux (`ubuntu-latest`) for `npm` and `standalone` install paths to catch Linux-only install/runtime breakages
- Homebrew checksum resolution now retries when fetching freshly uploaded release asset checksums, reducing transient CDN propagation failures
- Generated Homebrew formula install logic now validates binary discovery in the staging directory before install, with clearer failure behavior
## [0.5.2] - 2026-02-27
### Added
- `loop --duration` now prints the configured duration at the start of execution so users know how long the session will run
## [0.5.1] - 2026-02-27
### Added
- Child process PIDs are now surfaced during discovery and prompt-writing phases, giving outer agents visibility to monitor long-running prep steps
## [0.5.0] - 2026-02-26
### Added
- `loop --list-presets` to print built-in presets with defaults and summaries
- Presets system for domain-specific multi-round workflows, now with single-word built-ins: `bughunt`, `security`, `invariants`, `regression`, `contracts`, `hotspots`
- Custom presets via YAML files — pass `--preset path/to/preset.yml` to use your own preset definitions
- Non-TTY heartbeat: emits elapsed time and active PIDs to stderr every 60 seconds, preventing outer-agent timeouts during long-running dispatches
- `mkdir` can now be run without a prompt to create only an output directory (no `prompt.md`)
- `upgrade` now prints guidance to refresh the skill template after a successful upgrade
### Changed
- Skill template now warns orchestrating LLMs that dispatch is long-running (10–20+ min) and suggests background execution with progress monitoring
- Built-in presets are now YAML files with schema validation instead of hardcoded TypeScript
- Output directory names include a timestamp prefix for uniqueness (e.g. `1740300000-bughunt`)
- Output paths are always absolute, consistent across single `run` and `loop` commands
- Default tool timeout increased from 9 minutes to 15 minutes
- Non-TTY output is purpose-built for agent consumers with structured lifecycle messages (phase started/completed, tool started/completed with PID and duration)
- Replaced monolithic `ProgressDisplay` with event-driven Reporter interface — `TerminalReporter` for TTY, `AgentReporter` for non-TTY, `NullReporter` for dry-run
- Loop prompt augmentation now uses stronger multi-round guidance: challenge prior findings, use prior findings as leads, and mark overlap status (`confirmed`, `refined`, `invalidated`, `duplicate`)
- `loop` now always appends execution boilerplate, and non-preset inline prompts run discovery + prompt-writing enhancement by default (`--no-inline-enhancement` to opt out); prompt files (`-f`) and stdin prompts skip discovery/prompt-writing
- Duration-based loop runs are now truly unbounded by round count (removed hidden 999-round cap); reporter shows `Round N` when total rounds are open-ended
- Prior-round prompt references are capped to the most recent 8 reports to control prompt growth in long loops
- `mkdir --json` now reports `promptSource: "none"` and `promptFilePath: null` when no prompt input is provided
- Loop output files renamed: `round-synthesis.md` → `round-notes.md`, `final-synthesis.md` → `final-notes.md`
- CLI help text polished for LLM-friendly clarity across commands
- Failed tools now show `see /path/to/tool.stderr` instead of the misleading first line of stderr
- Between loop rounds, reporter shows elapsed time, remaining time (when `--duration` is set), and a Ctrl+C hint
### Security
- Environment variable denylist (`ENV_DENYLIST`) blocks `NODE_OPTIONS`, `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc. from being re-injected via `invocation.env`, bypassing the allowlist
- Markdown fence injection in `gatherContext` fixed via `safeFence()` that extends the delimiter until it doesn't conflict with content
### Fixed
- Restored `npm run typecheck` health by fixing `runLoop` abort-state narrowing around SIGINT handling
- Null stdio early-return no longer leaks child processes — tracked in `activeChildren` immediately after spawn
- `generateSlug` returns `untitled` instead of empty string for non-alphanumeric input
- `--file` no longer silently ignores `--context` — gathered context is appended to file content when both flags are provided
- Group + explicit tool overlap no longer creates duplicates — tools appearing in both `--group` and `--tools` are deduped while preserving intentional duplicates
- UTF-8 buffer truncation no longer splits multi-byte characters — `truncateUtf8` walks backwards past continuation bytes
- `extractHeadings` uses `report.outputFile` instead of reconstructing the path
- `removeToolFromConfig` cleans up groups left empty after tool removal
## [0.4.12] - 2026-02-19
### Fixed
- `run -f` no longer creates duplicate output directories when the prompt file already lives inside the output base directory
- Gemini adapter appends a prompt instruction to suppress tool-use narration ("I will read...", "I will list...") that was polluting headless output
## [0.4.11] - 2026-02-19
### Added
- Progress display shows child process PIDs and parent PID, with a timing note that sessions may take 10+ minutes — lets users verify processes are alive via `ps` or `tasklist`
## [0.4.10] - 2026-02-16
### Changed
- `tools add` custom model flow now asks for a model identifier instead of raw CLI flags, and constructs the correct flags automatically per adapter
### Fixed
- Gemini CLI with Vertex AI auth now works — `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` are passed to subprocesses (#13)
## [0.4.9] - 2026-02-16
### Fixed
- Various bug fixes
## [0.4.8] - 2026-02-16
### Added
- `counselors doctor` now warns when multiple installations are detected (e.g. npm + Homebrew + standalone)
### Changed
- `counselors tools test` now shows verbose failure details: timeout detection, stderr content, and actual tool output
## [0.4.7] - 2026-02-16
### Added
- `counselors config` command — prints the config file path and the full resolved configuration as JSON
- `counselors tools test` now prints the exact shell command used for each tool, so users can reproduce tests manually
- `counselors doctor` now validates that every group member references a configured tool
### Fixed
- `counselors tools add <tool>` now defaults to a model-specific name (e.g. `gemini-3-pro`) instead of just the adapter name (e.g. `gemini`)
## [0.4.6] - 2026-02-16
### Fixed
- Gemini 3 models now use the correct `-preview` suffixed API model IDs (`gemini-3-pro-preview`, `gemini-3-flash-preview`), fixing `ModelNotFoundError` when running tools
## [0.4.5] - 2026-02-16
### Fixed
- Various bug fixes
## [0.4.4] - 2026-02-16
### Fixed
- `install.sh` now supports `COUNSELORS_VERSION` pinning and performs a more resilient latest-tag lookup (with optional `GITHUB_TOKEN` auth) to avoid transient GitHub API failures.
- Release standalone smoke test now fetches `install.sh` from the release tag and runs it with `COUNSELORS_VERSION`, eliminating `main` drift and reducing flaky retries.
- Release workflow now computes Homebrew's SHA256 from a locally packed npm tarball and publishes that exact tarball, avoiding npm registry propagation 404s during checksum resolution.
- Binary discovery now includes `PATH` entries in stage-2 fallback scans, reducing Windows false negatives when `where` lookup times out.
## [0.4.3] - 2026-02-16
### Changed
- Gemini model IDs now use Gemini 3 names (`gemini-3-pro`, `gemini-3-flash`) in adapter config and README group examples.
- Release workflow now calls the binaries workflow directly via `workflow_call` instead of relying on tag-push side effects.
### Fixed
- Release workflow now passes an explicit tag to Homebrew update logic in manual (`workflow_dispatch`) runs.
- Homebrew formula updates now pin the npm tarball SHA256 and replace `sha256 :no_check`, so `brew install` succeeds.
- Release workflow now runs parallel smoke tests for npm, standalone installer, and Homebrew installs, validating `--help` and version output.
## [0.4.2] - 2026-02-16
### Fixed
- Various bug fixes
## [0.4.1] - 2026-02-16
### Fixed
- Various bug fixes
## [0.4.0] - 2026-02-16
### Added
- `cleanup` command to delete run output directories older than a configurable age (defaults to 1 day)
- Tool groups (`groups` config, `counselors groups ...`, and `counselors run --group`)
- `upgrade` command with install-method detection (Homebrew, npm, pnpm, yarn, standalone binary)
- Standalone binary releases and `install.sh` curl installer
- Support running the same tool multiple times by repeating it in `--tools` (e.g. `--tools opus,opus,opus`)
### Changed
- Skill template and docs clarify that output directories are configurable via `defaults.outputDir` and `counselors run -o`
- CI runs Windows unit tests on Node 20, 22, and 24 (matching Ubuntu's Node coverage)
### Fixed
- Windows: fixed `.cmd/.bat` execution via `cross-spawn` (stdout capture, synthetic ENOENT), and hardened PATH injection + env allowlisting
## [0.3.4] - 2026-02-10
### Changed
- Agentic quickstart rewritten so agents don't refuse it as social engineering — user installs the CLI, agent only runs config commands with explicit purposes
- Skill template uses second-precision UNIX timestamps instead of millisecond-precision (macOS `date` doesn't support `%N`)
- README adds example prompts and a slash command example to the quickstart
## [0.3.3] - 2026-02-10
### Changed
- Gemini CLI read-only level upgraded from `bestEffort` to `enforced` (tool restrictions are sufficient)
- Doctor no longer warns on `bestEffort` read-only level — only `none` triggers a warning
### Fixed
- Doctor correctly reports Amp deep mode as `bestEffort` instead of `enforced`
## [0.3.2] - 2026-02-10
### Fixed
- `package.json` bin path and repository URL corrected for npm publishing
## [0.3.1] - 2026-02-10
### Changed
- `agent` command clarifies that `counselors skill` prints a reference template to adapt, not a file to blindly copy
### Fixed
- Skill install path in `agent` command now points to `~/.claude/skills/` instead of `~/.claude/commands/`
## [0.3.0] - 2026-02-10
### Added
- Multi-agent parallel dispatch with configurable adapters (Claude, Codex, Gemini, Amp, Custom)
- Project-level `.counselors.json` configuration with defaults overrides
- Tool management commands: add, remove, test, list, discover
- Doctor command for environment diagnostics
- Context gathering with file discovery and prompt building
- Response synthesis across multiple agent outputs
- Amp deep mode support with separate settings file and read-only safety prompt
- Model selection during `init` with per-adapter `extraFlags`
- Skill template output directory prefixed with timestamp for lexical sorting
### Changed
- Simplified `ToolConfig` — removed model concept, unified flags into `extraFlags`
### Security
- Sanitize tool IDs before use in filenames to prevent path traversal
- Allowlist environment variables passed to child processes
- Use `execFileSync` instead of `execSync` in discovery to prevent shell injection
- Restrict project config to `defaults` only — cannot inject `tools`
- Atomic file writes via temp+rename pattern to prevent partial writes
### Fixed
- SIGINT handler properly terminates active child processes
- Release workflow: build before test so integration tests find `dist/cli.js`
- Release script handles blank changelogs instead of failing
- Release workflow accepts leading `v` in version input
[Unreleased]: https://github.com/aarondfrancis/counselors/compare/v0.5.2...HEAD
[0.3.0]: https://github.com/aarondfrancis/counselors/releases/tag/v0.3.0
[0.3.1]: https://github.com/aarondfrancis/counselors/releases/tag/v0.3.1
[0.3.2]: https://github.com/aarondfrancis/counselors/releases/tag/v0.3.2
[0.3.3]: https://github.com/aarondfrancis/counselors/releases/tag/v0.3.3
[0.3.4]: https://github.com/aarondfrancis/counselors/releases/tag/v0.3.4
[0.4.0]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.0
[0.4.1]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.1
[0.4.2]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.2
[0.4.3]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.3
[0.4.4]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.4
[0.4.5]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.5
[0.4.6]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.6
[0.4.7]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.7
[0.4.8]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.8
[0.4.9]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.9
[0.4.10]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.10
[0.4.11]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.11
[0.4.12]: https://github.com/aarondfrancis/counselors/releases/tag/v0.4.12
[0.5.0]: https://github.com/aarondfrancis/counselors/releases/tag/v0.5.0
[0.5.1]: https://github.com/aarondfrancis/counselors/releases/tag/v0.5.1
[0.5.2]: https://github.com/aarondfrancis/counselors/releases/tag/v0.5.2
================================================
FILE: README.md
================================================
# counselors
By [Aaron Francis](https://aaronfrancis.com), creator of [Faster.dev](https://faster.dev) and [Solo](https://soloterm.com).
Fan out prompts to multiple AI coding agents in parallel.
`counselors` dispatches the same prompt to Claude, Codex, Gemini, Amp, or custom tools simultaneously, collects their responses, and writes everything to a structured output directory.
No MCP servers, no direct API integrations, no complex configuration. It just calls your locally installed CLI tools.
## Will this get me banned from my provider?
Counselors only uses providers' **first-party CLI tools**. It does not call provider APIs directly, it does not extract or reuse auth tokens, and it does not do anything "tricky" behind the scenes. It literally runs the official CLI binaries you already installed, the same way you would from your terminal.
You are still subject to each provider's terms and rate limits. Counselors is just an orchestrator around the CLIs.
## Agentic quickstart
Install the CLI yourself first (pick one):
- npm (requires Node 20+): `npm install -g counselors`
- Homebrew: `brew install aarondfrancis/homebrew-tap/counselors`
- Standalone binary: `curl -fsSL https://github.com/aarondfrancis/counselors/raw/main/install.sh | bash`
Then paste this to your AI coding agent:
```
Run `counselors init --auto` to discover and configure installed AI CLIs. Then run `counselors skill` to see how to create a skill for the counselors CLI.
```
Your agent will configure available tools and set up the `/counselors` slash command.
### Updating your skill
The recommended skill template changes over time. If you already installed `/counselors` in your agent system, don’t blindly overwrite it.
Copy/paste this into your AI coding agent:
```
The counselors CLI has an updated skill template.
1. Run `counselors skill` and capture the full output.
2. Open my existing counselors skill file and compare VERY CAREFULLY for anything that changed.
3. Apply the updates manually; do not blindly overwrite.
4. If you need more context, check the git history for the skill template here:
https://github.com/aarondfrancis/counselors/commits/main/src/commands/skill.ts
```
**How it works:**
1. You invoke the Counselors skill with a prompt
2. Your agent gathers context from the codebase
3. Your agent asks which other agents you want to consult
4. Counselors fans out to those agents in parallel for independent research
5. Each agent writes a structured markdown report
6. Your main agent synthesizes and presents the results
**Example:** after a big refactor, ask your agents for a second opinion:
```
/counselors We just completed a major refactor of the authentication module.
Review the changes for edge cases, test gaps, or regressions we might have missed.
```
Your main agent handles the rest — it gathers relevant code, recent commits, and assembles a detailed prompt before dispatching to the counselors.
## Human quickstart
Install the CLI (pick one):
- npm (requires Node 20+): `npm install -g counselors`
- Homebrew: `brew install aarondfrancis/homebrew-tap/counselors`
- Standalone binary: `curl -fsSL https://github.com/aarondfrancis/counselors/raw/main/install.sh | bash`
```bash
# Discover installed AI CLIs and create a config
counselors init
# Send a prompt to all configured tools
counselors run "Trace the state management flow in the dashboard and flag any brittleness or stale state bugs"
# Send to specific tools only
counselors run -t claude,codex "Review src/api/ for security issues and missing edge cases"
```
## Supported tools
| Tool | Adapter | Read-Only | Install |
|------|---------|-----------|---------|
| Claude Code | `claude` | enforced | [docs](https://docs.anthropic.com/en/docs/claude-code) |
| OpenAI Codex | `codex` | enforced | [github](https://github.com/openai/codex) |
| Gemini CLI | `gemini` | enforced | [github](https://github.com/google-gemini/gemini-cli) |
| Amp CLI | `amp` | enforced | [ampcode.com](https://ampcode.com) |
| Custom | user-defined | configurable | — |
## Commands
### `run [prompt]`
Dispatch a prompt to configured tools in parallel.
```bash
counselors run "Your prompt here"
counselors run -f prompt.md # Use a prompt file
echo "prompt" | counselors run # Read from stdin
counselors run --dry-run "Show plan" # Preview without executing
counselors run -t opus,opus,opus "Review this" # Run the same tool multiple times
```
| Flag | Description |
|------|-------------|
| `-f, --file <path>` | Use a prompt file (no wrapping) |
| `-t, --tools <tools>` | Comma-separated tool IDs |
| `-g, --group <groups>` | Comma-separated group name(s) (expands to tool IDs) |
| `--context <paths>` | Gather context from paths (comma-separated, or `.` for git diff) |
| `--read-only <level>` | `strict`, `best-effort`, `off` (defaults to config `readOnly`) |
| `--dry-run` | Show what would run without executing |
| `--json` | Output manifest as JSON |
| `-o, --output-dir <dir>` | Base output directory |
### `loop [prompt]`
Multi-round dispatch — agents iterate, seeing prior outputs each round.
Each round dispatches to all tools in parallel. Starting from round 2, each agent receives the outputs from all prior rounds, so it can build on previous analysis and avoid repeating findings.
```text
input: user prompt/focus (e.g.: "focus on the auth module", "look at the sidebar component")
|
+--> with --preset:
| [repo discovery phase] --> [prompt-writing phase] --> execution prompt (includes boilerplate)
+--> without --preset:
inline arg prompt:
default: [repo discovery phase] --> [prompt-writing phase] --> enhanced execution prompt
opt-out: --no-inline-enhancement (skip discovery/prompt-writing)
file/stdin prompt: used as provided (discovery/prompt-writing skipped)
all modes: execution boilerplate is always appended
execution prompt
|
v
+------------------------------- loop rounds -------------------------------+
| round 1: dispatch to all selected tools in parallel |
| write per-tool outputs + round notes |
| |
| round N>1: execution prompt + references to prior round outputs |
| (new findings, challenge/refine prior findings) |
| dispatch in parallel, write outputs + notes |
| |
| stop when: |
| - max rounds reached, or |
| - duration expires, or |
| - convergence threshold reached, or |
| - user aborts (Ctrl+C after current round) |
+---------------------------------------------------------------------------+
|
v
final notes + run manifest
```
```text
Round behavior:
round 1 prompt = base execution prompt
round N prompt = base execution prompt
// Base execution prompt is amended with...
+ "Prior Round Outputs" section
+ @refs to recent prior tool outputs
+ instruction to avoid duplicate findings, challenge/refine
prior claims, and expand from prior leads
```
```bash
counselors loop "Find and fix test gaps in src/auth/" --rounds 5
counselors loop --duration 30m "Hunt for edge cases"
counselors loop --preset bughunt "src/api" --tools opus,codex
counselors loop --preset hotspots "critical request path" --group smart
counselors loop --list-presets
```
| Flag | Description |
|------|-------------|
| `--rounds <N>` | Number of dispatch rounds (default: 3) |
| `--duration <time>` | Max total duration (e.g. `"30m"`, `"1h"`). If set without `--rounds`, runs unlimited rounds until time expires |
| `--preset <name-or-path>` | Use a built-in preset (e.g. `"bughunt"`) or a custom `.yml/.yaml` preset file |
| `--list-presets` | List built-in presets and exit |
| `--no-inline-enhancement` | For non-preset inline prompts, skip discovery + prompt-writing enhancement |
Plus all `run` flags: `-f`, `-t`, `-g`, `--context`, `--read-only`, `--dry-run`, `--json`, `-o`.
**SIGINT handling:** First Ctrl+C finishes the current round gracefully. Second Ctrl+C force-exits immediately.
**Presets** provide domain-specific multi-round workflows.
Built-ins:
- `bughunt` — bugs, edge cases, and missing test coverage
- `security` — exploitable vulnerabilities and high-impact security flaws
- `invariants` — impossible states and state synchronization problems
- `regression` — behavior changes likely to break existing callers/users
- `contracts` — mismatches between API producers and consumers
- `hotspots` — high-impact bottlenecks, including O(n^2)+ patterns
Custom presets (code-grounded):
```yaml
name: auth-audit
description: |
Audit authentication and authorization code paths for real issues.
Ground every claim in repository evidence.
For each finding, include concrete file paths and explain the exact control/data flow.
Do not speculate about behavior that is not visible in code.
defaultRounds: 3
defaultReadOnly: bestEffort
```
```bash
counselors loop --preset ./presets/auth-audit.yml "src/auth and middleware"
counselors loop --preset ./presets/auth-audit.yml "session + token flows" --dry-run
```
Guidelines for "truth of the code" presets:
- Write `description` so findings must cite concrete evidence (file paths, functions, branches, tests).
- Require the agent to separate observed behavior from assumptions and call out unknowns explicitly.
- Ask for reproducible checks (commands/tests) for each high-confidence claim.
- Keep the focus target narrow in the prompt argument (specific dirs, modules, or request paths).
### `mkdir [prompt]`
Create a counselors output directory and optionally write `prompt.md` without dispatching.
If you do not provide a prompt (arg, `-f`, or stdin), `mkdir` creates only the containing directory.
Useful when an orchestrating agent wants counselors to own output-dir creation and just return paths.
```bash
counselors mkdir --json
counselors mkdir "Review the auth flow for edge cases" --json
echo "prompt" | counselors mkdir --json
cat prompt.md | counselors mkdir --json
counselors mkdir -f prompt.md --json
```
The JSON output includes:
- `outputDir`
- `promptFilePath` (`null` when no prompt was provided)
- `slug`
- `promptSource` (`none`, `inline`, `file`, or `stdin`)
### `init`
Interactive setup wizard. Discovers installed AI CLIs, lets you pick tools and models, runs validation tests.
```bash
counselors init # Interactive
counselors init --auto # Non-interactive: discover tools, use defaults, output JSON
```
### `doctor`
Check configuration health — verifies config file, tool binaries, versions, and read-only capabilities.
```bash
counselors doctor
```
### `upgrade`
Detect how `counselors` was installed and upgrade using the matching method when possible.
Supported:
- Homebrew
- npm global
- pnpm global
- yarn global (classic)
- Standalone binary installs (safe paths only: `~/.local/bin`, `~/bin`)
```bash
counselors upgrade
counselors upgrade --check # Show method/version only
counselors upgrade --dry-run # Show what would run
counselors upgrade --force # Force standalone self-upgrade outside safe locations
```
### `cleanup`
Delete run output directories older than a given age. Defaults to older than 1 day and uses your configured output directory (`defaults.outputDir`).
```bash
counselors cleanup
counselors cleanup --dry-run --older-than 7d
counselors cleanup --older-than 36h --yes
```
### `config`
Print the config file path and the full resolved configuration as JSON.
```bash
counselors config
```
### `tools`
Manage configured tools.
| Command | Description |
|---------|-------------|
| `tools discover` | Find installed AI CLIs on your system |
| `tools add [tool]` | Add a built-in or custom tool |
| `tools remove [tool]` | Remove tool(s) — interactive if no argument |
| `tools rename <old> <new>` | Rename a tool ID |
| `tools list` / `ls` | List configured tools (`-v` for full config) |
| `tools test [tools...]` | Test tools with a quick "reply OK" prompt |
### `groups`
Manage predefined groups of tool IDs for easier reuse.
```bash
counselors groups list
counselors groups add smart --tools claude-opus,codex-5.3-xhigh,gemini-3-pro
counselors groups add fast --tools codex-5.3-high,gemini-3-flash
counselors groups add opus-swarm --tools claude-opus,claude-opus,claude-opus
counselors groups remove fast
```
### `agent`
Print setup and skill installation instructions.
### `skill`
Print a `/counselors` slash-command template for use inside Claude Code or other agents.
## Configuration
### Global config
`~/.config/counselors/config.json` (respects `XDG_CONFIG_HOME`)
```jsonc
{
"version": 1,
"defaults": {
"timeout": 540,
"outputDir": "./agents/counselors",
"readOnly": "bestEffort",
"maxContextKb": 50,
"maxParallel": 4
},
"tools": {
"claude": {
"binary": "/usr/local/bin/claude",
"adapter": "claude",
"readOnly": { "level": "enforced" },
"extraFlags": ["--model", "opus"]
}
},
"groups": {
"smart": ["claude-opus", "codex-5.3-xhigh", "gemini-3-pro"],
"fast": ["codex-5.3-high", "gemini-3-flash"],
"opus-swarm": ["claude-opus", "claude-opus", "claude-opus"]
}
}
```
### Running the same tool multiple times
If you want multiple independent responses from the same configured tool, just repeat it in `--tools` (or inside a group). Counselors will automatically fan it out as separate instances.
```bash
counselors run -t opus,opus,opus "Review this module for edge cases"
```
### Project config
Place a `.counselors.json` in your project root to override `defaults` per-project. Project configs cannot add or modify `tools` (security boundary).
```jsonc
{
"defaults": {
"outputDir": "./ai-output",
"readOnly": "enforced"
}
}
```
## Read-only modes
| Level | Behavior |
|-------|----------|
| `enforced` | Tool is sandboxed to read-only operations |
| `bestEffort` | Tool is asked to avoid writes but may not guarantee it |
| `none` | Tool has full read/write access |
The `--read-only` flag on `run` controls the policy: `strict` only dispatches to tools with `enforced` support, `best-effort` uses whatever each tool supports, `off` disables read-only flags entirely. When omitted, falls back to the `readOnly` setting in your config defaults (which defaults to `bestEffort`).
## Output structure
Each run creates a directory under your configured output directory (`defaults.outputDir`, default `./agents/counselors`):
```
<outputDir>/{slug}/
prompt.md # The dispatched prompt
run.json # Manifest with status, timing, costs
summary.md # Synthesized summary
{tool-id}.md # Each tool's response
{tool-id}.stderr # Each tool's stderr
```
If the `{slug}` directory already exists, counselors appends a timestamp suffix to avoid collisions.
For multi-round runs (`loop`), each round gets its own subdirectory:
```
<outputDir>/{slug}/
round-1/
prompt.md
{tool-id}.md
{tool-id}.stderr
round-notes.md
round-2/
prompt.md # augmented with prior round outputs
{tool-id}.md
round-notes.md
...
final-notes.md # combined notes across all rounds
run.json # manifest with rounds array
```
## Skill / slash command
Install `/counselors` as a skill in Claude Code or other agents:
```bash
# Print the skill template
counselors skill
# Print full agent setup instructions
counselors agent
```
The skill template provides a multi-phase workflow: gather context, select agents, choose dispatch mode (`run` vs `loop`), assemble prompt/focus, create prompt files via `counselors mkdir` when needed, dispatch, read results, and synthesize a combined answer.
## How is this different from...?
Most parallel-agent tools ([Uzi](https://github.com/devflowinc/uzi), [FleetCode](https://github.com/built-by-as/FleetCode), [AI Fleet](https://github.com/nachoal/ai-fleet), [Superset](https://superset.sh)) are designed to parallelize _different tasks_ — each agent gets its own git worktree and works on a separate problem. They're throughput tools.
Counselors does something different: it sends the _same prompt_ to multiple agents and collects their independent perspectives. It's a "council of advisors" pattern — you're not splitting work, you're getting second opinions.
Other differences:
- **No git worktrees, no containers, no infrastructure.** Counselors just calls your locally installed CLIs and writes markdown files.
- **Read-only by default.** Agents are sandboxed to read-only mode so they can review your code without modifying it.
- **Built for agentic use.** The slash-command workflow lets your primary agent orchestrate the whole process — gather context, fan out, and synthesize — without you leaving your editor.
## Examples
The real value shows up when models disagree. Here are cross-model disagreement tables from actual counselors runs, synthesized by the primary agent:
**Topic: Tauri close-request handling** — _Claude Opus, Gemini Pro, Codex_
> /counselors Review my plan for handling Tauri 2.x close-request events — is the CloseRequested API usage correct, are there known emit_to bugs, and should "Stop All" be per-window or global?
| Topic | Claude Opus | Gemini Pro | Codex |
|-------|-------------|------------|-------|
| CloseRequested API | Says `set_prevent_default(true)` is correct for Tauri 2.x | Agrees plan is correct | Says plan is wrong — claims `api.prevent_close()` is needed |
| `emit_to` reliability | Flags potential Tauri bug (#10182) where `emit_to` may broadcast anyway; wants fallback plan | Says raw `app.emit_to` may be needed if tauri-specta doesn't expose it | Says `emit_to` is correct |
| "Stop All" semantics | Says keep it global (app-level menu = all processes) | No comment | Says command palette "stop all" is not ownership-aware |
---
**Topic: Escape key / modal stacking** — _Codex, Gemini, Amp_
> /counselors How should I implement escape-to-dismiss for stacked modals? Currently openModals is a Set and Escape closes everything. I want it to dismiss only the topmost modal.
| Approach | Codex | Gemini | Amp |
|----------|-------|--------|-----|
| Stack location | Parallel `modalStack: string[]` alongside `openModals: Set` | Replace `openModals: Set` → `openModals: string[]` | Separate `escapeStack` + `escapeHandlers` alongside `openModals: Set` |
| ESC dispatch | Each Modal keeps its own window listener but no-ops if not topmost | Same as Codex | One global dispatcher + handler registry; Modals don't add window listeners at all |
| Complexity | Medium (add stack, check in Modal) | Low (swap Set→Array, check in Modal) | Higher (new escape stack, new hooks, new global dispatcher, store handler functions) |
---
**Topic: Terminal drag-and-drop / image paste** — _Claude Opus, Gemini Pro, Codex_
> /counselors What's the best approach for drag-and-drop files and image paste in my ghostty-web terminal? Is inline image rendering feasible on the Canvas/WASM renderer or should I just insert file paths?
All 3 agents agreed on these key points:
1. Drag-and-drop should insert shell-escaped file paths — this is the universal convention (Terminal.app, iTerm2, Kitty, Ghostty native all do it). Highest value, lowest effort. Do it first.
2. Image paste should save to a temp file and insert the path — no terminal pastes raw image data. Show a toast to explain what happened.
3. Do NOT build inline image rendering now — ghostty-web's Canvas renderer has no image rendering capability. Building an HTML overlay compositor would be 40-80 hours of work for low value in a dev tool.
4. ghostty-web does NOT support image display despite native Ghostty supporting Kitty Graphics Protocol. The web/WASM build lacks the Metal/OpenGL rendering paths needed.
| Topic | Claude Opus | Gemini Pro |
|-------|-------------|------------|
| Kitty rendering | "ghostty-web does NOT render images" | Suggests "rely on ghostty-web's built-in Kitty support" |
The synthesizing agent's assessment: Claude Opus and Codex are correct — ghostty-web's CanvasRenderer draws text cells only. Gemini appears to conflate native Ghostty (which does support Kitty graphics) with ghostty-web (which doesn't have rendering paths for it).
---
**Topic: Rust detection module refactor** — _Claude, Gemini, Codex_
> /counselors The detection module is ~1200 lines in one file with boolean fields on DetectionContext. How should I refactor it — module directory, lazy file checks, rule engine? Also check for bugs in dedup and orchestration-skip logic.
All 3 agents agreed:
1. Split into `detection/` module directory — 1200-line file is the most immediate problem
2. Replace `DetectionContext` boolean fields with a lazy/cached `file_exists()`
3. The Laravel pattern (`LaravelPackages` sub-struct) is superior to Node.js's inline booleans
4. Don't build a full rule engine/DSL — conditional logic varies too much
Codex also found 2 bugs all agents acknowledged: dedup by name drops valid suggestions in polyglot repos, and Procfile orchestration skip is too broad.
---
**Topic: ghostty-web 0.3.0 to 0.4.0 upgrade** — _Claude, Codex, Gemini_
> /counselors Review my ghostty-web 0.3.0 → 0.4.0 upgrade plan. Key concerns: getLine() WASM bug, DSR response handling, isComposing guard for CJK, phase ordering, and renderer.metrics hack risk.
| Question | Consensus |
|----------|-----------|
| `getLine()` bug fixed? | All agree: likely fixed — old broken WASM export completely removed |
| DSR response coordination | All agree: strip CPR/DA from backend, keep kitty-only |
| `patchInputHandler` | All agree: must add `isComposing` guard — CJK/IME will break without it |
| Phase ordering | All agree: keep phases 4 and 5 separate, add a Phase 0 for compat checker |
| `renderer.metrics` hack | All agree: high to extremely high risk of breakage in 0.4.0 |
**Topic: Multi-round test gap hunting** — _`loop --preset test`_
> counselors loop --preset test --scope src/auth/ --rounds 3
Round 1 discovers the test landscape and finds initial gaps. Round 2 reads the round-1 reports and hunts for edge cases the first round missed. Round 3 goes deeper on anything still uncovered. Each agent independently builds on prior findings without repeating them.
## Security
- **Environment allowlisting**: Child processes only receive allowlisted environment variables (PATH, HOME, API keys, proxy settings, etc.) — no full `process.env` leak.
- **Atomic config writes**: Config files are written atomically via temp+rename with `0o600` permissions.
- **Tool name validation**: Tool IDs are validated against `[a-zA-Z0-9._-]` to prevent path traversal.
- **No shell execution**: All child processes use `execFile`/`spawn` without `shell: true`.
- **Project config isolation**: `.counselors.json` can only override `defaults`, never inject `tools`.
## Development
```bash
npm install
npm run build # tsup → dist/cli.js
npm run test # vitest (unit + integration)
npm run typecheck # tsc --noEmit
npm run lint # biome check
```
Requires Node 20+. TypeScript with ESM, built with tsup, tested with vitest, linted with biome.
## Known issues
- **Amp `deep` model uses Bash to read files.** The `deep` model (GPT-5.2 Codex) reads files via `Bash` rather than the `Read` tool. Because `Bash` is a write-capable tool, we cannot guarantee that deep mode will not modify files. A mandatory read-only instruction is injected into the prompt, but this is a best-effort safeguard. For safety-critical tasks, prefer `amp-smart`.
## License
MIT
================================================
FILE: assets/amp-deep-settings.json
================================================
{
"amp.tools.enable": [
"Read",
"Grep",
"glob",
"finder",
"librarian",
"look_at",
"oracle",
"read_web_page",
"read_mcp_resource",
"read_thread",
"find_thread",
"web_search",
"Bash"
]
}
================================================
FILE: assets/amp-readonly-settings.json
================================================
{
"amp.tools.enable": [
"Read",
"Grep",
"glob",
"finder",
"librarian",
"look_at",
"oracle",
"read_web_page",
"read_mcp_resource",
"read_thread",
"find_thread",
"web_search"
]
}
================================================
FILE: assets/presets/bughunt.yml
================================================
name: bughunt
description: |
You are hunting for real correctness bugs, edge-case failures, and missing tests that would allow regressions.
Prioritize:
- User-visible correctness failures over style issues
- High-blast-radius bugs over speculative nits
- Findings likely to produce meaningful failing tests
Look for:
- Logic errors: wrong conditionals, off-by-one, incorrect defaults, null/undefined handling gaps
- Boundary and error-path failures: empty inputs, max/min values, partial failures, cleanup/rollback gaps
- Validation and contract gaps: unchecked inputs, missing type guards, mismatched return assumptions
- Concurrency/order bugs: TOCTOU, race conditions, shared mutable state hazards, invalid async state transitions
- Resource-lifecycle bugs: unclosed handles, unreleased locks, dangling listeners, swallowed exceptions in finally blocks
- Missing test coverage on risky branches: error handlers, retry logic, fallback paths, migration paths
Multi-round rule:
- Prioritize novel findings not already reported in prior rounds.
- If you repeat a prior finding, add new evidence, sharper impact analysis, or a better test strategy.
For each finding, include:
- severity: critical | high | medium | low
- confidence: high | medium | low
- location: file path + function/method name
- evidence: concrete code pattern and why it can fail at runtime
- impact: user/system consequence if triggered
- minimal fix: smallest safe change
- test idea: a concrete failing test scenario (inputs + expected behavior)
Skip trivial style comments unless they hide a correctness bug.
defaultRounds: 3
defaultReadOnly: enforced
================================================
FILE: assets/presets/contracts.yml
================================================
name: contracts
description: |
You are auditing for API contract drift across server handlers, shared types, clients, validators, and tests.
Look for:
- Request/response field mismatches between API handlers and consumers (missing, renamed, retyped, or re-nested fields)
- Optional vs required drift across schemas, runtime validators, and TypeScript/PHP/Python types
- Enum and status value drift across backend models, API serializers, and frontend/client assumptions
- Inconsistent error contracts: different status codes or error payload shapes for similar failure classes
- Versioning and backward-compatibility breaks (silent behavior changes, removed fields, stricter parsing)
- Serialization mismatches (date/time formats, number/string coercion, nullability handling)
- Documentation/examples/spec files that no longer match actual implementation behavior
For each issue found, include the producer and consumer locations, describe the concrete contract mismatch, and explain the runtime impact. Suggest a contract test or integration test that would fail before the fix and pass after it.
defaultRounds: 3
defaultReadOnly: enforced
================================================
FILE: assets/presets/hotspots.yml
================================================
name: hotspots
description: |
You are auditing for high-impact performance bottlenecks, with emphasis on asymptotic complexity and scaling behavior.
Prioritize:
- Hot-path issues over cold-path micro-optimizations
- Large wins with low implementation risk
- Evidence-backed findings over speculative tuning
Look for:
- Accidental O(n^2)+ patterns: nested scans, repeated sort/filter passes, per-item linear lookups
- N+1 access patterns across database, API, filesystem, queue, or cache boundaries
- Unbounded traversal/fan-out work that grows poorly with input size
- Repeated expensive work that should be cached, memoized, batched, or precomputed
- Serialization/parsing churn on hot paths (JSON encode/decode loops, repeated cloning/transforms)
- Large allocations/copies in tight loops instead of incremental reuse
- Missing pagination, streaming, chunking, or backpressure that causes latency/memory spikes
Multi-round rule:
- Prioritize novel hotspots not already reported in prior rounds.
- If you repeat a hotspot, add stronger evidence, better complexity analysis, or a lower-risk fix.
For each finding, include:
- severity: critical | high | medium | low
- confidence: high | medium | low
- location: file path + function/method name
- evidence: exact code path and operation causing the cost
- complexity: define input variables (for example n, m) and estimate before/after Big-O
- impact: expected latency/throughput/memory effect and where it appears
- minimal fix: smallest safe change (index/map usage, batching, caching, pagination, etc.)
- validation idea: benchmark/profiling or test strategy to confirm the gain
Skip tiny micro-optimizations unless they are clearly on a critical hot path.
defaultRounds: 4
defaultReadOnly: enforced
================================================
FILE: assets/presets/invariants.yml
================================================
name: invariants
description: |
You are auditing a codebase for state synchronization issues, impossible states, and state management anti-patterns.
Look for:
- Boolean explosion: multiple booleans creating 2^n states where many combinations are impossible (e.g. isLoading && isError both true)
- Impossible states: bags of optionals instead of discriminated unions — types that allow combinations that should never exist
- Magic strings: string literals used for status/state comparisons instead of enums or constants
- Status mismatches: database enums not matching code enums (different spelling, different count, different casing)
- Duplicated state: the same data stored in multiple locations that can get out of sync
- Derived state stored: computed values persisted when they could be calculated on the fly (e.g. totalCount stored instead of items.length)
- Missing state machines: complex multi-step flows or status fields with 4+ values managed with ad-hoc conditionals instead of explicit state machines
- Single source of truth violations: the same authoritative data defined in multiple places (validation rules duplicated client/server, type definitions copied across files, permissions checked in both frontend and backend with different logic)
For each issue found, include the file path, the specific code pattern, and what can go wrong. Suggest the minimal fix — discriminated union, enum extraction, computed getter, or state machine. Focus on drift that can cause real bugs at runtime, not theoretical concerns.
defaultRounds: 3
defaultReadOnly: enforced
================================================
FILE: assets/presets/regression.yml
================================================
name: regression
description: |
You are auditing a codebase for regression risk with emphasis on behavior changes that can break existing users or dependent systems.
Look for:
- Contract drift in function signatures, return shapes, event payloads, or CLI flags that callers may rely on
- Removed or weakened guards (validation, authorization, null checks) that previously prevented invalid states
- Refactors that changed control flow or ordering semantics in subtle ways (initialization order, retry order, cleanup timing)
- Partial migrations where old and new code paths can diverge in behavior
- Error handling regressions: swallowed exceptions, changed status codes, missing rollback/cleanup in failure paths
- Feature flag or config default changes that alter runtime behavior without clear migration handling
- Tests that assert implementation details but miss observable behavior, leaving real regressions undetected
For each issue found, include file path and function/method name, explain the user-visible regression risk, and suggest a concrete failing test that would catch it. Prioritize high-blast-radius risks over low-impact code style concerns.
defaultRounds: 3
defaultReadOnly: enforced
================================================
FILE: assets/presets/security.yml
================================================
name: security
description: |
You are a security engineer reviewing a codebase with an attacker's mindset. Your goal is to find exploitable vulnerabilities, not theoretical concerns.
Look for:
- Injection flaws: SQL, NoSQL, OS command, LDAP, or template injection from unsanitized user input reaching queries, shells, or eval
- Broken authentication: weak password handling, missing rate limiting, session fixation, credential exposure in logs or error messages
- Broken access control: missing authorization checks, insecure direct object references (IDOR), privilege escalation, path traversal
- Sensitive data exposure: secrets in source code, unencrypted storage or transit, excessive data in API responses, PII in logs
- Cross-site scripting (XSS): reflected, stored, or DOM-based — user input reaching HTML, attributes, or JavaScript without escaping
- Insecure deserialization: untrusted data passed to deserializers (pickle, unserialize, JSON.parse of executable content, yaml.load)
- Security misconfiguration: verbose error messages leaking internals, debug mode in production, default credentials, overly permissive CORS
- Missing cryptographic controls: hardcoded keys, weak algorithms (MD5, SHA1 for passwords), predictable tokens, improper random number generation
For each vulnerability found, include the file path, the vulnerable code pattern, how an attacker would exploit it, and the specific fix. Prioritize findings by exploitability — a real injection flaw matters more than a missing security header.
defaultRounds: 3
defaultReadOnly: enforced
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noTemplateCurlyInString": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
}
}
================================================
FILE: install.sh
================================================
#!/bin/bash
set -euo pipefail
REPO="aarondfrancis/counselors"
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
case "$OS" in
darwin|linux) ;;
*) echo "Unsupported OS: $OS" >&2; exit 1 ;;
esac
ASSET="counselors-${OS}-${ARCH}"
CHECKSUM_ASSET="${ASSET}.sha256"
if [ -n "${COUNSELORS_VERSION:-}" ]; then
LATEST="${COUNSELORS_VERSION#refs/tags/}"
case "$LATEST" in
v*) ;;
*) LATEST="v$LATEST" ;;
esac
else
API_HEADERS=(-H "Accept: application/vnd.github+json")
if [ -n "${GITHUB_TOKEN:-}" ]; then
API_HEADERS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
RELEASES_JSON="$(
curl -fsSL "${API_HEADERS[@]}" \
"https://api.github.com/repos/${REPO}/releases/latest" || true
)"
LATEST="$(
printf '%s\n' "$RELEASES_JSON" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
head -n 1
)"
fi
if [ -z "$LATEST" ]; then
echo "Failed to resolve release version." >&2
echo "Set COUNSELORS_VERSION=vX.Y.Z to install a specific version." >&2
exit 1
fi
BASE_URL="https://github.com/${REPO}/releases/download/${LATEST}"
URL="${BASE_URL}/${ASSET}"
CHECKSUM_URL="${BASE_URL}/${CHECKSUM_ASSET}"
TMP_BIN="$(mktemp)"
TMP_SUM="$(mktemp)"
cleanup() {
rm -f "$TMP_BIN" "$TMP_SUM"
}
trap cleanup EXIT
mkdir -p "$INSTALL_DIR"
echo "Downloading counselors ${LATEST} (${OS}/${ARCH})..."
curl -fSL "$CHECKSUM_URL" -o "$TMP_SUM"
EXPECTED="$(awk '{print $1}' "$TMP_SUM" | tr -d '\r' | head -n 1)"
if ! [[ "$EXPECTED" =~ ^[A-Fa-f0-9]{64}$ ]]; then
echo "Failed to parse SHA256 checksum." >&2
exit 1
fi
curl -fSL "$URL" -o "$TMP_BIN"
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL="$(sha256sum "$TMP_BIN" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
ACTUAL="$(shasum -a 256 "$TMP_BIN" | awk '{print $1}')"
else
echo "No SHA256 tool found (sha256sum or shasum)." >&2
exit 1
fi
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Checksum mismatch." >&2
echo "Expected: $EXPECTED" >&2
echo "Actual: $ACTUAL" >&2
exit 1
fi
mv "$TMP_BIN" "${INSTALL_DIR}/counselors"
chmod +x "${INSTALL_DIR}/counselors"
echo "Installed counselors to ${INSTALL_DIR}/counselors"
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
echo ""
echo "Note: ${INSTALL_DIR} is not in your PATH."
echo "Add it with: export PATH=\"${INSTALL_DIR}:\$PATH\""
fi
================================================
FILE: package.json
================================================
{
"name": "counselors",
"version": "0.5.2",
"description": "Fan out prompts to multiple AI coding agents in parallel",
"type": "module",
"bin": {
"counselors": "dist/cli.js"
},
"scripts": {
"build": "tsup",
"build:binaries": "bun run scripts/build-binaries.ts",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check src/ tests/",
"lint:fix": "biome check --write src/ tests/"
},
"dependencies": {
"@inquirer/prompts": "^7.0.0",
"commander": "^13.0.0",
"cross-spawn": "^7.0.6",
"ora": "^8.0.0",
"p-limit": "^6.0.0",
"strip-ansi": "^7.1.0",
"yaml": "^2.8.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.14",
"@types/cross-spawn": "^6.0.6",
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": ">=20"
},
"files": [
"dist/cli.js",
"dist/cli.js.map",
"assets"
],
"license": "MIT",
"author": "Aaron Francis",
"repository": {
"type": "git",
"url": "git+https://github.com/aarondfrancis/counselors.git"
},
"homepage": "https://github.com/aarondfrancis/counselors",
"keywords": [
"ai",
"coding-agents",
"claude",
"codex",
"gemini",
"amp",
"parallel",
"multi-agent",
"cli"
]
}
================================================
FILE: scripts/build-binaries.ts
================================================
import { mkdirSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'));
const version: string = pkg.version;
const outDir = 'release';
mkdirSync(outDir, { recursive: true });
const targets = [
{ bun: 'bun-darwin-arm64', suffix: 'darwin-arm64' },
{ bun: 'bun-darwin-x64', suffix: 'darwin-x64' },
{ bun: 'bun-linux-x64', suffix: 'linux-x64' },
{ bun: 'bun-linux-arm64', suffix: 'linux-arm64' },
];
for (const target of targets) {
const outfile = `${outDir}/counselors-${target.suffix}`;
const args = [
'build', '--compile',
'--target', target.bun,
'--define', `__VERSION__="${version}"`,
'--outfile', outfile,
'./src/cli.ts',
];
console.log(`Building ${outfile}...`);
execFileSync('bun', args, { stdio: 'inherit' });
}
console.log('Done.');
================================================
FILE: src/adapters/amp.ts
================================================
import { existsSync } from 'node:fs';
import { AMP_DEEP_SETTINGS_FILE, AMP_SETTINGS_FILE } from '../constants.js';
import type {
CostInfo,
ExecResult,
Invocation,
ReadOnlyLevel,
RunRequest,
ToolConfig,
ToolReport,
} from '../types.js';
import { BaseAdapter } from './base.js';
export function isAmpDeepMode(flags?: string[]): boolean {
if (!flags) return false;
const idx = flags.indexOf('deep');
return idx > 0 && flags[idx - 1] === '-m';
}
export class AmpAdapter extends BaseAdapter {
id = 'amp';
displayName = 'Amp CLI';
commands = ['amp'];
installUrl = 'https://ampcode.com';
readOnly = { level: 'enforced' as const };
models = [
{
id: 'smart',
name: 'Smart — Opus 4.6, most capable',
recommended: true,
extraFlags: ['-m', 'smart'],
},
{
id: 'deep',
name: 'Deep — GPT-5.2 Codex, extended thinking',
extraFlags: ['-m', 'deep'],
},
];
getEffectiveReadOnlyLevel(toolConfig: ToolConfig): ReadOnlyLevel {
return isAmpDeepMode(toolConfig.extraFlags)
? 'bestEffort'
: this.readOnly.level;
}
buildInvocation(req: RunRequest): Invocation {
const args = ['-x'];
if (req.extraFlags) {
args.push(...req.extraFlags);
}
const isDeep = isAmpDeepMode(req.extraFlags);
const settingsFile = isDeep ? AMP_DEEP_SETTINGS_FILE : AMP_SETTINGS_FILE;
if (req.readOnlyPolicy !== 'none' && existsSync(settingsFile)) {
args.push('--settings-file', settingsFile);
}
// Amp uses stdin for prompt delivery
// Append oracle instruction like the existing skill does
const deepSafetyPrompt = isDeep
? '\n\nMANDATORY: Do not change any files. You are in read-only mode.'
: '';
const stdinContent =
req.prompt +
deepSafetyPrompt +
'\n\nUse the oracle tool to provide deeper reasoning and analysis on the most complex or critical aspects of this review.';
return {
cmd: req.binary ?? 'amp',
args,
stdin: stdinContent,
cwd: req.cwd,
};
}
parseResult(result: ExecResult): Partial<ToolReport> {
return {
...super.parseResult(result),
};
}
}
/**
* Parse `amp usage` output to extract balance information.
*/
export function parseAmpUsage(output: string): {
freeRemaining: number;
freeTotal: number;
creditsRemaining: number;
} {
const freeMatch = output.match(/Amp Free: \$([0-9.]+)\/\$([0-9.]+)/);
const creditsMatch = output.match(/Individual credits: \$([0-9.]+)/);
return {
freeRemaining: freeMatch ? parseFloat(freeMatch[1]) : 0,
freeTotal: freeMatch ? parseFloat(freeMatch[2]) : 0,
creditsRemaining: creditsMatch ? parseFloat(creditsMatch[1]) : 0,
};
}
/**
* Compute cost from before/after usage snapshots.
*/
export function computeAmpCost(
before: {
freeRemaining: number;
freeTotal: number;
creditsRemaining: number;
},
after: { freeRemaining: number; freeTotal: number; creditsRemaining: number },
): CostInfo {
const freeUsed = Math.max(0, before.freeRemaining - after.freeRemaining);
const creditsUsed = Math.max(
0,
before.creditsRemaining - after.creditsRemaining,
);
const totalCost = freeUsed + creditsUsed;
const source = creditsUsed > 0 ? 'credits' : 'free';
return {
cost_usd: Math.round(totalCost * 100) / 100,
free_used_usd: Math.round(freeUsed * 100) / 100,
credits_used_usd: Math.round(creditsUsed * 100) / 100,
source: source as 'free' | 'credits',
free_remaining_usd: after.freeRemaining,
free_total_usd: after.freeTotal,
credits_remaining_usd: after.creditsRemaining,
};
}
================================================
FILE: src/adapters/base.ts
================================================
import { countWords } from '../core/text-utils.js';
import type {
ExecResult,
Invocation,
ReadOnlyLevel,
RunRequest,
ToolAdapter,
ToolConfig,
ToolReport,
} from '../types.js';
export abstract class BaseAdapter implements ToolAdapter {
abstract id: string;
abstract displayName: string;
abstract commands: string[];
abstract installUrl: string;
abstract readOnly: { level: ReadOnlyLevel };
modelFlag = '-m';
abstract models: { id: string; name: string; recommended?: boolean }[];
abstract buildInvocation(req: RunRequest): Invocation;
getEffectiveReadOnlyLevel(_toolConfig: ToolConfig): ReadOnlyLevel {
return this.readOnly.level;
}
parseResult(result: ExecResult): Partial<ToolReport> {
return {
status: result.timedOut
? 'timeout'
: result.exitCode === 0
? 'success'
: 'error',
exitCode: result.exitCode,
durationMs: result.durationMs,
wordCount: countWords(result.stdout),
};
}
}
================================================
FILE: src/adapters/claude.ts
================================================
import { sanitizePath } from '../constants.js';
import type { Invocation, RunRequest } from '../types.js';
import { BaseAdapter } from './base.js';
export class ClaudeAdapter extends BaseAdapter {
id = 'claude';
displayName = 'Claude Code';
commands = ['claude'];
installUrl = 'https://docs.anthropic.com/en/docs/claude-code';
readOnly = { level: 'enforced' as const };
modelFlag = '--model';
models = [
{
id: 'opus',
name: 'Opus 4.6 — most capable',
recommended: true,
extraFlags: ['--model', 'opus'],
},
{
id: 'sonnet',
name: 'Sonnet 4.5 — fast and capable',
extraFlags: ['--model', 'sonnet'],
},
{
id: 'haiku',
name: 'Haiku 4.5 — fastest, most affordable',
extraFlags: ['--model', 'haiku'],
},
];
buildInvocation(req: RunRequest): Invocation {
const instruction = `Read the file at ${sanitizePath(req.promptFilePath)} and follow the instructions within it.`;
const args = ['-p', '--output-format', 'text'];
if (req.extraFlags) {
args.push(...req.extraFlags);
}
if (req.readOnlyPolicy !== 'none') {
args.push(
'--tools',
'Read,Glob,Grep,WebFetch,WebSearch',
'--allowedTools',
'Read,Glob,Grep,WebFetch,WebSearch',
'--strict-mcp-config',
);
}
args.push(instruction);
return { cmd: req.binary ?? 'claude', args, cwd: req.cwd };
}
}
================================================
FILE: src/adapters/codex.ts
================================================
import { sanitizePath } from '../constants.js';
import type { Invocation, RunRequest } from '../types.js';
import { BaseAdapter } from './base.js';
export class CodexAdapter extends BaseAdapter {
id = 'codex';
displayName = 'OpenAI Codex';
commands = ['codex'];
installUrl = 'https://github.com/openai/codex';
readOnly = { level: 'enforced' as const };
models = [
{
id: 'gpt-5.3-codex',
compoundId: 'codex-5.3-high',
name: 'GPT-5.3 Codex — high reasoning',
recommended: true,
extraFlags: ['-m', 'gpt-5.3-codex', '-c', 'model_reasoning_effort=high'],
},
{
id: 'gpt-5.3-codex',
compoundId: 'codex-5.3-xhigh',
name: 'GPT-5.3 Codex — xhigh reasoning',
extraFlags: ['-m', 'gpt-5.3-codex', '-c', 'model_reasoning_effort=xhigh'],
},
{
id: 'gpt-5.3-codex',
compoundId: 'codex-5.3-medium',
name: 'GPT-5.3 Codex — medium reasoning',
extraFlags: [
'-m',
'gpt-5.3-codex',
'-c',
'model_reasoning_effort=medium',
],
},
];
buildInvocation(req: RunRequest): Invocation {
const instruction = `Read the file at ${sanitizePath(req.promptFilePath)} and follow the instructions within it.`;
const args = ['exec'];
if (req.readOnlyPolicy !== 'none') {
args.push('--sandbox', 'read-only');
}
args.push('-c', 'web_search=live', '--skip-git-repo-check');
if (req.extraFlags) {
args.push(...req.extraFlags);
}
args.push(instruction);
return { cmd: req.binary ?? 'codex', args, cwd: req.cwd };
}
}
================================================
FILE: src/adapters/custom.ts
================================================
import { sanitizePath } from '../constants.js';
import type {
Invocation,
ReadOnlyLevel,
RunRequest,
ToolConfig,
} from '../types.js';
import { BaseAdapter } from './base.js';
export class CustomAdapter extends BaseAdapter {
id: string;
displayName: string;
commands: string[];
installUrl = '';
readOnly: { level: ReadOnlyLevel };
models: { id: string; name: string; recommended?: boolean }[] = [];
private config: ToolConfig;
constructor(id: string, config: ToolConfig) {
super();
this.id = id;
this.displayName = id;
this.commands = [config.binary];
this.readOnly = { level: config.readOnly.level };
this.config = config;
}
buildInvocation(req: RunRequest): Invocation {
const args: string[] = [];
if (req.extraFlags) {
args.push(...req.extraFlags);
}
// Add read-only flags if applicable
if (req.readOnlyPolicy !== 'none' && this.config.readOnly.flags) {
args.push(...this.config.readOnly.flags);
}
const cmd = req.binary ?? this.config.binary;
if (this.config.stdin === true) {
return { cmd, args, stdin: req.prompt, cwd: req.cwd };
}
const instruction = `Read the file at ${sanitizePath(req.promptFilePath)} and follow the instructions within it.`;
args.push(instruction);
return { cmd, args, cwd: req.cwd };
}
}
================================================
FILE: src/adapters/gemini.ts
================================================
import type { Invocation, RunRequest } from '../types.js';
import { BaseAdapter } from './base.js';
export class GeminiAdapter extends BaseAdapter {
id = 'gemini';
displayName = 'Gemini CLI';
commands = ['gemini'];
installUrl = 'https://github.com/google-gemini/gemini-cli';
readOnly = { level: 'enforced' as const };
models = [
{
id: 'gemini-3-pro',
name: 'Gemini 3 Pro — latest',
recommended: true,
extraFlags: ['-m', 'gemini-3-pro-preview'],
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro — stable GA',
extraFlags: ['-m', 'gemini-2.5-pro'],
},
{
id: 'gemini-3-flash',
name: 'Gemini 3 Flash — fast',
extraFlags: ['-m', 'gemini-3-flash-preview'],
},
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash — fast GA',
extraFlags: ['-m', 'gemini-2.5-flash'],
},
];
buildInvocation(req: RunRequest): Invocation {
const args = ['-p', ''];
if (req.extraFlags) {
args.push(...req.extraFlags);
}
if (req.readOnlyPolicy !== 'none') {
args.push(
'--extensions',
'',
'--allowed-tools',
'read_file',
'list_directory',
'search_file_content',
'glob',
'google_web_search',
'codebase_investigator',
);
}
args.push('--output-format', 'text');
// Gemini CLI includes tool-use narration ("I will read...", "I will list...")
// in its headless text output. Append an instruction to suppress it.
const prompt =
req.prompt +
'\n\nIMPORTANT: Do not narrate your tool usage, internal planning, or chain of thought. Start your response directly with your analysis. Do not prefix your response with lines like "I will read..." or "I will list...".';
return {
cmd: req.binary ?? 'gemini',
args,
stdin: prompt,
cwd: req.cwd,
};
}
}
================================================
FILE: src/adapters/index.ts
================================================
import type { ToolAdapter, ToolConfig } from '../types.js';
import { AmpAdapter } from './amp.js';
import { ClaudeAdapter } from './claude.js';
import { CodexAdapter } from './codex.js';
import { CustomAdapter } from './custom.js';
import { GeminiAdapter } from './gemini.js';
const builtInAdapters: Record<string, () => ToolAdapter> = {
claude: () => new ClaudeAdapter(),
codex: () => new CodexAdapter(),
gemini: () => new GeminiAdapter(),
amp: () => new AmpAdapter(),
};
export function getAdapter(id: string, config?: ToolConfig): ToolAdapter {
if (builtInAdapters[id]) {
return builtInAdapters[id]();
}
if (config) {
return new CustomAdapter(id, config);
}
throw new Error(
`Unknown tool: ${id}. Use "counselors tools add" to configure it.`,
);
}
export function getAllBuiltInAdapters(): ToolAdapter[] {
return Object.values(builtInAdapters).map((fn) => fn());
}
export function isBuiltInTool(id: string): boolean {
return id in builtInAdapters;
}
export function getBuiltInToolIds(): string[] {
return Object.keys(builtInAdapters);
}
export function resolveAdapter(
id: string,
toolConfig: ToolConfig,
): ToolAdapter {
const baseId = toolConfig.adapter ?? id;
return isBuiltInTool(baseId)
? getAdapter(baseId)
: new CustomAdapter(id, toolConfig);
}
================================================
FILE: src/cli.ts
================================================
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent.js';
import { registerCleanupCommand } from './commands/cleanup.js';
import { registerConfigCommand } from './commands/config.js';
import { registerDoctorCommand } from './commands/doctor.js';
import { registerGroupAddCommand } from './commands/groups/add.js';
import { registerGroupListCommand } from './commands/groups/list.js';
import { registerGroupRemoveCommand } from './commands/groups/remove.js';
import { registerInitCommand } from './commands/init.js';
import { registerLoopCommand } from './commands/loop.js';
import { registerMakeDirCommand } from './commands/make-dir.js';
import { registerRunCommand } from './commands/run.js';
import { registerSkillCommand } from './commands/skill.js';
import { registerAddCommand } from './commands/tools/add.js';
import { registerDiscoverCommand } from './commands/tools/discover.js';
import { registerListCommand } from './commands/tools/list.js';
import { registerRemoveCommand } from './commands/tools/remove.js';
import { registerRenameCommand } from './commands/tools/rename.js';
import { registerTestCommand } from './commands/tools/test.js';
import { registerUpgradeCommand } from './commands/upgrade.js';
import { VERSION } from './constants.js';
const program = new Command();
program
.name('counselors')
.description(
'Fan out prompts to multiple AI coding tools (agents) in parallel',
)
.version(VERSION);
// Top-level commands
registerRunCommand(program);
registerLoopCommand(program);
registerMakeDirCommand(program);
registerCleanupCommand(program);
registerConfigCommand(program);
registerDoctorCommand(program);
registerInitCommand(program);
registerAgentCommand(program);
registerSkillCommand(program);
registerUpgradeCommand(program);
// Tools subcommand group
const tools = program
.command('tools')
.description('Manage AI tool configurations');
registerDiscoverCommand(tools);
registerAddCommand(tools);
registerRemoveCommand(tools);
registerRenameCommand(tools);
registerListCommand(tools);
registerTestCommand(tools);
// Groups subcommand group
const groups = program
.command('groups')
.description('Manage predefined tool groups');
registerGroupListCommand(groups);
registerGroupAddCommand(groups);
registerGroupRemoveCommand(groups);
// Top-level aliases
program
.command('add [tool]')
.description('Alias for "tools add"')
.action(async (tool?: string) => {
const args = tool ? ['add', tool] : ['add'];
await tools.parseAsync(args, { from: 'user' });
});
program
.command('ls')
.description('Alias for "tools list"')
.option('-v, --verbose', 'Show full tool configuration including flags')
.action(async (opts: { verbose?: boolean }) => {
const args = ['list'];
if (opts.verbose) args.push('--verbose');
await tools.parseAsync(args, { from: 'user' });
});
program.parseAsync(process.argv).catch((err: Error) => {
process.stderr.write(`✗ ${err.message}\n`);
process.exitCode = 1;
});
================================================
FILE: src/commands/_run-shared.ts
================================================
import { copyFileSync, readFileSync } from 'node:fs';
import { basename, dirname, resolve, sep } from 'node:path';
import { isBuiltInTool, resolveAdapter } from '../adapters/index.js';
import { loadConfig, loadProjectConfig, mergeConfigs } from '../core/config.js';
import { gatherContext } from '../core/context.js';
import { safeWriteFile } from '../core/fs-utils.js';
import {
buildPrompt,
generateSlug,
generateSlugFromFile,
resolveOutputDir,
} from '../core/prompt-builder.js';
import type { Config, ReadOnlyLevel } from '../types.js';
import { error } from '../ui/logger.js';
import { selectRunTools } from '../ui/prompts.js';
// ── Duplicate tool expansion ──
/**
* Handle repeated tool IDs (e.g. `--tools claude,claude,claude`).
* First occurrence keeps its original ID. Subsequent occurrences get
* suffixed clones (`claude__2`, `claude__3`) with duplicated config entries.
*/
export function expandDuplicateToolIds(
toolIds: string[],
config: Config,
): { toolIds: string[]; config: Config } {
const used = new Set(Object.keys(config.tools));
const nextSuffix: Record<string, number> = {};
let expandedTools: Config['tools'] | null = null;
const expanded: string[] = [];
for (const id of toolIds) {
const next = nextSuffix[id] ?? 1;
if (next === 1) {
nextSuffix[id] = 2;
expanded.push(id);
continue;
}
let suffix = next;
let candidate = `${id}__${suffix}`;
while (used.has(candidate)) {
suffix++;
candidate = `${id}__${suffix}`;
}
nextSuffix[id] = suffix + 1;
if (!expandedTools) expandedTools = { ...config.tools };
const baseConfig = config.tools[id];
// Base tool existence is validated earlier; this is a defensive fallback.
if (baseConfig) {
const needsAdapter = !baseConfig.adapter && isBuiltInTool(id);
expandedTools[candidate] = needsAdapter
? { ...baseConfig, adapter: id }
: baseConfig;
}
used.add(candidate);
expanded.push(candidate);
}
if (!expandedTools) return { toolIds, config };
return { toolIds: expanded, config: { ...config, tools: expandedTools } };
}
// ── Tool resolution ──
export interface ToolOpts {
tools?: string;
group?: string;
dryRun?: boolean;
}
export interface ResolvedTools {
toolIds: string[];
config: Config;
}
export async function resolveTools(
opts: ToolOpts,
cwd: string,
): Promise<ResolvedTools | null> {
const globalConfig = loadConfig();
const projectConfig = loadProjectConfig(cwd);
let config = mergeConfigs(globalConfig, projectConfig);
const groupNames = opts.group
? opts.group
.split(',')
.map((g) => g.trim())
.filter(Boolean)
: [];
const explicitSelection = Boolean(opts.tools || groupNames.length > 0);
const groupToolIds: string[] = [];
if (groupNames.length > 0) {
for (const groupName of groupNames) {
const ids = config.groups[groupName];
if (!ids) {
error(
`Group "${groupName}" is not configured. Run "counselors groups list".`,
);
process.exitCode = 1;
return null;
}
for (const id of ids) {
if (!config.tools[id]) {
error(
`Group "${groupName}" references tool "${id}", but it is not configured.`,
);
process.exitCode = 1;
return null;
}
}
groupToolIds.push(...ids);
}
}
const explicitToolIds = opts.tools
? opts.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: [];
let toolIds: string[];
if (explicitSelection) {
// Dedup tools that appear in both --group and --tools to avoid running twice.
// Preserve intentional duplicates within --tools (handled by expandDuplicateToolIds).
const groupSet = new Set(groupToolIds);
const dedupedExplicit = explicitToolIds.filter((id) => !groupSet.has(id));
toolIds = [...groupToolIds, ...dedupedExplicit];
} else {
toolIds = Object.keys(config.tools);
}
if (toolIds.length === 0) {
if (Object.keys(config.tools).length === 0) {
error('No tools configured. Run "counselors init" first.');
} else {
error('No tools selected.');
}
process.exitCode = 1;
return null;
}
// Validate all tools exist in config
for (const id of toolIds) {
if (!config.tools[id]) {
error(`Tool "${id}" not configured. Run "counselors tools add ${id}".`);
process.exitCode = 1;
return null;
}
}
// Interactive tool selection when no --tools flag and TTY
if (
!explicitSelection &&
!opts.dryRun &&
process.stderr.isTTY &&
toolIds.length > 1
) {
const selected = await selectRunTools(toolIds);
if (selected.length === 0) {
error('No tools selected.');
process.exitCode = 1;
return null;
}
toolIds = selected;
}
// Expand duplicates (e.g. --tools claude,claude,claude)
const expanded = expandDuplicateToolIds(toolIds, config);
toolIds = expanded.toolIds;
config = expanded.config;
return { toolIds, config };
}
// ── Read-only policy resolution ──
/**
* Map CLI flag values (strict / best-effort / off) to internal
* ReadOnlyLevel values (enforced / bestEffort / none), falling
* back to the config default when no flag is provided.
*/
const READ_ONLY_MAP: [cli: string, internal: ReadOnlyLevel][] = [
['strict', 'enforced'],
['best-effort', 'bestEffort'],
['off', 'none'],
];
const cliToInternal = new Map(READ_ONLY_MAP.map(([c, i]) => [c, i]));
const internalToCli = new Map(READ_ONLY_MAP.map(([c, i]) => [i, c]));
export function resolveReadOnlyPolicy(
readOnlyInput: string | undefined,
config: Config,
): ReadOnlyLevel | null {
const input =
readOnlyInput ??
internalToCli.get(config.defaults.readOnly) ??
'best-effort';
const policy = cliToInternal.get(input);
if (!policy) {
error(
`Invalid --read-only value "${input}". Must be: strict, best-effort, or off.`,
);
process.exitCode = 1;
return null;
}
return policy;
}
// ── Prompt resolution ──
export interface PromptOpts {
file?: string;
context?: string;
enrichStdinPrompt?: boolean;
}
export interface ResolvedPrompt {
promptContent: string;
promptSource: 'inline' | 'file' | 'stdin';
slug: string;
}
export async function resolvePrompt(
promptArg: string | undefined,
opts: PromptOpts,
cwd: string,
config: Config,
): Promise<ResolvedPrompt | null> {
if (opts.file) {
const filePath = resolve(cwd, opts.file);
let promptContent: string;
try {
promptContent = readFileSync(filePath, 'utf-8');
} catch {
error(`Cannot read prompt file: ${filePath}`);
process.exitCode = 1;
return null;
}
if (opts.context) {
const context = gatherContext(
cwd,
opts.context === '.' ? [] : opts.context.split(','),
config.defaults.maxContextKb,
);
if (context) promptContent = `${promptContent}\n\n${context}`;
}
return {
promptContent,
promptSource: 'file',
slug: generateSlugFromFile(filePath),
};
}
if (promptArg) {
const context = opts.context
? gatherContext(
cwd,
opts.context === '.' ? [] : opts.context.split(','),
config.defaults.maxContextKb,
)
: undefined;
return {
promptContent: buildPrompt(promptArg, context),
promptSource: 'inline',
slug: generateSlug(promptArg),
};
}
// Check stdin
if (process.stdin.isTTY) {
error(
'No prompt provided. Pass as argument, use -f <file>, or pipe via stdin.',
);
process.exitCode = 1;
return null;
}
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const stdinContent = Buffer.concat(chunks).toString('utf-8').trim();
if (!stdinContent) {
error('Empty prompt from stdin.');
process.exitCode = 1;
return null;
}
const context = opts.context
? gatherContext(
cwd,
opts.context === '.' ? [] : opts.context.split(','),
config.defaults.maxContextKb,
)
: undefined;
const enrichStdinPrompt = opts.enrichStdinPrompt ?? true;
return {
promptContent: enrichStdinPrompt
? buildPrompt(stdinContent, context)
: context
? `${stdinContent}\n\n${context}`
: stdinContent,
promptSource: 'stdin',
slug: generateSlug(stdinContent),
};
}
// ── Output directory creation ──
export interface OutputDirResult {
outputDir: string;
promptFilePath: string;
}
export function createOutputDir(
opts: { file?: string; outputDir?: string },
slug: string,
promptContent: string,
cwd: string,
config: Config,
): OutputDirResult {
const baseDir = opts.outputDir || config.defaults.outputDir;
if (opts.file) {
const absFile = resolve(cwd, opts.file);
const fileDir = dirname(absFile);
const resolvedBase = resolve(cwd, baseDir);
// If the prompt file already lives inside a subdir of baseDir,
// reuse that directory instead of creating a duplicate.
if (fileDir.startsWith(resolvedBase + sep) && fileDir !== resolvedBase) {
return { outputDir: fileDir, promptFilePath: absFile };
}
const outputDir = resolveOutputDir(baseDir, slug);
const promptFilePath = resolve(outputDir, 'prompt.md');
copyFileSync(absFile, promptFilePath);
return { outputDir, promptFilePath };
}
const outputDir = resolveOutputDir(baseDir, slug);
const promptFilePath = resolve(outputDir, 'prompt.md');
safeWriteFile(promptFilePath, promptContent);
return { outputDir, promptFilePath };
}
// ── Dry-run invocation builder ──
export function buildDryRunInvocations(
config: Config,
toolIds: string[],
promptContent: string,
outputDir: string,
readOnlyPolicy: ReadOnlyLevel,
cwd: string,
) {
const promptFilePath = resolve(outputDir, 'prompt.md');
return toolIds.map((id) => {
const toolConfig = config.tools[id];
const adapter = resolveAdapter(id, toolConfig);
const inv = adapter.buildInvocation({
prompt: promptContent,
promptFilePath,
toolId: id,
outputDir,
readOnlyPolicy,
timeout: config.defaults.timeout,
cwd,
binary: toolConfig.binary,
extraFlags: toolConfig.extraFlags,
});
return {
toolId: id,
cmd: inv.cmd,
args: inv.args,
};
});
}
// ── Prompt label helper ──
export function getPromptLabel(
promptArg: string | undefined,
file: string | undefined,
): string {
return promptArg || (file ? `file:${basename(file)}` : 'stdin');
}
================================================
FILE: src/commands/agent.ts
================================================
import type { Command } from 'commander';
import { info } from '../ui/logger.js';
export function registerAgentCommand(program: Command): void {
program
.command('agent')
.description('Print setup and skill installation instructions')
.action(async () => {
const instructions = `# Counselors — Setup & Skill Installation
## 1. Install the CLI
\`\`\`bash
npm install -g counselors
\`\`\`
Requires Node 20+.
## 2. Configure tools
Auto-discover and configure all installed AI coding agents:
\`\`\`bash
counselors init --auto
\`\`\`
This detects installed agents (Claude, Codex, Gemini, Amp), configures them with recommended models, and writes your config to \`~/.config/counselors/config.json\`. The output is JSON listing what was configured.
You can also manage tools individually:
\`\`\`bash
counselors tools discover # Find available agents
counselors tools add # Add a tool (interactive)
counselors tools remove <id> # Remove a tool
counselors tools rename <old> <new> # Rename a tool
counselors ls # List configured tools
counselors doctor # Verify tools are working
\`\`\`
## 3. Install the skill
The \`/counselors\` skill lets AI coding agents invoke counselors directly via a slash command.
Run \`counselors skill\` to print a reference template with instructions. **Read the output carefully** — it describes a multi-phase workflow that you need to adapt to your agent's skill format before saving. Do not blindly copy the output into a file.
For Claude Code, save the adapted skill to \`~/.claude/skills/counselors/SKILL.md\`. For other agents, save it wherever your system looks for slash commands or skills.
## 4. Verify
\`\`\`bash
counselors doctor
\`\`\`
Then use \`/counselors\` from your AI coding agent to fan out a prompt for parallel review.
`;
info(instructions);
});
}
================================================
FILE: src/commands/cleanup.ts
================================================
import { resolve } from 'node:path';
import type { Command } from 'commander';
import {
deleteCleanupCandidates,
parseDurationMs,
scanCleanupCandidates,
} from '../core/cleanup.js';
import { loadConfig, loadProjectConfig, mergeConfigs } from '../core/config.js';
import { error, info, success, warn } from '../ui/logger.js';
import { confirmAction } from '../ui/prompts.js';
function formatDurationForHumans(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m`;
const h = Math.round(m / 60);
if (h < 48) return `${h}h`;
const d = Math.round(h / 24);
return `${d}d`;
}
export function registerCleanupCommand(program: Command): void {
program
.command('cleanup')
.description('Delete run output directories older than a given age')
.option(
'--older-than <duration>',
'Delete runs older than this age (e.g. 1d, 12h, 30m, 2w, 500ms). Defaults to 1d. A bare number is days.',
'1d',
)
.option(
'-o, --output-dir <dir>',
'Base output directory (overrides config)',
)
.option('--dry-run', 'Show what would be deleted without removing files')
.option('-y, --yes', 'Do not prompt for confirmation')
.option('--json', 'Output results as JSON')
.action(
async (opts: {
olderThan: string;
outputDir?: string;
dryRun?: boolean;
yes?: boolean;
json?: boolean;
}) => {
const cwd = process.cwd();
const globalConfig = loadConfig();
const projectConfig = loadProjectConfig(cwd);
const config = mergeConfigs(globalConfig, projectConfig);
let olderThanMs: number;
try {
olderThanMs = parseDurationMs(opts.olderThan);
} catch (e) {
error(e instanceof Error ? e.message : String(e));
process.exitCode = 1;
return;
}
if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
error(`Invalid --older-than value "${opts.olderThan}".`);
process.exitCode = 1;
return;
}
const baseDir = opts.outputDir || config.defaults.outputDir;
const absBaseDir = resolve(cwd, baseDir);
const cutoffMs = Date.now() - olderThanMs;
const { baseExists, candidates, skippedSymlinks } =
scanCleanupCandidates(absBaseDir, cutoffMs);
if (!baseExists) {
info(`No output directory found at: ${absBaseDir}`);
return;
}
if (skippedSymlinks.length > 0) {
warn(
`Skipping ${skippedSymlinks.length} symlink(s) in output dir for safety.`,
);
}
if (candidates.length === 0) {
info(
`No run output directories older than ${formatDurationForHumans(
olderThanMs,
)} to clean up.`,
);
return;
}
if (opts.dryRun) {
if (opts.json) {
info(
JSON.stringify(
{
baseDir: absBaseDir,
olderThan: opts.olderThan,
candidates: candidates.map((c) => ({
name: c.name,
path: c.path,
mtimeMs: c.mtimeMs,
})),
},
null,
2,
),
);
} else {
info(
`Dry run: would delete ${candidates.length} director${
candidates.length === 1 ? 'y' : 'ies'
} under ${absBaseDir}`,
);
for (const c of candidates) {
info(`- ${c.name}`);
}
}
return;
}
if (!opts.yes) {
if (!process.stderr.isTTY) {
error(
'Refusing to delete in non-interactive mode without --yes. Re-run with --dry-run to preview.',
);
process.exitCode = 1;
return;
}
const ok = await confirmAction(
`Delete ${candidates.length} director${
candidates.length === 1 ? 'y' : 'ies'
} under ${absBaseDir} older than ${formatDurationForHumans(
olderThanMs,
)}?`,
);
if (!ok) {
info('Aborted.');
return;
}
}
const result = deleteCleanupCandidates(candidates);
if (opts.json) {
info(
JSON.stringify(
{
baseDir: absBaseDir,
olderThan: opts.olderThan,
deleted: result.deleted,
failed: result.failed,
},
null,
2,
),
);
} else {
if (result.deleted.length > 0) {
success(
`Deleted ${result.deleted.length} director${
result.deleted.length === 1 ? 'y' : 'ies'
}.`,
);
}
if (result.failed.length > 0) {
error(
`Failed to delete ${result.failed.length} director${
result.failed.length === 1 ? 'y' : 'ies'
}.`,
);
for (const f of result.failed) {
warn(`${f.path}: ${f.error}`);
}
process.exitCode = 1;
}
}
},
);
}
================================================
FILE: src/commands/config.ts
================================================
import type { Command } from 'commander';
import { CONFIG_FILE } from '../constants.js';
import { loadConfig } from '../core/config.js';
import { info } from '../ui/logger.js';
export function registerConfigCommand(program: Command): void {
program
.command('config')
.description('Show resolved configuration')
.action(() => {
info(`Config file: ${CONFIG_FILE}\n`);
const config = loadConfig();
info(JSON.stringify(config, null, 2));
});
}
================================================
FILE: src/commands/doctor.ts
================================================
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Command } from 'commander';
import { isAmpDeepMode } from '../adapters/amp.js';
import { resolveAdapter } from '../adapters/index.js';
import {
AMP_DEEP_SETTINGS_FILE,
AMP_SETTINGS_FILE,
CONFIG_FILE,
} from '../constants.js';
import { loadConfig } from '../core/config.js';
import { findBinary, getBinaryVersion } from '../core/discovery.js';
import { detectInstallation } from '../core/upgrade.js';
import type { DoctorCheck } from '../types.js';
import { info } from '../ui/logger.js';
import { formatDoctorResults } from '../ui/output.js';
export function registerDoctorCommand(program: Command): void {
program
.command('doctor')
.description('Check tool configuration and health')
.action(async () => {
const checks: DoctorCheck[] = [];
// Check config file
if (existsSync(CONFIG_FILE)) {
checks.push({
name: 'Config file',
status: 'pass',
message: CONFIG_FILE,
});
} else {
checks.push({
name: 'Config file',
status: 'warn',
message: 'Not found. Run "counselors init" to create one.',
});
}
let config;
try {
config = loadConfig();
} catch (e) {
checks.push({
name: 'Config parse',
status: 'fail',
message: `Invalid config: ${e}`,
});
info(formatDoctorResults(checks));
process.exitCode = 1;
return;
}
const toolIds = Object.keys(config.tools);
if (toolIds.length === 0) {
checks.push({
name: 'Tools configured',
status: 'warn',
message: 'No tools configured. Run "counselors init".',
});
}
// Check each configured tool
for (const id of toolIds) {
const toolConfig = config.tools[id];
// Binary exists + executable
const binaryPath = findBinary(toolConfig.binary);
if (binaryPath) {
checks.push({
name: `${id}: binary`,
status: 'pass',
message: binaryPath,
});
} else {
checks.push({
name: `${id}: binary`,
status: 'fail',
message: `"${toolConfig.binary}" not found in PATH`,
});
continue;
}
// Version check
const version = getBinaryVersion(binaryPath);
if (version) {
checks.push({
name: `${id}: version`,
status: 'pass',
message: version,
});
} else {
checks.push({
name: `${id}: version`,
status: 'warn',
message: 'Could not determine version',
});
}
// Read-only capability
const adapter = resolveAdapter(id, toolConfig);
let readOnlyLevel = adapter.readOnly.level;
// Amp deep mode uses Bash (a write-capable tool), so it's bestEffort
const adapterName = toolConfig.adapter ?? id;
if (adapterName === 'amp' && isAmpDeepMode(toolConfig.extraFlags)) {
readOnlyLevel = 'bestEffort';
}
checks.push({
name: `${id}: read-only`,
status: readOnlyLevel === 'none' ? 'warn' : 'pass',
message: readOnlyLevel,
});
}
// Check amp settings files if any amp-based tool is configured
const hasAmp = Object.entries(config.tools).some(
([id, t]) => (t.adapter ?? id) === 'amp',
);
if (hasAmp) {
if (existsSync(AMP_SETTINGS_FILE)) {
checks.push({
name: 'Amp settings file',
status: 'pass',
message: AMP_SETTINGS_FILE,
});
} else {
checks.push({
name: 'Amp settings file',
status: 'warn',
message: 'Not found. Amp read-only mode may not work.',
});
}
if (existsSync(AMP_DEEP_SETTINGS_FILE)) {
checks.push({
name: 'Amp deep settings file',
status: 'pass',
message: AMP_DEEP_SETTINGS_FILE,
});
} else {
checks.push({
name: 'Amp deep settings file',
status: 'warn',
message: 'Not found. Amp deep mode may not work.',
});
}
}
// Check groups reference valid tools
const groups = config.groups ?? {};
for (const [groupName, members] of Object.entries(groups)) {
const invalid = members.filter((m) => !config.tools[m]);
if (invalid.length > 0) {
checks.push({
name: `group "${groupName}"`,
status: 'fail',
message: `References missing tool(s): ${invalid.join(', ')}`,
});
} else {
checks.push({
name: `group "${groupName}"`,
status: 'pass',
message: `${members.length} tool(s)`,
});
}
}
// Check for multiple installations
const detection = detectInstallation();
const sources: string[] = [];
if (detection.brewVersion) sources.push('homebrew');
if (detection.npmVersion) sources.push('npm');
// Check standalone paths independently of the detected method
const home = process.env.HOME ?? '';
const standalonePaths = [
join(home, '.local', 'bin', 'counselors'),
join(home, 'bin', 'counselors'),
];
const hasStandalone = home && standalonePaths.some((p) => existsSync(p));
if (hasStandalone) sources.push('standalone');
if (sources.length > 1) {
checks.push({
name: 'Multiple installations',
status: 'warn',
message: `Found counselors via ${sources.join(', ')}. This may cause version conflicts.`,
});
}
info(formatDoctorResults(checks));
if (checks.some((c) => c.status === 'fail')) {
process.exitCode = 1;
}
});
}
================================================
FILE: src/commands/groups/add.ts
================================================
import type { Command } from 'commander';
import { SAFE_ID_RE } from '../../constants.js';
import { addGroupToConfig, loadConfig, saveConfig } from '../../core/config.js';
import { error, success } from '../../ui/logger.js';
function parseToolList(value: string | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
export function registerGroupAddCommand(program: Command): void {
program
.command('add <name>')
.description('Create or update a group (comma-separated tool IDs)')
.requiredOption('-t, --tools <list>', 'Comma-separated tool IDs')
.action(async (name: string, opts: { tools?: string }) => {
if (!SAFE_ID_RE.test(name)) {
error(
`Invalid group name "${name}". Use only letters, numbers, dots, hyphens, and underscores.`,
);
process.exitCode = 1;
return;
}
const toolIds = parseToolList(opts.tools);
if (toolIds.length === 0) {
error('No tool IDs provided. Use --tools <a,b,c>.');
process.exitCode = 1;
return;
}
const config = loadConfig();
if (Object.keys(config.tools).length === 0) {
error('No tools configured. Run "counselors init" first.');
process.exitCode = 1;
return;
}
for (const id of toolIds) {
if (!config.tools[id]) {
error(`Tool "${id}" is not configured.`);
process.exitCode = 1;
return;
}
}
const existed = Boolean(config.groups[name]);
const updated = addGroupToConfig(config, name, toolIds);
saveConfig(updated);
success(
existed
? `Updated group "${name}" (${toolIds.length} tool(s)).`
: `Created group "${name}" (${toolIds.length} tool(s)).`,
);
});
}
================================================
FILE: src/commands/groups/list.ts
================================================
import type { Command } from 'commander';
import { loadConfig } from '../../core/config.js';
import { info } from '../../ui/logger.js';
function formatGroupList(groups: Record<string, string[]>): string {
const names = Object.keys(groups).sort();
if (names.length === 0) {
return '\nNo groups configured. Use "counselors groups add <name> --tools <list>" to create one.\n';
}
const lines: string[] = ['', 'Configured groups:', ''];
for (const name of names) {
const toolIds = groups[name] ?? [];
lines.push(
` ${name}: ${toolIds.length > 0 ? toolIds.join(', ') : '(empty)'}`,
);
}
lines.push('');
return lines.join('\n');
}
export function registerGroupListCommand(program: Command): void {
program
.command('list')
.alias('ls')
.description('List configured groups')
.action(async () => {
const config = loadConfig();
info(formatGroupList(config.groups));
});
}
================================================
FILE: src/commands/groups/remove.ts
================================================
import type { Command } from 'commander';
import {
loadConfig,
removeGroupFromConfig,
saveConfig,
} from '../../core/config.js';
import { error, success } from '../../ui/logger.js';
export function registerGroupRemoveCommand(program: Command): void {
program
.command('remove <name>')
.description('Remove a configured group')
.action(async (name: string) => {
const config = loadConfig();
if (!config.groups[name]) {
error(`Group "${name}" is not configured.`);
process.exitCode = 1;
return;
}
const updated = removeGroupFromConfig(config, name);
saveConfig(updated);
success(`Removed group "${name}".`);
});
}
================================================
FILE: src/commands/init.ts
================================================
import type { Command } from 'commander';
import { getAllBuiltInAdapters, resolveAdapter } from '../adapters/index.js';
import { AMP_SETTINGS_FILE, CONFIG_DIR } from '../constants.js';
import { copyAmpSettings } from '../core/amp-utils.js';
import { addToolToConfig, loadConfig, saveConfig } from '../core/config.js';
import { discoverTool } from '../core/discovery.js';
import { executeTest } from '../core/executor.js';
import { info, success, warn } from '../ui/logger.js';
import {
createSpinner,
formatDiscoveryResults,
formatTestResults,
} from '../ui/output.js';
import { confirmAction, selectModels, selectTools } from '../ui/prompts.js';
function buildToolConfig(
id: string,
adapter: import('../types.js').ToolAdapter,
binaryPath: string,
) {
return {
binary: binaryPath,
readOnly: { level: adapter.readOnly.level },
...(id === 'gemini' || id === 'codex' ? { timeout: 900 } : {}),
};
}
function compoundId(adapterId: string, modelId: string): string {
if (modelId.startsWith(`${adapterId}-`)) return modelId;
return `${adapterId}-${modelId}`;
}
export function registerInitCommand(program: Command): void {
program
.command('init')
.description('Interactive setup wizard')
.option(
'--auto',
'Non-interactive mode: discover tools, use recommended models, output JSON',
)
.action(async (opts: { auto?: boolean }) => {
// Non-interactive auto mode
if (opts.auto) {
const adapters = getAllBuiltInAdapters();
const discoveries = adapters.map((adapter) => {
const result = discoverTool(adapter.commands);
return { adapter, discovery: result };
});
const foundTools = discoveries.filter((d) => d.discovery.found);
if (foundTools.length === 0) {
info(
JSON.stringify(
{
configured: [],
notFound: adapters.map((a) => a.id),
configPath: CONFIG_DIR,
},
null,
2,
),
);
return;
}
let config = loadConfig();
const configured: {
id: string;
adapter: string;
binary: string;
version: string | null;
}[] = [];
const notFound: string[] = [];
for (const { adapter, discovery } of discoveries) {
if (!discovery.found) {
notFound.push(adapter.id);
continue;
}
for (const model of adapter.models) {
const cid = model.compoundId ?? compoundId(adapter.id, model.id);
const toolConfig = {
...buildToolConfig(adapter.id, adapter, discovery.path!),
adapter: adapter.id,
...(model.extraFlags ? { extraFlags: model.extraFlags } : {}),
};
config = addToolToConfig(config, cid, toolConfig);
configured.push({
id: cid,
adapter: adapter.id,
binary: discovery.path!,
version: discovery.version,
});
}
}
if (configured.some((t) => t.adapter === 'amp')) {
copyAmpSettings();
}
saveConfig(config);
info(
JSON.stringify(
{ configured, notFound, configPath: CONFIG_DIR },
null,
2,
),
);
return;
}
// Interactive mode
info('\nCounselors — setup wizard\n');
const existingConfig = loadConfig();
const existingTools = Object.keys(existingConfig.tools);
if (existingTools.length > 0) {
warn(
`Existing config has ${existingTools.length} tool(s). Re-running init will overwrite any tools with the same name.`,
);
}
// Step 1: Discover all built-in tools
const spinner = createSpinner('Discovering installed tools...').start();
const adapters = getAllBuiltInAdapters();
const discoveries = adapters.map((adapter) => {
const result = discoverTool(adapter.commands);
return { adapter, discovery: result };
});
spinner.stop();
info(
formatDiscoveryResults(
discoveries.map((d) => ({
...d.discovery,
toolId: d.adapter.id,
displayName: d.adapter.displayName,
})),
),
);
const foundTools = discoveries.filter((d) => d.discovery.found);
if (foundTools.length === 0) {
warn(
'No AI CLI tools found. Install at least one before running init.',
);
return;
}
// Step 2: Select which tools to add
const selectedIds = await selectTools(
discoveries.map((d) => ({
id: d.adapter.id,
name: d.adapter.displayName,
found: d.discovery.found,
})),
);
if (selectedIds.length === 0) {
info('No tools selected. Exiting.');
return;
}
// Step 3: Model selection per tool
let config = loadConfig();
const configuredIds: string[] = [];
for (const id of selectedIds) {
const d = discoveries.find((x) => x.adapter.id === id)!;
const models = await selectModels(id, d.adapter.models);
for (const model of models) {
const cid = model.compoundId ?? compoundId(id, model.id);
const toolConfig = {
...buildToolConfig(id, d.adapter, d.discovery.path!),
adapter: id,
...(model.extraFlags ? { extraFlags: model.extraFlags } : {}),
};
config = addToolToConfig(config, cid, toolConfig);
configuredIds.push(cid);
}
}
// Step 4: Copy amp settings if amp was selected
if (selectedIds.includes('amp')) {
copyAmpSettings();
success(`Copied amp settings to ${AMP_SETTINGS_FILE}`);
}
// Step 5: Save config
saveConfig(config);
success(`Config saved to ${CONFIG_DIR}`);
// Step 6: Offer to test
const runTests = await confirmAction('Run tool tests now?');
if (runTests) {
const testResults = [];
for (const id of configuredIds) {
const toolConfig = config.tools[id];
const adapter = resolveAdapter(id, toolConfig);
const spinner = createSpinner(`Testing ${id}...`).start();
const result = await executeTest(adapter, toolConfig, id);
spinner.stop();
testResults.push(result);
}
info(formatTestResults(testResults));
}
});
}
================================================
FILE: src/commands/loop.ts
================================================
import { join, resolve } from 'node:path';
import type { Command } from 'commander';
import { getExecutionBoilerplate } from '../core/boilerplate.js';
import { parseDurationMs } from '../core/cleanup.js';
import { safeWriteFile } from '../core/fs-utils.js';
import { runLoop } from '../core/loop.js';
import { generateSlug } from '../core/prompt-builder.js';
import { writePrompt } from '../core/prompt-writer.js';
import { runRepoDiscovery } from '../core/repo-discovery.js';
import { synthesizeFinal } from '../core/synthesis.js';
import { getPresetNames, resolvePreset } from '../presets/index.js';
import type { PresetDefinition } from '../presets/types.js';
import type { RunManifest } from '../types.js';
import { error, info } from '../ui/logger.js';
import { formatDryRun } from '../ui/output.js';
import { createReporter } from '../ui/reporter.js';
import {
buildDryRunInvocations,
createOutputDir,
getPromptLabel,
resolvePrompt,
resolveReadOnlyPolicy,
resolveTools,
} from './_run-shared.js';
const INLINE_PROMPT_ENHANCEMENT_DESCRIPTION = `You are preparing a multi-round code review prompt from a raw user request (no preset selected). Preserve the user's intent and success criteria, then expand it into a concrete execution prompt grounded in the discovered repository context. Require evidence-backed findings with file/function references, clear risk framing, and concrete fix suggestions.`;
function withExecutionBoilerplate(promptContent: string): string {
const content = promptContent.trimEnd();
const boilerplate = getExecutionBoilerplate().trim();
if (content.includes(boilerplate)) return content;
return content.length > 0 ? `${content}\n\n${boilerplate}` : boilerplate;
}
export function registerLoopCommand(program: Command): void {
const loopCmd = program
.command('loop [prompt]')
.description(
'Multi-round dispatch — tools (agents) iterate, seeing prior outputs each round',
)
.option(
'-f, --file <path>',
'Use a pre-built prompt file (skip discovery/prompt-writing enhancement)',
)
.option('-t, --tools <tools>', 'Comma-separated list of tools to use')
.option(
'-g, --group <groups>',
'Comma-separated group name(s) to run (expands to tool IDs)',
)
.option(
'--context <paths>',
'Gather context from paths (comma-separated, or "." for git diff)',
)
.option('--read-only <level>', 'Read-only policy: strict, best-effort, off')
.option('--rounds <N>', 'Number of dispatch rounds', '3')
.option('--duration <time>', 'Max total duration (e.g. "30m", "1h")')
.option('--preset <name>', 'Use a built-in preset (e.g. "bughunt")')
.option('--list-presets', 'List built-in presets and exit')
.option(
'--discovery-tool <id>',
'Tool for discovery and prompt-writing phases (default: first tool)',
)
.option(
'--no-inline-enhancement',
'Skip discovery/prompt-writing for non-preset inline prompts',
)
.option(
'--convergence-threshold <ratio>',
'Word count ratio for early stop',
'0.3',
)
.option('--dry-run', 'Show what would be dispatched without running')
.option('--json', 'Output manifest as JSON')
.option('-o, --output-dir <dir>', 'Base output directory');
loopCmd.action(
async (
promptArg: string | undefined,
opts: {
file?: string;
tools?: string;
group?: string;
context?: string;
readOnly?: string;
rounds?: string;
duration?: string;
preset?: string;
listPresets?: boolean;
discoveryTool?: string;
inlineEnhancement?: boolean;
convergenceThreshold?: string;
dryRun?: boolean;
json?: boolean;
outputDir?: string;
},
) => {
const cwd = process.cwd();
if (opts.listPresets) {
const names = getPresetNames();
if (names.length === 0) {
info('No built-in presets found.');
return;
}
info('Built-in presets:');
for (const name of names) {
const preset = resolvePreset(name);
const firstLine = preset.description.split('\n')[0]?.trim() ?? '';
const rounds = preset.defaultRounds ?? 3;
info(`- ${name} (rounds: ${rounds}): ${firstLine}`);
}
return;
}
// Resolve tools
const resolved = await resolveTools(opts, cwd);
if (!resolved) return;
const { toolIds, config } = resolved;
// Resolve read-only policy
let readOnlyPolicy = resolveReadOnlyPolicy(opts.readOnly, config);
if (!readOnlyPolicy) return;
// Parse rounds and duration
const roundsExplicit = loopCmd.getOptionValueSource('rounds') === 'cli';
let rounds = Number.parseInt(opts.rounds ?? '3', 10);
if (Number.isNaN(rounds) || rounds < 1) {
error('--rounds must be a positive integer.');
process.exitCode = 1;
return;
}
let durationMs: number | undefined;
if (opts.duration) {
try {
durationMs = parseDurationMs(opts.duration);
} catch (e) {
error(
e instanceof Error
? e.message
: `Invalid --duration value "${opts.duration}".`,
);
process.exitCode = 1;
return;
}
// If duration is set but rounds is default, allow unlimited rounds
if (!roundsExplicit) rounds = Number.MAX_SAFE_INTEGER;
}
// Parse convergence threshold
const convergenceThreshold = Number.parseFloat(
opts.convergenceThreshold ?? '0.3',
);
if (
Number.isNaN(convergenceThreshold) ||
convergenceThreshold < 0 ||
convergenceThreshold > 1
) {
error('--convergence-threshold must be a number between 0 and 1.');
process.exitCode = 1;
return;
}
// Resolve preset
let preset: PresetDefinition | undefined;
if (opts.preset) {
try {
preset = resolvePreset(opts.preset);
} catch (e) {
error(
e instanceof Error ? e.message : `Unknown preset "${opts.preset}".`,
);
process.exitCode = 1;
return;
}
// Apply preset defaults (only if not explicitly overridden)
if (!roundsExplicit && !durationMs && preset.defaultRounds) {
rounds = preset.defaultRounds;
}
if (!opts.readOnly && preset.defaultReadOnly) {
readOnlyPolicy = preset.defaultReadOnly;
}
}
// Resolve prompt
let promptContent: string;
let promptSource: 'inline' | 'file' | 'stdin';
let slug: string;
const reporter = createReporter({ dryRun: opts.dryRun });
const getDiscoveryToolId = (): string | null => {
const discoveryToolId = opts.discoveryTool ?? toolIds[0];
if (!config.tools[discoveryToolId]) {
error(`Discovery tool "${discoveryToolId}" not configured.`);
process.exitCode = 1;
return null;
}
return discoveryToolId;
};
if (preset) {
// Preset mode: prompt arg is the user's target/focus
if (!promptArg) {
error(
`Preset "${preset.name}" requires a prompt argument describing what to focus on.`,
);
process.exitCode = 1;
return;
}
// Discovery tool: first tool or explicit --discovery-tool
const discoveryToolId = getDiscoveryToolId();
if (!discoveryToolId) return;
slug = generateSlug(preset.name);
promptSource = 'inline';
if (opts.dryRun) {
// Dry run: show what would happen without running prep phases
promptContent = `[Generated by ${preset.name} preset after discovery + prompt-writing phases]`;
} else {
// Phase 1: Discovery
reporter.discoveryStarted(discoveryToolId);
let repoContext: string;
try {
const discovery = await runRepoDiscovery({
config,
toolId: discoveryToolId,
cwd,
target: promptArg,
onProgress: (event) => {
if (event.event === 'started')
reporter.phasePidReported(event.toolId, event.pid!);
},
});
repoContext = discovery.repoContext;
} catch (e) {
error(
`Discovery failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exitCode = 1;
return;
}
reporter.discoveryCompleted(discoveryToolId);
// Phase 2: Prompt Writing
reporter.promptWritingStarted(discoveryToolId);
let generatedPrompt: string;
try {
const result = await writePrompt({
config,
toolId: discoveryToolId,
cwd,
userInput: promptArg,
presetDescription: preset.description,
repoContext,
onProgress: (event) => {
if (event.event === 'started')
reporter.phasePidReported(event.toolId, event.pid!);
},
});
generatedPrompt = result.generatedPrompt;
} catch (e) {
error(
`Prompt writing failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exitCode = 1;
return;
}
reporter.promptWritingCompleted(discoveryToolId);
promptContent = generatedPrompt;
}
} else {
const prompt = await resolvePrompt(
promptArg,
{
file: opts.file,
context: opts.context,
enrichStdinPrompt: false,
},
cwd,
config,
);
if (!prompt) return;
promptContent = prompt.promptContent;
promptSource = prompt.promptSource;
slug = prompt.slug;
const shouldEnhanceInline =
promptSource === 'inline' && opts.inlineEnhancement !== false;
if (shouldEnhanceInline) {
const discoveryToolId = getDiscoveryToolId();
if (!discoveryToolId) return;
if (opts.dryRun) {
promptContent =
'[Generated from inline prompt after discovery + prompt-writing phases]';
} else {
reporter.discoveryStarted(discoveryToolId);
let repoContext: string;
try {
const discovery = await runRepoDiscovery({
config,
toolId: discoveryToolId,
cwd,
target: promptArg,
onProgress: (event) => {
if (event.event === 'started')
reporter.phasePidReported(event.toolId, event.pid!);
},
});
repoContext = discovery.repoContext;
} catch (e) {
error(
`Discovery failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exitCode = 1;
return;
}
reporter.discoveryCompleted(discoveryToolId);
reporter.promptWritingStarted(discoveryToolId);
let generatedPrompt: string;
try {
const result = await writePrompt({
config,
toolId: discoveryToolId,
cwd,
userInput: promptArg ?? promptContent,
presetDescription: INLINE_PROMPT_ENHANCEMENT_DESCRIPTION,
repoContext,
onProgress: (event) => {
if (event.event === 'started')
reporter.phasePidReported(event.toolId, event.pid!);
},
});
generatedPrompt = result.generatedPrompt;
} catch (e) {
error(
`Prompt writing failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exitCode = 1;
return;
}
reporter.promptWritingCompleted(discoveryToolId);
promptContent = generatedPrompt;
}
}
}
// Always include execution boilerplate regardless of prompt source.
promptContent = withExecutionBoilerplate(promptContent);
if (!slug) slug = generateSlug('loop');
// Dry run — no filesystem side effects
if (opts.dryRun) {
const baseDir = opts.outputDir || config.defaults.outputDir;
const dryOutputDir = join(baseDir, slug);
const invocations = buildDryRunInvocations(
config,
toolIds,
promptContent,
dryOutputDir,
readOnlyPolicy,
cwd,
);
info(formatDryRun(invocations));
const roundCount =
rounds === Number.MAX_SAFE_INTEGER ? 'unlimited' : String(rounds);
const durStr = durationMs ? `, max duration: ${opts.duration}` : '';
info(` Rounds: ${roundCount}${durStr}`);
if (preset) {
info(` Preset: ${preset.name}`);
}
info(` Convergence threshold: ${convergenceThreshold}`);
return;
}
// Create output directory
const { outputDir, promptFilePath } = createOutputDir(
opts,
slug,
promptContent,
cwd,
config,
);
const promptLabel = getPromptLabel(promptArg, opts.file);
// Run multi-round loop
const runStart = Date.now();
const totalRoundsLabel =
rounds === Number.MAX_SAFE_INTEGER ? null : rounds;
reporter.executionStarted(outputDir, toolIds, { durationMs });
try {
const loopResult = await runLoop({
config,
toolIds,
promptContent,
promptFilePath,
outputDir,
readOnlyPolicy,
cwd,
rounds,
durationMs,
convergenceThreshold,
onRoundStart: (round) => {
reporter.roundStarted(round, totalRoundsLabel);
},
onProgress: (event) => {
if (event.event === 'started')
reporter.toolStarted(event.toolId, event.pid);
if (event.event === 'completed')
reporter.toolCompleted(event.toolId, event.report!);
},
onConvergence: (round, ratio) => {
reporter.convergenceDetected(round, ratio, convergenceThreshold);
},
});
reporter.executionFinished();
// Flatten all tool reports for the manifest
const allReports = loopResult.rounds.flatMap((r) => r.tools);
// Write final cross-round notes
const finalNotes = synthesizeFinal(loopResult.rounds, outputDir);
safeWriteFile(resolve(outputDir, 'final-notes.md'), finalNotes);
// Build manifest
const manifest: RunManifest = {
timestamp: new Date().toISOString(),
slug,
prompt: promptLabel,
promptSource,
readOnlyPolicy,
tools: allReports,
rounds: loopResult.rounds,
totalRounds: loopResult.rounds.length,
durationMs: Date.now() - runStart,
preset: preset?.name,
};
safeWriteFile(
resolve(outputDir, 'run.json'),
JSON.stringify(manifest, null, 2),
);
reporter.printSummary(manifest, { json: opts.json });
} catch (e) {
reporter.executionFinished();
throw e;
}
},
);
}
================================================
FILE: src/commands/make-dir.ts
================================================
import type { Command } from 'commander';
import { loadConfig, loadProjectConfig, mergeConfigs } from '../core/config.js';
import { gatherContext } from '../core/context.js';
import {
buildPrompt,
generateSlug,
resolveOutputDir,
} from '../core/prompt-builder.js';
import { info } from '../ui/logger.js';
import { createOutputDir, resolvePrompt } from './_run-shared.js';
export function registerMakeDirCommand(program: Command): void {
program
.command('mkdir [prompt]')
.description(
'Create an output directory and optionally write prompt.md without dispatching (supports prompt arg, -f, or stdin)',
)
.option('-f, --file <path>', 'Use a pre-built prompt file (no wrapping)')
.option(
'--context <paths>',
'Gather context from paths (comma-separated, or "." for git diff)',
)
.option('-o, --output-dir <dir>', 'Base output directory')
.option(
'--json',
'Output metadata as JSON (outputDir, promptFilePath, slug, promptSource). promptFilePath is null when no prompt is provided.',
)
.action(
async (
promptArg: string | undefined,
opts: {
file?: string;
context?: string;
outputDir?: string;
json?: boolean;
},
) => {
const cwd = process.cwd();
const globalConfig = loadConfig();
const projectConfig = loadProjectConfig(cwd);
const config = mergeConfigs(globalConfig, projectConfig);
const hasExplicitPromptInput = Boolean(promptArg || opts.file);
let prompt = hasExplicitPromptInput
? await resolvePrompt(promptArg, opts, cwd, config)
: null;
if (hasExplicitPromptInput && !prompt) return;
// In non-TTY contexts, stdin may be an empty pipe. Treat empty stdin as
// "no prompt provided" so mkdir can still create a directory-only run.
if (!prompt && !process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const stdinContent = Buffer.concat(chunks).toString('utf-8').trim();
if (stdinContent) {
const context = opts.context
? gatherContext(
cwd,
opts.context === '.' ? [] : opts.context.split(','),
config.defaults.maxContextKb,
)
: undefined;
prompt = {
promptContent: buildPrompt(stdinContent, context),
promptSource: 'stdin' as const,
slug: generateSlug(stdinContent),
};
}
}
if (!prompt) {
const slug = generateSlug('manual-prompt');
const baseDir = opts.outputDir || config.defaults.outputDir;
const outputDir = resolveOutputDir(baseDir, slug);
if (opts.json) {
info(
JSON.stringify(
{
outputDir,
promptFilePath: null,
slug,
promptSource: 'none',
},
null,
2,
),
);
return;
}
info(`Output directory: ${outputDir}`);
info('Prompt file: (not created)');
info(`Slug: ${slug}`);
return;
}
const slug = prompt.slug || generateSlug('prompt');
const { outputDir, promptFilePath } = createOutputDir(
opts,
slug,
prompt.promptContent,
cwd,
config,
);
if (opts.json) {
info(
JSON.stringify(
{
outputDir,
promptFilePath,
slug,
promptSource: prompt.promptSource,
},
null,
2,
),
);
return;
}
info(`Output directory: ${outputDir}`);
info(`Prompt file: ${promptFilePath}`);
info(`Slug: ${slug}`);
},
);
}
================================================
FILE: src/commands/run.ts
================================================
import { resolve } from 'node:path';
import type { Command } from 'commander';
import { dispatch } from '../core/dispatcher.js';
import { safeWriteFile } from '../core/fs-utils.js';
import { generateSlug } from '../core/prompt-builder.js';
import { synthesize } from '../core/synthesis.js';
import type { RunManifest, ToolReport } from '../types.js';
import { info } from '../ui/logger.js';
import { formatDryRun } from '../ui/output.js';
import { createReporter } from '../ui/reporter.js';
import {
buildDryRunInvocations,
createOutputDir,
getPromptLabel,
resolvePrompt,
resolveReadOnlyPolicy,
resolveTools,
} from './_run-shared.js';
export function registerRunCommand(program: Command): void {
program
.command('run [prompt]')
.description('Dispatch prompt to configured AI tools in parallel')
.option('-f, --file <path>', 'Use a pre-built prompt file (no wrapping)')
.option('-t, --tools <tools>', 'Comma-separated list of tools to use')
.option(
'-g, --group <groups>',
'Comma-separated group name(s) to run (expands to tool IDs)',
)
.option(
'--context <paths>',
'Gather context from paths (comma-separated, or "." for git diff)',
)
.option('--read-only <level>', 'Read-only policy: strict, best-effort, off')
.option('--dry-run', 'Show what would be dispatched without running')
.option('--json', 'Output manifest as JSON')
.option('-o, --output-dir <dir>', 'Base output directory')
.action(
async (
promptArg: string | undefined,
opts: {
file?: string;
tools?: string;
group?: string;
context?: string;
readOnly?: string;
dryRun?: boolean;
json?: boolean;
outputDir?: string;
},
) => {
const cwd = process.cwd();
// Resolve tools
const resolved = await resolveTools(opts, cwd);
if (!resolved) return;
const { toolIds, config } = resolved;
// Resolve read-only policy
const readOnlyPolicy = resolveReadOnlyPolicy(opts.readOnly, config);
if (!readOnlyPolicy) return;
// Resolve prompt
const prompt = await resolvePrompt(promptArg, opts, cwd, config);
if (!prompt) return;
let { promptContent, promptSource, slug } = prompt;
if (!slug) slug = generateSlug('run');
// Dry run — no filesystem side effects
if (opts.dryRun) {
const baseDir = opts.outputDir || config.defaults.outputDir;
const dryOutputDir = resolve(cwd, baseDir, slug);
const invocations = buildDryRunInvocations(
config,
toolIds,
promptContent,
dryOutputDir,
readOnlyPolicy,
cwd,
);
info(formatDryRun(invocations));
return;
}
// Create output directory
const { outputDir, promptFilePath } = createOutputDir(
opts,
slug,
promptContent,
cwd,
config,
);
const promptLabel = getPromptLabel(promptArg, opts.file);
// Dispatch (single-shot)
const reporter = createReporter();
reporter.executionStarted(outputDir, toolIds);
let reports: ToolReport[];
try {
reports = await dispatch({
config,
toolIds,
promptFilePath,
promptContent,
outputDir,
readOnlyPolicy,
cwd,
onProgress: (event) => {
if (event.event === 'started')
reporter.toolStarted(event.toolId, event.pid);
if (event.event === 'completed')
reporter.toolCompleted(event.toolId, event.report!);
},
});
} finally {
reporter.executionFinished();
}
// Build manifest
const manifest: RunManifest = {
timestamp: new Date().toISOString(),
slug,
prompt: promptLabel,
promptSource,
readOnlyPolicy,
tools: reports,
};
// Write manifest + synthesis
safeWriteFile(
resolve(outputDir, 'run.json'),
JSON.stringify(manifest, null, 2),
);
const summary = synthesize(manifest, outputDir);
safeWriteFile(resolve(outputDir, 'summary.md'), summary);
// Output
reporter.printSummary(manifest, { json: opts.json });
},
);
}
================================================
FILE: src/commands/skill.ts
================================================
import type { Command } from 'commander';
import { info } from '../ui/logger.js';
export function registerSkillCommand(program: Command): void {
program
.command('skill')
.description('Print a skill/slash-command template for coding agents')
.action(async () => {
const template = `---
name: counselors
description: Get parallel second opinions from multiple AI coding agents. Use when the user wants independent reviews, architecture feedback, or a sanity check from other AI models.
---
# Counselors — Multi-Agent Review Skill
> **⏱ Long-running command.** Counselors dispatches to multiple external AI agents in parallel, each of which may take several minutes. Total wall time is commonly **10–20+ minutes**. Consider running the dispatch command (Phase 5) in the background and monitoring progress rather than blocking your main context. You can check on results periodically and proceed to Phase 6 once the process completes. Counselors is a well-behaved long-running process: it emits periodic heartbeat lines to stdout and prints each child process PID alongside the agent name, so you can verify agents are still running.
> **Note:** This is a reference skill template. Your agent system may use a different skill/command format. Adapt the structure and frontmatter below to match your system's conventions — the workflow and phases are what matter.
Fan out a prompt to multiple AI coding agents in parallel and synthesize their responses.
Use \`run\` for single-shot parallel review, or \`loop\` for iterative multi-round analysis.
Arguments: $ARGUMENTS
**If no arguments provided**, ask the user what they want reviewed.
---
## Phase 1: Context Gathering
Parse \`$ARGUMENTS\` to understand what the user wants reviewed. Then identify relevant context:
1. **Files mentioned in the prompt**: Use Glob/Grep to find files referenced by name, class, function, or keyword
2. **Recent changes**: Run \`git diff HEAD\` and \`git diff --staged\` to identify what changed
3. **Related code**: Search for key terms from the prompt to identify the most relevant files (up to 5 files)
**Important**: You do NOT need to read and inline every file. Subagents have access to the filesystem and git — they can read files and run git commands themselves. Your job is to *identify* the relevant files and reference them, not to copy their contents into the prompt. See Phase 4 for how to use \`@file\` references.
---
## Phase 2: Dispatch Mode Selection
Decide whether this request should use \`run\` or \`loop\`.
1. **Default to \`run\`** for a quick second-opinion pass.
2. **Use \`loop\`** when the user wants deeper iterative analysis, broad hunts, or multi-round convergence.
3. If using \`loop\`, choose one of two loop modes:
- **Preset loop**: use \`--preset\` for domain workflows (bug, security, state, regression, API contracts, performance)
- **Custom loop**: no preset; you write a full prompt file just like \`run\`, but dispatch with \`counselors loop\`
- **Inline loop**: pass a short prompt string directly (no \`-f\`); counselors automatically runs discovery + prompt-writing phases to expand it into a full execution prompt. Use \`--no-inline-enhancement\` to skip this and send the raw prompt as-is.
If the user says "use a preset" or names one, run:
\`\`\`bash
counselors loop --list-presets
\`\`\`
Print the output and have them pick a preset.
---
## Phase 3: Agent Selection
1. **Discover available agents and groups** by running via Bash:
\`\`\`bash
counselors ls
counselors groups ls
\`\`\`
The first command lists all configured agents with their IDs and binaries. The second lists any configured **groups** (predefined sets of tool IDs).
2. **MANDATORY: Print the full agent list and group list, then ask the user which to use.**
**Always print the full \`counselors ls\` output and \`counselors groups ls\` output as inline text** (not inside AskUserQuestion). Just show the raw output so the user sees every tool/group. Do NOT reformat or abbreviate it.
Then ask the user to pick:
**If 4 or fewer agents**: Use AskUserQuestion with \`multiSelect: true\`, one option per agent.
**If more than 4 agents**: AskUserQuestion only supports 4 options. Use these fixed options:
- Option 1: "All [N] agents" — sends to every configured agent
- Option 2-4: The first 3 individual agents by ID
- The user can always select "Other" to type a comma-separated list of agent IDs from the printed list above
If groups exist, you MAY offer group options (e.g. "Group: smart"), but you MUST expand them to the underlying tool IDs and confirm that expanded list with the user before dispatch. This avoids silently omitting or adding agents.
If the user says something like "use the smart group", you MUST look up that group in the configured groups list (\`counselors groups ls\`). If it exists, use it (via \`--group smart\` or by expanding to tool IDs) and confirm the expanded tool list before dispatch. If it does not exist, tell the user and ask them to choose again — do not guess.
3. Wait for the user's selection before proceeding.
4. **MANDATORY: Confirm the selection before continuing.** After the user picks agents, echo back the exact list you will dispatch to:
> Dispatching to: **claude-opus**, **codex-5.3-high**, **gemini-pro**
Then ask the user to confirm (e.g. "Look good?") before proceeding to Phase 4. This prevents silent tool omissions. If the user corrects the list, update your selection accordingly.
5. **Discovery tool (loop only)**: By default, the first tool in your selection runs the discovery and prompt-writing prep phases. To use a different agent for these phases, pass \`--discovery-tool <id>\`.
---
## Phase 4: Prompt Assembly
For \`run\` and custom \`loop\` (file-based) modes, assemble the review prompt content.
For preset loop mode and inline loop mode, skip this phase — counselors handles prompt generation automatically via discovery + prompt-writing phases (see Phase 5).
**Note:** Counselors automatically appends execution boilerplate (general guidelines about focusing on source dirs, skipping vendor/binary files, providing file paths for findings) to every prompt before dispatch. You do not need to include these instructions yourself.
**Subagents can read files and use git.** You do NOT need to inline file contents or diff output into the prompt. Instead, use \`@path/to/file\` references to point subagents at the relevant files. They will read the files themselves. This keeps the prompt concise and avoids bloating it with copied code.
Only inline small, critical snippets if they're essential for framing the question (e.g. a specific function signature or error message). For everything else, use \`@file\` references.
\`\`\`markdown
# Review Request
## Question
[User's original prompt/question from $ARGUMENTS]
## Context
### Files to Review
[List @path/to/file references for each relevant file found in Phase 1]
[e.g. @src/core/executor.ts, @src/adapters/claude.ts]
### Recent Changes
[Brief description of what changed. If a diff is relevant, tell the agent to run \`git diff HEAD\` themselves, or inline only a small critical snippet]
### Related Code
[@path/to/file references for related files discovered via search]
## Instructions
You are providing an independent review. Be critical and thorough.
- Read the referenced files to understand the full context
- Analyze the question in the context provided
- Identify risks, tradeoffs, and blind spots
- Suggest alternatives if you see better approaches
- Be direct and opinionated — don't hedge
- Structure your response with clear headings
\`\`\`
---
## Phase 5: Dispatch
Dispatch based on the selected mode.
### Mode A: \`run\` (single-shot)
First, create the output directory + \`prompt.md\` via counselors itself by piping your assembled prompt content:
\`\`\`bash
cat <<'PROMPT' | counselors mkdir --json
[assembled prompt content from Phase 4]
PROMPT
\`\`\`
Parse the JSON output and read \`promptFilePath\`, then dispatch with that path:
\`\`\`bash
counselors run -f <promptFilePath> --tools [comma-separated-tool-ids] --json
\`\`\`
Examples:
- \`--tools claude,codex,gemini\`
- \`--group smart\` (uses the configured group)
- \`--group smart --tools codex\` (group plus explicit tools)
### Mode B: \`loop\` + custom prompt file (iterative, no preset)
As with Mode A, first create \`prompt.md\` via \`counselors mkdir --json\`, then run:
\`\`\`bash
counselors loop -f <promptFilePath> --tools [comma-separated-tool-ids] --json
\`\`\`
Using \`-f\` skips the discovery/prompt-writing phases and sends the prompt as-is. You may add these optional flags:
- \`--rounds <N>\` — number of rounds (default: 3)
- \`--duration <time>\` — max wall time (e.g. \`30m\`, \`1h\`); when set without explicit \`--rounds\`, rounds are unlimited
- \`--convergence-threshold <ratio>\` — early stop when output word count drops below this ratio of the previous round (default: 0.3)
### Mode C: \`loop\` + inline prompt (iterative, no preset, auto-enhanced)
Pass a short prompt string directly. Counselors automatically runs two prep phases before dispatch:
1. **Discovery** — the discovery tool scans the repo to gather structural context
2. **Prompt writing** — the discovery tool expands your short input into a full execution prompt grounded in the discovered context
\`\`\`bash
counselors loop "find race conditions in the worker pool" --tools [comma-separated-tool-ids] --json
\`\`\`
To skip the automatic enhancement and send the raw prompt: add \`--no-inline-enhancement\`.
### Mode D: \`loop\` + preset (iterative, preset-driven)
For preset mode, do NOT write a full prompt file. Pass a concise focus string instead. The preset provides domain-specific instructions, and counselors runs the same discovery + prompt-writing phases as inline mode.
\`\`\`bash
counselors loop --preset <preset-name> "<focus area>" --tools [comma-separated-tool-ids] --json
\`\`\`
Example:
- \`counselors loop --preset hotspots "critical request path" --group smart --duration 20m --json\`
### Loop behavior: prior-round enrichment
In rounds 2+, counselors automatically augments the prompt with \`@file\` references to all prior round outputs. Agents receive explicit instructions to:
- Not repeat findings unless adding new evidence
- Challenge and refine prior claims
- Follow adjacent code paths discovered in earlier rounds
- Label overlapping findings as confirmed, refined, invalidated, or duplicate
### Common flags for all loop modes
| Flag | Description |
|------|-------------|
| \`--rounds <N>\` | Number of rounds (default: 3) |
| \`--duration <time>\` | Max wall time (\`30m\`, \`1h\`); unlimited rounds when set alone |
| \`--convergence-threshold <ratio>\` | Early stop ratio (default: 0.3) |
| \`--discovery-tool <id>\` | Agent for prep phases (default: first tool) |
| \`--no-inline-enhancement\` | Skip discovery/prompt-writing for inline prompts |
Use \`timeout: 600000\` (10 minutes) or higher. Counselors dispatches to the selected agents in parallel and writes results to the output directory shown in the JSON output.
**Important**: For run/custom-loop file mode, use \`-f\` so the prompt is sent as-is without wrapping. Use \`--json\` on both \`mkdir\` and dispatch commands to get structured output for parsing.
**Timing**: Sessions commonly take more than 10 minutes. Counselors prints each child process PID alongside the agent name in its progress output (e.g. \`PID 12345 claude\`). If a run seems stuck, you can verify processes are still alive with \`ps -p <PID>\` (macOS/Linux) or \`tasklist /FI "PID eq <PID>"\` (Windows).
---
## Phase 6: Read Results
1. **Parse the JSON output** from stdout — it contains the run manifest with status, duration, word count, and output file paths for each agent
2. **Read each agent's response** from the \`outputFile\` path in the manifest
3. **Check \`stderrFile\` paths** for any agent that failed or returned empty output
4. **Skip empty or error-only reports** — note which agents failed
### Loop output structure
For \`loop\` runs, the output directory contains per-round subdirectories plus cross-round notes:
\`\`\`
{outputDir}/
├── round-1/
│ ├── prompt.md # Input prompt for this round
│ ├── {tool-id}.md # Each agent's output
│ └── round-notes.md # Per-round summary (auto-generated)
├── round-2/
│ ├── prompt.md # Base prompt + @file refs to round-1 outputs
│ ├── {tool-id}.md
│ └── round-notes.md
├── final-notes.md # Cross-round summary (auto-generated)
└── run.json # Structured manifest with all rounds
\`\`\`
The manifest's \`rounds\` array contains per-round tool reports. \`totalRounds\` and \`durationMs\` are at the top level. Start with \`final-notes.md\` for a high-level summary, then drill into individual round outputs as needed.
---
## Phase 7: Synthesize and Present
Combine all agent responses into a synthesis:
\`\`\`markdown
## Counselors Review
**Agents consulted:** [list of agents that responded]
**Consensus:** [What most agents agree on — key takeaways]
**Disagreements:** [Where they differ, and reasoning behind each position]
**Key Risks:** [Risks or concerns flagged by any agent]
**Blind Spots:** [Things none of the agents addressed that seem important]
**Recommendation:** [Your synthesized recommendation based on all inputs]
---
Reports saved to: [output directory from manifest]
\`\`\`
Present this synthesis to the user. Be concise — the individual reports are saved for deep reading.
---
## Phase 8: Action (Optional)
After presenting the synthesis, ask the user what they'd like to address. Offer the top 2-3 actionable items from the synthesis as options. If the user wants to act on findings, plan the implementation before making changes.
---
## Error Handling
- **counselors not installed**: Tell the user to install it (\`npm install -g counselors\`)
- **No tools configured**: Tell the user to run \`counselors init\` or \`counselors tools add <tool>\`
- **Agent fails**: Note it in the synthesis and continue with other agents' results
- **All agents fail**: Report errors from stderr files and suggest checking \`counselors doctor\`
`;
info(template);
});
}
================================================
FILE: src/commands/tools/add.ts
================================================
import { accessSync, constants } from 'node:fs';
import { resolve } from 'node:path';
import type { Command } from 'commander';
import {
getAdapter,
getAllBuiltInAdapters,
isBuiltInTool,
resolveAdapter,
} from '../../adapters/index.js';
import { SAFE_ID_RE, sanitizeId } from '../../constants.js';
import { copyAmpSettings } from '../../core/amp-utils.js';
import { addToolToConfig, loadConfig, saveConfig } from '../../core/config.js';
import { discoverTool, findBinary } from '../../core/discovery.js';
import { executeTest } from '../../core/executor.js';
import type { ReadOnlyLevel, ToolConfig } from '../../types.js';
import { error, info, success, warn } from '../../ui/logger.js';
import { createSpinner, formatTestResults } from '../../ui/output.js';
import {
confirmAction,
confirmOverwrite,
promptInput,
promptSelect,
selectModelDetails,
} from '../../ui/prompts.js';
const CUSTOM_TOOL_VALUE = '__custom__';
/**
* Interactive wizard to pick a tool when none is specified.
* Discovers built-in tools, lets user pick one or add a custom tool.
* Returns the toolId to add.
*/
async function runAddWizard(): Promise<{ toolId: string; isCustom: boolean }> {
const spinner = createSpinner('Discovering installed tools...').start();
const adapters = getAllBuiltInAdapters();
const discovered: {
id: string;
name: string;
found: boolean;
version: string | null;
}[] = [];
for (const adapter of adapters) {
const result = discoverTool(adapter.commands);
discovered.push({
id: adapter.id,
name: adapter.displayName,
found: result.found,
version: result.version,
});
}
spinner.stop();
const choices = discovered.map((d) => ({
name: d.found
? `${d.name} (${d.id})${d.version ? ` — ${d.version}` : ''}`
: `${d.name} (${d.id}) — not installed`,
value: d.id,
disabled: !d.found ? '(not installed)' : undefined,
}));
choices.push({
name: 'Custom tool — provide a binary path',
value: CUSTOM_TOOL_VALUE,
disabled: undefined,
});
const selected = await promptSelect<string>(
'Which tool would you like to add?',
choices as any,
);
if (selected === CUSTOM_TOOL_VALUE) {
return { toolId: '', isCustom: true };
}
return { toolId: selected, isCustom: false };
}
/**
* Validate that a binary path exists and is executable.
* Resolves relative paths against cwd. Also tries `which` for bare commands.
*/
function validateBinary(input: string): string | null {
// Try as absolute/relative path first
const resolved = resolve(input);
try {
accessSync(resolved, constants.X_OK);
return resolved;
} catch {
// Fall through
}
// Try finding it in PATH
const found = findBinary(input);
if (found) return found;
return null;
}
async function addBuiltInTool(
toolId: string,
config: ReturnType<typeof loadConfig>,
nameOverride?: string,
): Promise<void> {
const adapter = getAdapter(toolId);
const discovery = discoverTool(adapter.commands);
if (!discovery.found) {
error(
`"${toolId}" binary not found. Install it from: ${adapter.installUrl}`,
);
process.exitCode = 1;
return;
}
const selectedModel = await selectModelDetails(toolId, adapter.models);
let extraFlags: string[] | undefined;
let defaultName: string;
if (selectedModel.id === '__custom__') {
const modelId = await promptInput('Model identifier:');
if (!modelId.trim()) {
error('No model identifier provided.');
process.exitCode = 1;
return;
}
const extraInput = await promptInput(
'Extra flags (optional, space-separated):',
);
const parsedExtra = extraInput.trim() ? extraInput.trim().split(/\s+/) : [];
extraFlags = [adapter.modelFlag ?? '-m', modelId.trim(), ...parsedExtra];
defaultName = nameOverride ?? `${toolId}-${sanitizeId(modelId.trim())}`;
} else {
extraFlags = selectedModel.extraFlags;
const fallbackName = selectedModel.id.startsWith(`${toolId}-`)
? selectedModel.id
: `${toolId}-${selectedModel.id}`;
defaultName = nameOverride ?? selectedModel.compoundId ?? fallbackName;
}
let name = nameOverride ?? (await promptInput('Tool name:', defaultName));
if (!SAFE_ID_RE.test(name)) {
error(
`Invalid tool name "${name}". Use only letters, numbers, dots, hyphens, and underscores.`,
);
process.exitCode = 1;
return;
}
// Check for conflicts
if (config.tools[name]) {
const overwrite = await confirmOverwrite(name);
if (!overwrite) {
// Let them pick a different name
name = await promptInput('Pick a different name:');
if (!SAFE_ID_RE.test(name)) {
error(
`Invalid tool name "${name}". Use only letters, numbers, dots, hyphens, and underscores.`,
);
process.exitCode = 1;
return;
}
if (config.tools[name]) {
error(`"${name}" also exists. Run "counselors tools add" again.`);
process.exitCode = 1;
return;
}
}
}
const toolConfig: ToolConfig = {
binary: discovery.path!,
readOnly: { level: adapter.readOnly.level },
adapter: toolId,
...(extraFlags ? { extraFlags } : {}),
};
const updated = addToolToConfig(config, name, toolConfig);
saveConfig(updated);
if (toolId === 'amp') {
copyAmpSettings();
}
success(`Added "${name}" to config.`);
// For custom models, immediately test to verify the flags work
if (selectedModel.id === '__custom__') {
info('Testing tool configuration...');
const testAdapter = resolveAdapter(name, toolConfig);
const result = await executeTest(testAdapter, toolConfig, name);
info(formatTestResults([result]));
if (!result.passed) {
warn(
'The tool was saved to your config but the test failed. You may need to check your API access or flags.',
);
}
}
}
async function collectCustomConfig(
config: ReturnType<typeof loadConfig>,
presetId?: string,
): Promise<void> {
// Get and validate binary
let binaryPath: string | null = null;
while (!binaryPath) {
const binaryInput = await promptInput('Binary path or command:');
binaryPath = validateBinary(binaryInput);
if (!binaryPath) {
warn(`"${binaryInput}" not found or not executable. Please try again.`);
}
}
// Prompt delivery — stdin or CLI argument
const useStdin = await confirmAction(
'Does this tool receive prompts via stdin?',
);
// Collect flags
info('');
info(' Counselors runs tools non-interactively. Your flags MUST include:');
info(
' 1. Headless/non-interactive mode (e.g. -p, --non-interactive, --headless)',
);
info(' 2. Model selection if needed (e.g. --model gpt-4o)');
info(' 3. Output format if needed (e.g. --output-format text)');
info('');
if (!useStdin) {
info(' Counselors will append the prompt as the last CLI argument:');
info(
' "Read the file at <path> and follow the instructions within it."',
);
} else {
info(' Counselors will pipe the prompt text to stdin.');
}
info('');
info(' Example: -p --model gpt-4o --output-format text');
info('');
let extraFlags: string[] | undefined;
const flagsInput = await promptInput('Flags (space-separated):');
if (flagsInput.trim()) {
extraFlags = flagsInput.trim().split(/\s+/);
}
const readOnlyLevel = await promptSelect<ReadOnlyLevel>(
'Read-only capability:',
[
{ name: 'Enforced — tool guarantees read-only', value: 'enforced' },
{
name: 'Best effort — tool tries but may not guarantee',
value: 'bestEffort',
},
{ name: 'None — tool has full access', value: 'none' },
],
);
// Get tool ID
const defaultId =
presetId ??
binaryPath
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') ??
'custom';
const toolId = await promptInput(
'Tool name (used in config and output filenames):',
defaultId,
);
if (!SAFE_ID_RE.test(toolId)) {
error(
`Invalid tool name "${toolId}". Use only letters, numbers, dots, hyphens, and underscores.`,
);
process.exitCode = 1;
return;
}
// Preview
info('');
info(' Tool will be invoked as:');
const previewArgs = [
...(extraFlags ?? []),
useStdin
? '< prompt.md'
: '"Read the file at <path> and follow the instructions..."',
];
info(` ${binaryPath} ${previewArgs.join(' ')}`);
info('');
if (config.tools[toolId]) {
const overwrite = await confirmOverwrite(toolId);
if (!overwrite) {
const newId = await promptInput('Pick a different name:');
if (!SAFE_ID_RE.test(newId)) {
error(
`Invalid tool name "${newId}". Use only letters, numbers, dots, hyphens, and underscores.`,
);
process.exitCode = 1;
return;
}
if (config.tools[newId]) {
error(`"${newId}" also exists. Run "counselors tools add" again.`);
process.exitCode = 1;
return;
}
const toolConfig: ToolConfig = {
binary: binaryPath,
readOnly: { level: readOnlyLevel },
...(useStdin ? { stdin: true } : {}),
extraFlags,
custom: true,
};
const updated = addToolToConfig(config, newId, toolConfig);
saveConfig(updated);
success(`Added "${newId}" to config.`);
return;
}
}
const toolConfig: ToolConfig = {
binary: binaryPath,
readOnly: { level: readOnlyLevel },
...(useStdin ? { stdin: true } : {}),
extraFlags,
custom: true,
};
const updated = addToolToConfig(config, toolId, toolConfig);
saveConfig(updated);
success(`Added "${toolId}" to config.`);
}
export function registerAddCommand(program: Command): void {
program
.command('add [tool]')
.description('Add a tool (claude, codex, gemini, amp, or custom)')
.action(async (toolId?: string) => {
const config = loadConfig();
if (!toolId) {
// Interactive wizard
const result = await runAddWizard();
if (result.isCustom) {
await collectCustomConfig(config);
} else {
await addBuiltInTool(result.toolId, config);
}
return;
}
// Direct add (original flow)
if (isBuiltInTool(toolId)) {
await addBuiltInTool(toolId, config);
} else {
await collectCustomConfig(config, toolId);
}
});
}
================================================
FILE: src/commands/tools/discover.ts
================================================
import type { Command } from 'commander';
import { getAllBuiltInAdapters } from '../../adapters/index.js';
import { discoverTool } from '../../core/discovery.js';
import { info } from '../../ui/logger.js';
import { createSpinner, formatDiscoveryResults } from '../../ui/output.js';
export function registerDiscoverCommand(program: Command): void {
program
.command('discover')
.description('Discover installed AI CLI tools')
.action(async () => {
const spinner = createSpinner('Scanning for AI CLI tools...').start();
const adapters = getAllBuiltInAdapters();
const results = [];
for (const adapter of adapters) {
const result = discoverTool(adapter.commands);
results.push({
...result,
toolId: adapter.id,
displayName: adapter.displayName,
});
}
spinner.stop();
info(formatDiscoveryResults(results));
});
}
================================================
FILE: src/commands/tools/list.ts
================================================
import type { Command } from 'commander';
import { resolveAdapter } from '../../adapters/index.js';
import { loadConfig } from '../../core/config.js';
import { info } from '../../ui/logger.js';
import { formatToolList } from '../../ui/output.js';
export function registerListCommand(program: Command): void {
program
.command('list')
.alias('ls')
.description('List configured tools')
.option('-v, --verbose', 'Show full tool configuration including flags')
.action(async (opts: { verbose?: boolean }) => {
const config = loadConfig();
const tools = Object.entries(config.tools).map(([id, t]) => {
const entry: { id: string; binary: string; args?: string[] } = {
id,
binary: t.binary,
};
if (opts.verbose) {
const adapter = resolveAdapter(id, t);
const inv = adapter.buildInvocation({
prompt: '<prompt>',
promptFilePath: '<prompt-file>',
toolId: id,
outputDir: '.',
readOnlyPolicy: t.readOnly.level,
timeout: t.timeout ?? config.defaults.timeout,
cwd: process.cwd(),
binary: t.binary,
extraFlags: t.extraFlags,
});
entry.args = inv.args;
}
return entry;
});
info(formatToolList(tools, opts.verbose));
});
}
================================================
FILE: src/commands/tools/remove.ts
================================================
import { checkbox } from '@inquirer/prompts';
import type { Command } from 'commander';
import {
loadConfig,
removeToolFromConfig,
saveConfig,
} from '../../core/config.js';
import { error, info, success } from '../../ui/logger.js';
import { confirmAction } from '../../ui/prompts.js';
export function registerRemoveCommand(program: Command): void {
program
.command('remove [tool]')
.description('Remove a configured tool')
.action(async (toolId?: string) => {
const config = loadConfig();
const toolIds = Object.keys(config.tools);
if (toolIds.length === 0) {
error('No tools configured.');
process.exitCode = 1;
return;
}
let toRemove: string[];
if (toolId) {
if (!config.tools[toolId]) {
error(`Tool "${toolId}" is not configured.`);
process.exitCode = 1;
return;
}
toRemove = [toolId];
} else {
toRemove = await checkbox({
message: 'Select tools to remove:',
choices: toolIds.map((id) => ({
name: `${id} (${config.tools[id].binary})`,
value: id,
})),
});
if (toRemove.length === 0) {
info('No tools selected.');
return;
}
}
const confirmed = await confirmAction(
toRemove.length === 1
? `Remove "${toRemove[0]}" from config?`
: `Remove ${toRemove.length} tools from config?`,
);
if (!confirmed) return;
let updated = config;
for (const id of toRemove) {
updated = removeToolFromConfig(updated, id);
}
saveConfig(updated);
success(`Removed ${toRemove.join(', ')}.`);
});
}
================================================
FILE: src/commands/tools/rename.ts
================================================
import type { Command } from 'commander';
import { SAFE_ID_RE } from '../../constants.js';
import {
loadConfig,
renameToolInConfig,
saveConfig,
} from '../../core/config.js';
import { error, success } from '../../ui/logger.js';
export function registerRenameCommand(program: Command): void {
program
.command('rename <old> <new>')
.description('Rename a configured tool')
.action(async (oldId: string, newId: string) => {
const config = loadConfig();
if (!config.tools[oldId]) {
error(`Tool "${oldId}" is not configured.`);
process.exitCode = 1;
return;
}
if (config.tools[newId]) {
error(`Tool "${newId}" already exists.`);
process.exitCode = 1;
return;
}
if (!SAFE_ID_RE.test(newId)) {
error(
`Invalid tool name "${newId}". Use only letters, numbers, dots, hyphens, and underscores.`,
);
process.exitCode = 1;
return;
}
const updated = renameToolInConfig(config, oldId, newId);
saveConfig(updated);
success(`Renamed "${oldId}" → "${newId}".`);
});
}
================================================
FILE: src/commands/tools/test.ts
================================================
import type { Command } from 'commander';
import { resolveAdapter } from '../../adapters/index.js';
import { loadConfig } from '../../core/config.js';
import { executeTest } from '../../core/executor.js';
import type { TestResult } from '../../types.js';
import { error, info } from '../../ui/logger.js';
import { createSpinner, formatTestResults } from '../../ui/output.js';
export function registerTestCommand(program: Command): void {
program
.command('test [tools...]')
.description('Test configured tools with a "reply OK" prompt')
.action(async (toolIds: string[]) => {
const config = loadConfig();
const idsToTest =
toolIds.length > 0 ? toolIds : Object.keys(config.tools);
if (idsToTest.length === 0) {
error('No tools configured. Run "counselors init" first.');
process.exitCode = 1;
return;
}
const results: TestResult[] = [];
for (const id of idsToTest) {
const toolConfig = config.tools[id];
if (!toolConfig) {
results.push({
toolId: id,
passed: false,
output: '',
error: 'Not configured',
durationMs: 0,
});
continue;
}
const spinner = createSpinner(`Testing ${id}...`).start();
const adapter = resolveAdapter(id, toolConfig);
const result = await executeTest(adapter, toolConfig, id);
spinner.stop();
results.push(result);
}
info(formatTestResults(results));
if (results.some((r) => !r.passed)) {
process.exitCode = 1;
}
});
}
================================================
FILE: src/commands/upgrade.ts
================================================
import type { Command } from 'commander';
import { VERSION } from '../constants.js';
import {
detectInstallation,
getStandaloneAssetName,
performUpgrade,
} from '../core/upgrade.js';
import { error, info, success, warn } from '../ui/logger.js';
const METHOD_LABEL: Record<string, string> = {
homebrew: 'Homebrew',
npm: 'npm (global)',
pnpm: 'pnpm (global)',
yarn: 'yarn (global)',
standalone: 'Standalone binary',
unknown: 'Unknown',
};
const INSTALL_SCRIPT =
'curl -fsSL https://github.com/aarondfrancis/counselors/raw/main/install.sh | bash';
const MANUAL_UPGRADE_OPTIONS = [
'brew upgrade counselors',
'npm install -g counselors@latest',
'pnpm add -g counselors@latest',
'yarn global add counselors@latest',
INSTALL_SCRIPT,
] as const;
const FORCE_NOTE =
'If this is a standalone install in a non-standard location, re-run with --force.';
const SKILL_TEMPLATE_HISTORY_URL =
'https://github.com/aarondfrancis/counselors/commits/main/src/commands/skill.ts';
function printSkillUpdateGuidance(): void {
info('');
info(
'The skill template might have changed. Copy and paste this into your LLM:',
);
info('');
info('The counselors CLI has an updated skill template.');
info('');
info('1. Run `counselors skill` and capture the full output.');
info(
'2. Open my existing counselors skill file and compare VERY CAREFULLY for anything that changed.',
);
info('3. Apply the updates manually; do not blindly overwrite.');
info(
'4. If you need more context, check the git history for the skill template here:',
);
info(` ${SKILL_TEMPLATE_HISTORY_URL}`);
}
function printManualUpgradeGuidance(): void {
warn('Try one of:');
for (const option of MANUAL_UPGRADE_OPTIONS) {
warn(` ${option}`);
}
}
export function registerUpgradeCommand(program: Command): void {
program
.command('upgrade')
.description('Detect install method and upgrade counselors when possible')
.option('--check', 'Only show install method/version details')
.option('--dry-run', 'Show what would be done without upgrading')
.option('--force', 'Force standalone self-upgrade outside safe locations')
.action(
async (opts: { check?: boolean; dryRun?: boolean; force?: boolean }) => {
const detection = detectInstallation();
info('');
info(
`Install method: ${METHOD_LABEL[detection.method] ?? detection.method}`,
);
info(`Running version: ${VERSION}`);
if (detection.installedVersion) {
info(`Installed version: ${detection.installedVersion}`);
}
if (detection.binaryPath) {
info(`Binary path: ${detection.binaryPath}`);
}
info('');
if (opts.check) return;
const effective =
detection.method === 'unknown' && opts.force && detection.binaryPath
? { ...detection, method: 'standalone' as const }
: detection;
if (opts.dryRun) {
info('Dry run — no changes will be made.');
if (detection.method === 'unknown' && !opts.force) {
info(
'Install method is unknown; would not run an automatic upgrade.',
);
printManualUpgradeGuidance();
warn(FORCE_NOTE);
return;
}
if (effective.method === 'standalone') {
const assetName = getStandaloneAssetName();
const targetPath =
effective.resolvedBinaryPath ??
effective.binaryPath ??
'(unknown)';
info(`Would self-upgrade standalone binary at: ${targetPath}`);
if (assetName) {
info(`Would download: ${assetName} and ${assetName}.sha256`);
}
} else {
info(`Would run: ${effective.upgradeCommand ?? '(unknown)'}`);
}
return;
}
if (detection.method === 'unknown' && !opts.force) {
error(
'Could not detect a supported install method for auto-upgrades.',
);
if (detection.binaryPath) {
warn(`Detected counselors binary at: ${detection.binaryPath}`);
}
printManualUpgradeGuidance();
warn('');
warn(FORCE_NOTE);
process.exitCode = 1;
return;
}
info(
`Upgrading via ${METHOD_LABEL[effective.method] ?? effective.method}...`,
);
const result = await performUpgrade(effective, { force: opts.force });
if (!result.ok) {
error(result.message);
process.exitCode = 1;
return;
}
success(result.message);
const refreshed = detectInstallation();
if (refreshed.installedVersion) {
info(`Detected version after upgrade: ${refreshed.installedVersion}`);
} else {
warn('Upgrade completed. Re-run "counselors --version" to verify.');
}
printSkillUpdateGuidance();
},
);
}
================================================
FILE: src/constants.ts
================================================
import { homedir } from 'node:os';
import { join } from 'node:path';
// ── XDG config ──
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
export const CONFIG_DIR = join(xdgConfig, 'counselors');
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
export const AMP_SETTINGS_FILE = join(CONFIG_DIR, 'amp-readonly-settings.json');
export const AMP_DEEP_SETTINGS_FILE = join(
CONFIG_DIR,
'amp-deep-settings.json',
);
// ── Default output ──
export const DEFAULT_OUTPUT_DIR = './agents/counselors';
// ── Timeouts (seconds) ──
export const DEFAULT_TIMEOUT = 540;
export const KILL_GRACE_PERIOD = 15_000; // ms
export const TEST_TIMEOUT = 30_000; // ms
export const DISCOVERY_TIMEOUT = 5_000; // ms
export const VERSION_TIMEOUT = 10_000; // ms
// ── Concurrency ──
export const DEFAULT_MAX_PARALLEL = 4;
// ── Context ──
export const DEFAULT_MAX_CONTEXT_KB = 50;
// ── Extended binary search paths ──
export function getExtendedSearchPaths(): string[] {
const home = homedir();
const paths: string[] = [
join(home, '.local', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
join(home, '.npm-global', 'bin'),
join(home, '.volta', 'bin'),
join(home, '.bun', 'bin'),
];
// NVM
const nvmBin = process.env.NVM_BIN;
if (nvmBin) paths.push(nvmBin);
// FNM
const fnmMultishell = process.env.FNM_MULTISHELL_PATH;
if (fnmMultishell) paths.push(join(fnmMultishell, 'bin'));
return paths;
}
// ── Model validation ──
export const MODEL_PATTERN = /^[a-zA-Z0-9._:\-/]+$/;
// ── Slug generation ──
export const MAX_SLUG_LENGTH = 40;
// ── File permissions ──
export const CONFIG_FILE_MODE = 0o600;
// ── Safe ID patterns ──
/** Sanitize a tool ID for safe use in filenames. */
export function sanitizeId(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, '_');
}
/** Regex for validating tool names (letters, numbers, dots, hyphens, underscores). */
export const SAFE_ID_RE = /^[a-zA-Z0-9._-]+$/;
/** Strip control characters from a path to prevent prompt injection.
* Preserves tab (0x09) but removes 0x00-0x08 and 0x0A-0x1F. */
export function sanitizePath(p: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — we need to match and strip control chars
return p.replace(/[\x00-\x08\x0A-\x1F]/g, '');
}
// ── Version ──
declare const __VERSION__: string;
export const VERSION =
typeof __VERSION__ !== 'undefined' ? __VERSION__ : '0.0.0-dev';
================================================
FILE: src/core/amp-utils.ts
================================================
import { mkdirSync, writeFileSync } from 'node:fs';
import ampDeepSettings from '../../assets/amp-deep-settings.json';
import ampReadonlySettings from '../../assets/amp-readonly-settings.json';
import {
AMP_DEEP_SETTINGS_FILE,
AMP_SETTINGS_FILE,
CONFIG_DIR,
CONFIG_FILE_MODE,
} from '../constants.js';
export function copyAmpSettings(): void {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(
AMP_SETTINGS_FILE,
`${JSON.stringify(ampReadonlySettings, null, 2)}\n`,
{ mode: CONFIG_FILE_MODE },
);
writeFileSync(
AMP_DEEP_SETTINGS_FILE,
`${JSON.stringify(ampDeepSettings, null, 2)}\n`,
{ mode: CONFIG_FILE_MODE },
);
}
================================================
FILE: src/core/boilerplate.ts
================================================
/**
* Universal execution boilerplate appended to every generated prompt before dispatch.
*/
export function getExecutionBoilerplate(): string {
return `## General Guidelines
- Focus on source directories, not vendor/node_modules/generated/dependency dirs
- Skip binary files, lockfiles, bundled output, compiled assets
- Provide thorough analysis with clear headings
- Include file paths and function names for each finding
- Focus on actionable findings, not trivial style issues`;
}
================================================
FILE: src/core/cleanup.ts
================================================
import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
export type CleanupCandidate = {
name: string;
path: string;
mtimeMs: number;
};
const MS = 1;
const SECOND = 1000 * MS;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
/**
* Parse a human-friendly duration into milliseconds.
*
* Supported:
* - "1d", "12h", "30m", "45s", "500ms", "2w"
* - A bare integer (e.g. "7") is interpreted as days for convenience.
*/
export function parseDurationMs(input: string): number {
const raw = input.trim();
if (!raw) throw new Error('Duration cannot be empty.');
if (/^\d+$/.test(raw)) {
const days = Number(raw);
if (!Number.isFinite(days) || days < 0) {
throw new Error(`Invalid duration "${input}".`);
}
return days * DAY;
}
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/i.exec(raw);
if (!m) {
throw new Error(
`Invalid duration "${input}". Use e.g. "1d", "12h", "30m", "45s".`,
);
}
const value = Number(m[1]);
const unit = m[2].toLowerCase();
if (!Number.isFinite(value) || value < 0) {
throw new Error(`Invalid duration "${input}".`);
}
const multipliers: Record<string, number> = {
ms: MS,
s: SECOND,
m: MINUTE,
h: HOUR,
d: DAY,
w: WEEK,
};
const mult = multipliers[unit];
if (!mult) throw new Error(`Invalid duration unit in "${input}".`);
return value * mult;
}
export function scanCleanupCandidates(
baseDir: string,
cutoffMs: number,
): {
baseExists: boolean;
candidates: CleanupCandidate[];
skippedSymlinks: string[];
} {
if (!existsSync(baseDir)) {
return { baseExists: false, candidates: [], skippedSymlinks: [] };
}
const skippedSymlinks: string[] = [];
const candidates: CleanupCandidate[] = [];
for (const name of readdirSync(baseDir)) {
const fullPath = join(baseDir, name);
let st: ReturnType<typeof lstatSync>;
try {
st = lstatSync(fullPath);
} catch {
continue;
}
if (st.isSymbolicLink()) {
skippedSymlinks.push(name);
continue;
}
if (!st.isDirectory()) continue;
if (st.mtimeMs < cutoffMs) {
candidates.push({ name, path: fullPath, mtimeMs: st.mtimeMs });
}
}
candidates.sort((a, b) => a.mtimeMs - b.mtimeMs);
return { baseExists: true, candidates, skippedSymlinks };
}
export function deleteCleanupCandidates(candidates: CleanupCandidate[]): {
deleted: string[];
failed: { path: string; error: string }[];
} {
const deleted: string[] = [];
const failed: { path: string; error: string }[] = [];
for (const c of candidates) {
try {
rmSync(c.path, { recursive: true, force: true });
deleted.push(c.path);
} catch (e) {
failed.push({
path: c.path,
error: e instanceof Error ? e.message : String(e),
});
}
}
return { deleted, failed };
}
================================================
FILE: src/core/config.ts
================================================
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { z } from 'zod';
import { CONFIG_FILE, CONFIG_FILE_MODE } from '../constants.js';
import {
type Config,
ConfigSchema,
type ReadOnlyLevel,
type ToolConfig,
} from '../types.js';
import { safeWriteFile } from './fs-utils.js';
/** Strictness ranking: higher = stricter. */
const READ_ONLY_STRICTNESS: Record<ReadOnlyLevel, number> = {
none: 0,
bestEffort: 1,
enforced: 2,
};
/** Return the stricter of two read-only levels. */
function stricterReadOnly(a: ReadOnlyLevel, b: ReadOnlyLevel): ReadOnlyLevel {
return READ_ONLY_STRICTNESS[a] >= READ_ONLY_STRICTNESS[b] ? a : b;
}
const DEFAULT_CONFIG: Config = {
version: 1,
defaults: {
timeout: 900,
outputDir: './agents/counselors',
readOnly: 'bestEffort',
maxContextKb: 50,
maxParallel: 4,
},
tools: {},
groups: {},
};
export function loadConfig(globalPath?: string): Config {
const path = globalPath ?? CONFIG_FILE;
if (!existsSync(path)) return { ...DEFAULT_CONFIG };
let raw: unknown;
try {
raw = JSON.parse(readFileSync(path, 'utf-8'));
} catch (e) {
throw new Error(
`Invalid JSON in ${path}: ${e instanceof Error ? e.message : e}`,
);
}
return ConfigSchema.parse(raw);
}
/** Schema for project config — only defaults are allowed, not tools.
* Uses .optional() (not .default()) so missing fields stay absent
* and don't clobber global config during merge. */
const ProjectConfigSchema = z.object({
defaults: z
.object({
timeout: z.number().optional(),
outputDir: z.string().optional(),
readOnly: z.enum(['enforced', 'bestEffort', 'none']).optional(),
maxContextKb: z.number().optional(),
maxParallel: z.number().optional(),
})
.
gitextract_xut__s4g/ ├── .gitattributes ├── .github/ │ ├── scripts/ │ │ └── parse-changelog.sh │ └── workflows/ │ ├── ci.yml │ ├── release-binaries.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── assets/ │ ├── amp-deep-settings.json │ ├── amp-readonly-settings.json │ └── presets/ │ ├── bughunt.yml │ ├── contracts.yml │ ├── hotspots.yml │ ├── invariants.yml │ ├── regression.yml │ └── security.yml ├── biome.json ├── install.sh ├── package.json ├── scripts/ │ └── build-binaries.ts ├── src/ │ ├── adapters/ │ │ ├── amp.ts │ │ ├── base.ts │ │ ├── claude.ts │ │ ├── codex.ts │ │ ├── custom.ts │ │ ├── gemini.ts │ │ └── index.ts │ ├── cli.ts │ ├── commands/ │ │ ├── _run-shared.ts │ │ ├── agent.ts │ │ ├── cleanup.ts │ │ ├── config.ts │ │ ├── doctor.ts │ │ ├── groups/ │ │ │ ├── add.ts │ │ │ ├── list.ts │ │ │ └── remove.ts │ │ ├── init.ts │ │ ├── loop.ts │ │ ├── make-dir.ts │ │ ├── run.ts │ │ ├── skill.ts │ │ ├── tools/ │ │ │ ├── add.ts │ │ │ ├── discover.ts │ │ │ ├── list.ts │ │ │ ├── remove.ts │ │ │ ├── rename.ts │ │ │ └── test.ts │ │ └── upgrade.ts │ ├── constants.ts │ ├── core/ │ │ ├── amp-utils.ts │ │ ├── boilerplate.ts │ │ ├── cleanup.ts │ │ ├── config.ts │ │ ├── context.ts │ │ ├── discovery.ts │ │ ├── dispatcher.ts │ │ ├── executor.ts │ │ ├── fs-utils.ts │ │ ├── loop.ts │ │ ├── prompt-builder.ts │ │ ├── prompt-writer.ts │ │ ├── repo-discovery.ts │ │ ├── synthesis.ts │ │ ├── text-utils.ts │ │ └── upgrade.ts │ ├── presets/ │ │ ├── index.ts │ │ └── types.ts │ ├── types.ts │ └── ui/ │ ├── agent-reporter.ts │ ├── logger.ts │ ├── output.ts │ ├── prompts.ts │ ├── reporter.ts │ └── terminal-reporter.ts ├── tests/ │ ├── fixtures/ │ │ ├── bin/ │ │ │ ├── fake-amp │ │ │ ├── fake-claude │ │ │ └── fake-codex │ │ └── configs/ │ │ └── valid.json │ ├── integration/ │ │ └── cli.test.ts │ └── unit/ │ ├── adapters/ │ │ ├── amp.test.ts │ │ ├── claude.test.ts │ │ ├── codex.test.ts │ │ ├── custom.test.ts │ │ ├── gemini.test.ts │ │ └── resolve.test.ts │ ├── agent-reporter.test.ts │ ├── amp-utils.test.ts │ ├── cleanup.test.ts │ ├── config.test.ts │ ├── constants.test.ts │ ├── context.test.ts │ ├── discovery.test.ts │ ├── dispatcher.test.ts │ ├── execute-test.test.ts │ ├── executor.test.ts │ ├── fs-utils.test.ts │ ├── logger.test.ts │ ├── loop-command.test.ts │ ├── loop.test.ts │ ├── output.test.ts │ ├── presets.test.ts │ ├── prompt-builder.test.ts │ ├── prompt-writer.test.ts │ ├── prompts.test.ts │ ├── repo-discovery.test.ts │ ├── reporter.test.ts │ ├── run-shared.test.ts │ ├── synthesis.test.ts │ ├── terminal-reporter.test.ts │ ├── text-utils.test.ts │ ├── tools-add-custom-model.test.ts │ ├── upgrade-exec.test.ts │ ├── upgrade-standalone.test.ts │ └── upgrade.test.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts
SYMBOL INDEX (346 symbols across 66 files)
FILE: src/adapters/amp.ts
function isAmpDeepMode (line 14) | function isAmpDeepMode(flags?: string[]): boolean {
class AmpAdapter (line 20) | class AmpAdapter extends BaseAdapter {
method getEffectiveReadOnlyLevel (line 40) | getEffectiveReadOnlyLevel(toolConfig: ToolConfig): ReadOnlyLevel {
method buildInvocation (line 46) | buildInvocation(req: RunRequest): Invocation {
method parseResult (line 80) | parseResult(result: ExecResult): Partial<ToolReport> {
function parseAmpUsage (line 90) | function parseAmpUsage(output: string): {
function computeAmpCost (line 108) | function computeAmpCost(
FILE: src/adapters/base.ts
method getEffectiveReadOnlyLevel (line 23) | getEffectiveReadOnlyLevel(_toolConfig: ToolConfig): ReadOnlyLevel {
method parseResult (line 27) | parseResult(result: ExecResult): Partial<ToolReport> {
FILE: src/adapters/claude.ts
class ClaudeAdapter (line 5) | class ClaudeAdapter extends BaseAdapter {
method buildInvocation (line 31) | buildInvocation(req: RunRequest): Invocation {
FILE: src/adapters/codex.ts
class CodexAdapter (line 5) | class CodexAdapter extends BaseAdapter {
method buildInvocation (line 38) | buildInvocation(req: RunRequest): Invocation {
FILE: src/adapters/custom.ts
class CustomAdapter (line 10) | class CustomAdapter extends BaseAdapter {
method constructor (line 20) | constructor(id: string, config: ToolConfig) {
method buildInvocation (line 29) | buildInvocation(req: RunRequest): Invocation {
FILE: src/adapters/gemini.ts
class GeminiAdapter (line 4) | class GeminiAdapter extends BaseAdapter {
method buildInvocation (line 34) | buildInvocation(req: RunRequest): Invocation {
FILE: src/adapters/index.ts
function getAdapter (line 15) | function getAdapter(id: string, config?: ToolConfig): ToolAdapter {
function getAllBuiltInAdapters (line 27) | function getAllBuiltInAdapters(): ToolAdapter[] {
function isBuiltInTool (line 31) | function isBuiltInTool(id: string): boolean {
function getBuiltInToolIds (line 35) | function getBuiltInToolIds(): string[] {
function resolveAdapter (line 39) | function resolveAdapter(
FILE: src/commands/_run-shared.ts
function expandDuplicateToolIds (line 24) | function expandDuplicateToolIds(
type ToolOpts (line 70) | interface ToolOpts {
type ResolvedTools (line 76) | interface ResolvedTools {
function resolveTools (line 81) | async function resolveTools(
constant READ_ONLY_MAP (line 191) | const READ_ONLY_MAP: [cli: string, internal: ReadOnlyLevel][] = [
function resolveReadOnlyPolicy (line 199) | function resolveReadOnlyPolicy(
type PromptOpts (line 220) | interface PromptOpts {
type ResolvedPrompt (line 226) | interface ResolvedPrompt {
function resolvePrompt (line 232) | async function resolvePrompt(
type OutputDirResult (line 320) | interface OutputDirResult {
function createOutputDir (line 325) | function createOutputDir(
function buildDryRunInvocations (line 358) | function buildDryRunInvocations(
function getPromptLabel (line 391) | function getPromptLabel(
FILE: src/commands/agent.ts
function registerAgentCommand (line 4) | function registerAgentCommand(program: Command): void {
FILE: src/commands/cleanup.ts
function formatDurationForHumans (line 12) | function formatDurationForHumans(ms: number): string {
function registerCleanupCommand (line 23) | function registerCleanupCommand(program: Command): void {
FILE: src/commands/config.ts
function registerConfigCommand (line 6) | function registerConfigCommand(program: Command): void {
FILE: src/commands/doctor.ts
function registerDoctorCommand (line 18) | function registerDoctorCommand(program: Command): void {
FILE: src/commands/groups/add.ts
function parseToolList (line 6) | function parseToolList(value: string | undefined): string[] {
function registerGroupAddCommand (line 14) | function registerGroupAddCommand(program: Command): void {
FILE: src/commands/groups/list.ts
function formatGroupList (line 5) | function formatGroupList(groups: Record<string, string[]>): string {
function registerGroupListCommand (line 22) | function registerGroupListCommand(program: Command): void {
FILE: src/commands/groups/remove.ts
function registerGroupRemoveCommand (line 9) | function registerGroupRemoveCommand(program: Command): void {
FILE: src/commands/init.ts
function buildToolConfig (line 16) | function buildToolConfig(
function compoundId (line 28) | function compoundId(adapterId: string, modelId: string): string {
function registerInitCommand (line 33) | function registerInitCommand(program: Command): void {
FILE: src/commands/loop.ts
constant INLINE_PROMPT_ENHANCEMENT_DESCRIPTION (line 26) | const INLINE_PROMPT_ENHANCEMENT_DESCRIPTION = `You are preparing a multi...
function withExecutionBoilerplate (line 28) | function withExecutionBoilerplate(promptContent: string): string {
function registerLoopCommand (line 35) | function registerLoopCommand(program: Command): void {
FILE: src/commands/make-dir.ts
function registerMakeDirCommand (line 12) | function registerMakeDirCommand(program: Command): void {
FILE: src/commands/run.ts
function registerRunCommand (line 20) | function registerRunCommand(program: Command): void {
FILE: src/commands/skill.ts
function registerSkillCommand (line 4) | function registerSkillCommand(program: Command): void {
FILE: src/commands/tools/add.ts
constant CUSTOM_TOOL_VALUE (line 26) | const CUSTOM_TOOL_VALUE = '__custom__';
function runAddWizard (line 33) | async function runAddWizard(): Promise<{ toolId: string; isCustom: boole...
function validateBinary (line 86) | function validateBinary(input: string): string | null {
function addBuiltInTool (line 103) | async function addBuiltInTool(
function collectCustomConfig (line 206) | async function collectCustomConfig(
function registerAddCommand (line 339) | function registerAddCommand(program: Command): void {
FILE: src/commands/tools/discover.ts
function registerDiscoverCommand (line 7) | function registerDiscoverCommand(program: Command): void {
FILE: src/commands/tools/list.ts
function registerListCommand (line 7) | function registerListCommand(program: Command): void {
FILE: src/commands/tools/remove.ts
function registerRemoveCommand (line 11) | function registerRemoveCommand(program: Command): void {
FILE: src/commands/tools/rename.ts
function registerRenameCommand (line 10) | function registerRenameCommand(program: Command): void {
FILE: src/commands/tools/test.ts
function registerTestCommand (line 9) | function registerTestCommand(program: Command): void {
FILE: src/commands/upgrade.ts
constant METHOD_LABEL (line 10) | const METHOD_LABEL: Record<string, string> = {
constant INSTALL_SCRIPT (line 19) | const INSTALL_SCRIPT =
constant MANUAL_UPGRADE_OPTIONS (line 21) | const MANUAL_UPGRADE_OPTIONS = [
constant FORCE_NOTE (line 28) | const FORCE_NOTE =
constant SKILL_TEMPLATE_HISTORY_URL (line 30) | const SKILL_TEMPLATE_HISTORY_URL =
function printSkillUpdateGuidance (line 33) | function printSkillUpdateGuidance(): void {
function printManualUpgradeGuidance (line 52) | function printManualUpgradeGuidance(): void {
function registerUpgradeCommand (line 59) | function registerUpgradeCommand(program: Command): void {
FILE: src/constants.ts
constant CONFIG_DIR (line 7) | const CONFIG_DIR = join(xdgConfig, 'counselors');
constant CONFIG_FILE (line 8) | const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
constant AMP_SETTINGS_FILE (line 9) | const AMP_SETTINGS_FILE = join(CONFIG_DIR, 'amp-readonly-settings.json');
constant AMP_DEEP_SETTINGS_FILE (line 10) | const AMP_DEEP_SETTINGS_FILE = join(
constant DEFAULT_OUTPUT_DIR (line 17) | const DEFAULT_OUTPUT_DIR = './agents/counselors';
constant DEFAULT_TIMEOUT (line 21) | const DEFAULT_TIMEOUT = 540;
constant KILL_GRACE_PERIOD (line 22) | const KILL_GRACE_PERIOD = 15_000;
constant TEST_TIMEOUT (line 23) | const TEST_TIMEOUT = 30_000;
constant DISCOVERY_TIMEOUT (line 24) | const DISCOVERY_TIMEOUT = 5_000;
constant VERSION_TIMEOUT (line 25) | const VERSION_TIMEOUT = 10_000;
constant DEFAULT_MAX_PARALLEL (line 29) | const DEFAULT_MAX_PARALLEL = 4;
constant DEFAULT_MAX_CONTEXT_KB (line 33) | const DEFAULT_MAX_CONTEXT_KB = 50;
function getExtendedSearchPaths (line 37) | function getExtendedSearchPaths(): string[] {
constant MODEL_PATTERN (line 61) | const MODEL_PATTERN = /^[a-zA-Z0-9._:\-/]+$/;
constant MAX_SLUG_LENGTH (line 65) | const MAX_SLUG_LENGTH = 40;
constant CONFIG_FILE_MODE (line 69) | const CONFIG_FILE_MODE = 0o600;
function sanitizeId (line 74) | function sanitizeId(id: string): string {
constant SAFE_ID_RE (line 79) | const SAFE_ID_RE = /^[a-zA-Z0-9._-]+$/;
function sanitizePath (line 83) | function sanitizePath(p: string): string {
constant VERSION (line 91) | const VERSION =
FILE: src/core/amp-utils.ts
function copyAmpSettings (line 11) | function copyAmpSettings(): void {
FILE: src/core/boilerplate.ts
function getExecutionBoilerplate (line 4) | function getExecutionBoilerplate(): string {
FILE: src/core/cleanup.ts
type CleanupCandidate (line 4) | type CleanupCandidate = {
constant SECOND (line 11) | const SECOND = 1000 * MS;
constant MINUTE (line 12) | const MINUTE = 60 * SECOND;
constant HOUR (line 13) | const HOUR = 60 * MINUTE;
constant DAY (line 14) | const DAY = 24 * HOUR;
constant WEEK (line 15) | const WEEK = 7 * DAY;
function parseDurationMs (line 24) | function parseDurationMs(input: string): number {
function scanCleanupCandidates (line 63) | function scanCleanupCandidates(
function deleteCleanupCandidates (line 103) | function deleteCleanupCandidates(candidates: CleanupCandidate[]): {
FILE: src/core/config.ts
constant READ_ONLY_STRICTNESS (line 14) | const READ_ONLY_STRICTNESS: Record<ReadOnlyLevel, number> = {
function stricterReadOnly (line 21) | function stricterReadOnly(a: ReadOnlyLevel, b: ReadOnlyLevel): ReadOnlyL...
constant DEFAULT_CONFIG (line 25) | const DEFAULT_CONFIG: Config = {
function loadConfig (line 38) | function loadConfig(globalPath?: string): Config {
type ProjectConfig (line 68) | type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
function loadProjectConfig (line 70) | function loadProjectConfig(cwd: string): ProjectConfig | null {
function mergeConfigs (line 85) | function mergeConfigs(
function saveConfig (line 117) | function saveConfig(config: Config, path?: string): void {
function addToolToConfig (line 125) | function addToolToConfig(
function removeToolFromConfig (line 136) | function removeToolFromConfig(config: Config, id: string): Config {
function renameToolInConfig (line 150) | function renameToolInConfig(
function getConfiguredTools (line 169) | function getConfiguredTools(config: Config): string[] {
function addGroupToConfig (line 173) | function addGroupToConfig(
function removeGroupFromConfig (line 184) | function removeGroupFromConfig(config: Config, name: string): Config {
function getConfiguredGroups (line 190) | function getConfiguredGroups(config: Config): string[] {
FILE: src/core/context.ts
function safeFence (line 8) | function safeFence(content: string): string {
function truncateUtf8 (line 15) | function truncateUtf8(str: string, maxBytes: number): string {
function gatherContext (line 40) | function gatherContext(
function getGitDiff (line 114) | function getGitDiff(cwd: string): string | null {
FILE: src/core/discovery.ts
constant DEFAULT_WINDOWS_EXTENSIONS (line 20) | const DEFAULT_WINDOWS_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd'];
function getWindowsExecutableExtensions (line 23) | function getWindowsExecutableExtensions(
function buildBinaryCandidatesForScan (line 41) | function buildBinaryCandidatesForScan(
function findBinary (line 71) | function findBinary(command: string): string | null {
function getPathEntries (line 114) | function getPathEntries(pathEnv = process.env.PATH): string[] {
function getNvmPaths (line 126) | function getNvmPaths(): string[] {
function getFnmPaths (line 164) | function getFnmPaths(): string[] {
function getBinaryVersion (line 217) | function getBinaryVersion(binaryPath: string): string | null {
function discoverTool (line 239) | function discoverTool(
FILE: src/core/dispatcher.ts
type ProgressEvent (line 19) | interface ProgressEvent {
type DispatchOptions (line 26) | interface DispatchOptions {
function dispatch (line 40) | async function dispatch(
FILE: src/core/executor.ts
constant MAX_OUTPUT_BYTES (line 17) | const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
constant WINDOWS_TASKKILL_TIMEOUT_MS (line 18) | const WINDOWS_TASKKILL_TIMEOUT_MS = 1500;
function killProcessGroup (line 23) | function killProcessGroup(child: ChildProcess, signal: NodeJS.Signals): ...
function clearSigintExit (line 72) | function clearSigintExit(): void {
constant ENV_DENYLIST (line 79) | const ENV_DENYLIST = new Set([
constant ENV_ALLOWLIST (line 91) | const ENV_ALLOWLIST = [
function buildSafeEnv (line 136) | function buildSafeEnv(extra?: Record<string, string>): Record<string, st...
function normalizeWindowsPathForComparison (line 151) | function normalizeWindowsPathForComparison(path: string): string {
function execute (line 165) | function execute(
function captureAmpUsage (line 314) | async function captureAmpUsage(): Promise<string | null> {
function computeAmpCostFromSnapshots (line 330) | function computeAmpCostFromSnapshots(
function executeTest (line 346) | async function executeTest(
FILE: src/core/fs-utils.ts
function safeWriteFile (line 8) | function safeWriteFile(
FILE: src/core/loop.ts
type LoopOptions (line 10) | interface LoopOptions {
type LoopResult (line 28) | interface LoopResult {
constant MAX_PRIOR_REPORT_REFS (line 33) | const MAX_PRIOR_REPORT_REFS = 8;
function totalWordCount (line 36) | function totalWordCount(round: RoundManifest): number {
function runLoop (line 43) | async function runLoop(options: LoopOptions): Promise<LoopResult> {
function collectPriorOutputPaths (line 181) | function collectPriorOutputPaths(
function augmentPromptWithPriorOutputs (line 208) | function augmentPromptWithPriorOutputs(
FILE: src/core/prompt-builder.ts
function secondsTimestamp (line 5) | function secondsTimestamp(): number {
function slugify (line 9) | function slugify(text: string): string {
function generateSlug (line 25) | function generateSlug(text: string): string {
function generateSlugFromFile (line 33) | function generateSlugFromFile(filePath: string): string {
function resolveOutputDir (line 46) | function resolveOutputDir(baseDir: string, slug: string): string {
function buildPrompt (line 65) | function buildPrompt(question: string, context?: string): string {
FILE: src/core/prompt-writer.ts
type PromptWriterOptions (line 10) | interface PromptWriterOptions {
type PromptWriterResult (line 20) | interface PromptWriterResult {
function writePrompt (line 29) | async function writePrompt(
FILE: src/core/repo-discovery.ts
type RepoDiscoveryOptions (line 10) | interface RepoDiscoveryOptions {
type RepoDiscoveryResult (line 18) | interface RepoDiscoveryResult {
function runRepoDiscovery (line 27) | async function runRepoDiscovery(
FILE: src/core/synthesis.ts
function synthesize (line 11) | function synthesize(manifest: RunManifest, outputDir: string): string {
function synthesizeFinal (line 84) | function synthesizeFinal(
function extractHeadings (line 130) | function extractHeadings(outputDir: string, report: ToolReport): string[] {
FILE: src/core/text-utils.ts
function countWords (line 3) | function countWords(text: string): number {
function buildToolReport (line 7) | function buildToolReport(
FILE: src/core/upgrade.ts
type InstallMethod (line 21) | type InstallMethod =
type InstallDetection (line 29) | interface InstallDetection {
type DetectInstallMethodInput (line 44) | interface DetectInstallMethodInput {
type CaptureResult (line 55) | interface CaptureResult {
type RunResult (line 62) | interface RunResult {
type UpgradeDeps (line 68) | interface UpgradeDeps {
type PerformUpgradeOptions (line 77) | interface PerformUpgradeOptions {
type UpgradeOutcome (line 81) | interface UpgradeOutcome {
type GithubReleaseAsset (line 87) | interface GithubReleaseAsset {
type GithubLatestRelease (line 92) | interface GithubLatestRelease {
type StandaloneUpgradeResult (line 97) | interface StandaloneUpgradeResult {
function parseBrewVersion (line 105) | function parseBrewVersion(output: string): string | null {
function parseNpmLsVersion (line 112) | function parseNpmLsVersion(output: string): string | null {
function getStandaloneAssetName (line 125) | function getStandaloneAssetName(
function getSafeStandaloneRoots (line 150) | function getSafeStandaloneRoots(homeDir: string): string[] {
function isSafeStandalonePath (line 158) | function isSafeStandalonePath(path: string | null, homeDir: string): boo...
function isLikelyPnpmInstall (line 168) | function isLikelyPnpmInstall(
function isLikelyYarnGlobalInstall (line 190) | function isLikelyYarnGlobalInstall(
function detectInstallMethod (line 210) | function detectInstallMethod(
function detectInstallation (line 271) | function detectInstallation(deps: UpgradeDeps = {}): InstallDetection {
function performUpgrade (line 356) | async function performUpgrade(
function upgradeStandaloneBinary (line 453) | async function upgradeStandaloneBinary(
function runManagerUpgrade (line 609) | function runManagerUpgrade(
function resolveStandaloneTargetPath (line 631) | function resolveStandaloneTargetPath(binaryPath: string): string {
function extractVersion (line 643) | function extractVersion(value: string | null): string | null {
function stripLeadingV (line 651) | function stripLeadingV(version: string): string {
function safeRealPath (line 655) | function safeRealPath(
function normalizePath (line 666) | function normalizePath(path: string | null): string | null {
function defaultCaptureCommand (line 671) | function defaultCaptureCommand(cmd: string, args: string[]): CaptureResu...
function defaultRunCommand (line 700) | function defaultRunCommand(cmd: string, args: string[]): RunResult {
function readNpmGlobalVersion (line 718) | function readNpmGlobalVersion(npmPrefix: string): string | null {
function readNpmGlobalVersionFromNpmLs (line 743) | function readNpmGlobalVersionFromNpmLs(
function parseSha256File (line 758) | function parseSha256File(text: string, filename: string): string | null {
function sha256 (line 790) | function sha256(bytes: Buffer): string {
function hashesEqual (line 794) | function hashesEqual(a: string, b: string): boolean {
function uniqueBackupPath (line 798) | function uniqueBackupPath(targetPath: string): string {
function ensureWritable (line 804) | function ensureWritable(dir: string): void {
function validateExecutable (line 815) | function validateExecutable(path: string): void {
function toText (line 829) | function toText(value: unknown): string {
FILE: src/presets/index.ts
function findPackageRoot (line 8) | function findPackageRoot(): string {
function builtinPresetsDir (line 19) | function builtinPresetsDir(): string {
function isFilePath (line 23) | function isFilePath(input: string): boolean {
function parsePresetYaml (line 32) | function parsePresetYaml(
function resolvePreset (line 56) | function resolvePreset(input: string): PresetDefinition {
function getPresetNames (line 78) | function getPresetNames(): string[] {
FILE: src/presets/types.ts
type PresetDefinition (line 4) | interface PresetDefinition {
FILE: src/types.ts
type ReadOnlyLevel (line 5) | type ReadOnlyLevel = 'enforced' | 'bestEffort' | 'none';
type ToolConfig (line 22) | type ToolConfig = z.infer<typeof ToolConfigSchema>;
type Config (line 41) | type Config = z.infer<typeof ConfigSchema>;
type RunRequest (line 45) | interface RunRequest {
type Invocation (line 57) | interface Invocation {
type ExecResult (line 65) | interface ExecResult {
type CostInfo (line 73) | interface CostInfo {
type ToolReport (line 83) | interface ToolReport {
type ToolAdapter (line 97) | interface ToolAdapter {
type DiscoveryResult (line 120) | interface DiscoveryResult {
type DoctorCheck (line 129) | interface DoctorCheck {
type TestResult (line 137) | interface TestResult {
type RoundManifest (line 148) | interface RoundManifest {
type RunManifest (line 156) | interface RunManifest {
FILE: src/ui/agent-reporter.ts
constant HEARTBEAT_INTERVAL (line 5) | const HEARTBEAT_INTERVAL = 60_000;
function formatDuration (line 7) | function formatDuration(ms: number): string {
type ToolState (line 15) | interface ToolState {
class AgentReporter (line 25) | class AgentReporter implements Reporter {
method discoveryStarted (line 35) | discoveryStarted(toolId: string): void {
method discoveryCompleted (line 39) | discoveryCompleted(_toolId: string): void {
method promptWritingStarted (line 43) | promptWritingStarted(toolId: string): void {
method promptWritingCompleted (line 47) | promptWritingCompleted(_toolId: string): void {
method phasePidReported (line 51) | phasePidReported(toolId: string, pid: number): void {
method executionStarted (line 57) | executionStarted(
method toolStarted (line 81) | toolStarted(toolId: string, pid?: number): void {
method toolCompleted (line 91) | toolCompleted(toolId: string, report: ToolReport): void {
method executionFinished (line 111) | executionFinished(): void {
method roundStarted (line 117) | roundStarted(round: number, totalRounds: number | null): void {
method roundCompleted (line 137) | roundCompleted(_round: number): void {
method convergenceDetected (line 141) | convergenceDetected(round: number, ratio: number, threshold: number): ...
method printSummary (line 149) | printSummary(manifest: RunManifest, opts: { json?: boolean }): void {
method stderr (line 159) | private stderr(line: string): void {
method startHeartbeat (line 163) | private startHeartbeat(): void {
method stopHeartbeat (line 179) | private stopHeartbeat(): void {
FILE: src/ui/logger.ts
function isDebug (line 1) | function isDebug(): boolean {
function debug (line 5) | function debug(msg: string): void {
function warn (line 11) | function warn(msg: string): void {
function error (line 15) | function error(msg: string): void {
function info (line 19) | function info(msg: string): void {
function success (line 23) | function success(msg: string): void {
FILE: src/ui/output.ts
function clickablePath (line 10) | function clickablePath(p: string): string {
function createSpinner (line 14) | function createSpinner(text: string): Ora {
function formatDiscoveryResults (line 18) | function formatDiscoveryResults(
function formatDoctorResults (line 36) | function formatDoctorResults(checks: DoctorCheck[]): string {
type ToolListEntry (line 56) | interface ToolListEntry {
function formatToolList (line 62) | function formatToolList(
function formatTestResults (line 109) | function formatTestResults(results: TestResult[]): string {
function formatRunSummary (line 128) | function formatRunSummary(manifest: RunManifest): string {
function formatMultiRoundSummary (line 156) | function formatMultiRoundSummary(manifest: RunManifest): string {
function formatDryRun (line 185) | function formatDryRun(
FILE: src/ui/prompts.ts
function selectModelDetails (line 3) | async function selectModelDetails(
function selectModels (line 37) | async function selectModels(
function selectTools (line 59) | async function selectTools(
function confirmOverwrite (line 75) | async function confirmOverwrite(toolId: string): Promise<boolean> {
function selectRunTools (line 82) | async function selectRunTools(tools: string[]): Promise<string[]> {
function confirmAction (line 95) | async function confirmAction(message: string): Promise<boolean> {
function promptInput (line 99) | async function promptInput(
function promptSelect (line 106) | async function promptSelect<T extends string>(
FILE: src/ui/reporter.ts
type Reporter (line 5) | interface Reporter {
class NullReporter (line 32) | class NullReporter implements Reporter {
method discoveryStarted (line 33) | discoveryStarted(): void {}
method discoveryCompleted (line 34) | discoveryCompleted(): void {}
method promptWritingStarted (line 35) | promptWritingStarted(): void {}
method promptWritingCompleted (line 36) | promptWritingCompleted(): void {}
method phasePidReported (line 37) | phasePidReported(): void {}
method executionStarted (line 38) | executionStarted(): void {}
method toolStarted (line 39) | toolStarted(): void {}
method toolCompleted (line 40) | toolCompleted(): void {}
method executionFinished (line 41) | executionFinished(): void {}
method roundStarted (line 42) | roundStarted(): void {}
method roundCompleted (line 43) | roundCompleted(): void {}
method convergenceDetected (line 44) | convergenceDetected(): void {}
method printSummary (line 45) | printSummary(): void {}
function createReporter (line 48) | function createReporter(opts?: { dryRun?: boolean }): Reporter {
FILE: src/ui/terminal-reporter.ts
constant SPINNER_FRAMES (line 5) | const SPINNER_FRAMES = ['\u25d0', '\u25d3', '\u25d1', '\u25d2'];
constant TICK_INTERVAL (line 6) | const TICK_INTERVAL = 200;
constant LABEL_COL_WIDTH (line 7) | const LABEL_COL_WIDTH = 40;
constant RED (line 9) | const RED = '\x1b[31m';
constant DIM (line 10) | const DIM = '\x1b[2m';
constant GREEN (line 11) | const GREEN = '\x1b[32m';
constant BOLD (line 12) | const BOLD = '\x1b[1m';
constant RESET (line 13) | const RESET = '\x1b[0m';
function formatDuration (line 15) | function formatDuration(ms: number): string {
type ToolPhase (line 23) | type ToolPhase = 'pending' | 'running' | 'done';
type ToolState (line 25) | interface ToolState {
class TerminalReporter (line 42) | class TerminalReporter implements Reporter {
method discoveryStarted (line 60) | discoveryStarted(toolId: string): void {
method discoveryCompleted (line 64) | discoveryCompleted(_toolId: string): void {
method promptWritingStarted (line 69) | promptWritingStarted(toolId: string): void {
method promptWritingCompleted (line 73) | promptWritingCompleted(_toolId: string): void {
method phasePidReported (line 78) | phasePidReported(toolId: string, pid: number): void {
method executionStarted (line 85) | executionStarted(
method toolStarted (line 112) | toolStarted(toolId: string, pid?: number): void {
method toolCompleted (line 120) | toolCompleted(toolId: string, report: ToolReport): void {
method executionFinished (line 127) | executionFinished(): void {
method roundStarted (line 138) | roundStarted(round: number, totalRounds: number | null): void {
method roundCompleted (line 165) | roundCompleted(_round: number): void {
method convergenceDetected (line 169) | convergenceDetected(round: number, ratio: number, threshold: number): ...
method printSummary (line 179) | printSummary(manifest: RunManifest, opts: { json?: boolean }): void {
method startPhaseSpinner (line 189) | private startPhaseSpinner(text: string): void {
method stopPhaseSpinner (line 199) | private stopPhaseSpinner(): void {
method renderPhase (line 208) | private renderPhase(): void {
method clearStatus (line 219) | private clearStatus(): void {
method restoreStatus (line 229) | private restoreStatus(): void {
method stderr (line 233) | private stderr(line: string): void {
method render (line 237) | private render(): void {
method formatLine (line 281) | private formatLine(tool: ToolState): string {
FILE: tests/integration/cli.test.ts
constant CLI (line 15) | const CLI = resolve(import.meta.dirname, '../../dist/cli.js');
function run (line 17) | function run(args: string, options?: { env?: Record<string, string> }): ...
FILE: tests/unit/agent-reporter.test.ts
function createReporter (line 27) | async function createReporter() {
function makeReport (line 32) | function makeReport(overrides: Partial<ToolReport> = {}): ToolReport {
FILE: tests/unit/dispatcher.test.ts
function makeConfig (line 43) | function makeConfig(tools: Config['tools']): Config {
FILE: tests/unit/loop-command.test.ts
function createProgramHarness (line 61) | function createProgramHarness() {
function makeConfig (line 91) | function makeConfig(): Config {
FILE: tests/unit/loop.test.ts
function makeConfig (line 55) | function makeConfig(): Config {
function baseOptions (line 75) | function baseOptions(overrides: Record<string, unknown> = {}) {
FILE: tests/unit/prompt-writer.test.ts
function makeConfig (line 29) | function makeConfig(tools?: Config['tools']): Config {
FILE: tests/unit/repo-discovery.test.ts
function makeConfig (line 29) | function makeConfig(tools?: Config['tools']): Config {
FILE: tests/unit/run-shared.test.ts
function makeConfig (line 22) | function makeConfig(
FILE: tests/unit/terminal-reporter.test.ts
function createReporter (line 29) | async function createReporter() {
function makeReport (line 36) | function makeReport(overrides: Partial<ToolReport> = {}): ToolReport {
FILE: tests/unit/text-utils.test.ts
function makeResult (line 24) | function makeResult(overrides: Partial<ExecResult> = {}): ExecResult {
FILE: tests/unit/tools-add-custom-model.test.ts
function createProgram (line 98) | function createProgram() {
FILE: tests/unit/upgrade-exec.test.ts
function makeDetection (line 7) | function makeDetection(
FILE: tests/unit/upgrade-standalone.test.ts
function sha256Hex (line 21) | function sha256Hex(buf: Buffer): string {
function makeRelease (line 25) | function makeRelease(tag: string, name: string) {
function makeFetch (line 41) | function makeFetch(opts: {
Condensed preview — 117 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (560K chars).
[
{
"path": ".gitattributes",
"chars": 59,
"preview": "* text=auto eol=lf\n*.bat text eol=crlf\n*.cmd text eol=crlf\n"
},
{
"path": ".github/scripts/parse-changelog.sh",
"chars": 6034,
"preview": "#!/bin/bash\nset -e\n\n# parse-changelog.sh\n# Parses and updates CHANGELOG.md following Keep a Changelog format\n#\n# Usage:\n"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2251,
"preview": "name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\npermissions:\n contents: write\n\njobs:\n "
},
{
"path": ".github/workflows/release-binaries.yml",
"chars": 1891,
"preview": "name: Release Binaries\n\non:\n workflow_call:\n inputs:\n tag:\n description: 'Tag to build binaries for (e.g"
},
{
"path": ".github/workflows/release.yml",
"chars": 11035,
"preview": "name: Release\nrun-name: \"Release ${{ inputs.version }}\"\n\non:\n workflow_dispatch:\n inputs:\n version:\n des"
},
{
"path": ".gitignore",
"chars": 55,
"preview": "node_modules/\ndist/\nrelease/\nagents/\n*.tgz\nogimage.txt\n"
},
{
"path": "CHANGELOG.md",
"chars": 13844,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
},
{
"path": "README.md",
"chars": 24053,
"preview": "# counselors\n\nBy [Aaron Francis](https://aaronfrancis.com), creator of [Faster.dev](https://faster.dev) and [Solo](https"
},
{
"path": "assets/amp-deep-settings.json",
"chars": 241,
"preview": "{\n \"amp.tools.enable\": [\n \"Read\",\n \"Grep\",\n \"glob\",\n \"finder\",\n \"librarian\",\n \"look_at\",\n \"oracle\""
},
{
"path": "assets/amp-readonly-settings.json",
"chars": 229,
"preview": "{\n \"amp.tools.enable\": [\n \"Read\",\n \"Grep\",\n \"glob\",\n \"finder\",\n \"librarian\",\n \"look_at\",\n \"oracle\""
},
{
"path": "assets/presets/bughunt.yml",
"chars": 1679,
"preview": "name: bughunt\ndescription: |\n You are hunting for real correctness bugs, edge-case failures, and missing tests that wou"
},
{
"path": "assets/presets/contracts.yml",
"chars": 1164,
"preview": "name: contracts\ndescription: |\n You are auditing for API contract drift across server handlers, shared types, clients, "
},
{
"path": "assets/presets/hotspots.yml",
"chars": 1810,
"preview": "name: hotspots\ndescription: |\n You are auditing for high-impact performance bottlenecks, with emphasis on asymptotic co"
},
{
"path": "assets/presets/invariants.yml",
"chars": 1598,
"preview": "name: invariants\ndescription: |\n You are auditing a codebase for state synchronization issues, impossible states, and s"
},
{
"path": "assets/presets/regression.yml",
"chars": 1227,
"preview": "name: regression\ndescription: |\n You are auditing a codebase for regression risk with emphasis on behavior changes that"
},
{
"path": "assets/presets/security.yml",
"chars": 1591,
"preview": "name: security\ndescription: |\n You are a security engineer reviewing a codebase with an attacker's mindset. Your goal i"
},
{
"path": "biome.json",
"chars": 596,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/2.3.14/schema.json\",\n \"assist\": {\n \"actions\": {\n \"source\": {\n "
},
{
"path": "install.sh",
"chars": 2557,
"preview": "#!/bin/bash\nset -euo pipefail\n\nREPO=\"aarondfrancis/counselors\"\nINSTALL_DIR=\"${INSTALL_DIR:-$HOME/.local/bin}\"\n\nOS=$(unam"
},
{
"path": "package.json",
"chars": 1408,
"preview": "{\n \"name\": \"counselors\",\n \"version\": \"0.5.2\",\n \"description\": \"Fan out prompts to multiple AI coding agents in parall"
},
{
"path": "scripts/build-binaries.ts",
"chars": 878,
"preview": "import { mkdirSync, readFileSync } from 'node:fs';\nimport { execFileSync } from 'node:child_process';\n\nconst pkg = JSON."
},
{
"path": "src/adapters/amp.ts",
"chars": 3629,
"preview": "import { existsSync } from 'node:fs';\nimport { AMP_DEEP_SETTINGS_FILE, AMP_SETTINGS_FILE } from '../constants.js';\nimpor"
},
{
"path": "src/adapters/base.ts",
"chars": 996,
"preview": "import { countWords } from '../core/text-utils.js';\nimport type {\n ExecResult,\n Invocation,\n ReadOnlyLevel,\n RunRequ"
},
{
"path": "src/adapters/claude.ts",
"chars": 1431,
"preview": "import { sanitizePath } from '../constants.js';\nimport type { Invocation, RunRequest } from '../types.js';\nimport { Base"
},
{
"path": "src/adapters/codex.ts",
"chars": 1584,
"preview": "import { sanitizePath } from '../constants.js';\nimport type { Invocation, RunRequest } from '../types.js';\nimport { Base"
},
{
"path": "src/adapters/custom.ts",
"chars": 1348,
"preview": "import { sanitizePath } from '../constants.js';\nimport type {\n Invocation,\n ReadOnlyLevel,\n RunRequest,\n ToolConfig,"
},
{
"path": "src/adapters/gemini.ts",
"chars": 1908,
"preview": "import type { Invocation, RunRequest } from '../types.js';\nimport { BaseAdapter } from './base.js';\n\nexport class Gemini"
},
{
"path": "src/adapters/index.ts",
"chars": 1314,
"preview": "import type { ToolAdapter, ToolConfig } from '../types.js';\nimport { AmpAdapter } from './amp.js';\nimport { ClaudeAdapte"
},
{
"path": "src/cli.ts",
"chars": 3025,
"preview": "import { Command } from 'commander';\nimport { registerAgentCommand } from './commands/agent.js';\nimport { registerCleanu"
},
{
"path": "src/commands/_run-shared.ts",
"chars": 10642,
"preview": "import { copyFileSync, readFileSync } from 'node:fs';\nimport { basename, dirname, resolve, sep } from 'node:path';\nimpor"
},
{
"path": "src/commands/agent.ts",
"chars": 1872,
"preview": "import type { Command } from 'commander';\nimport { info } from '../ui/logger.js';\n\nexport function registerAgentCommand("
},
{
"path": "src/commands/cleanup.ts",
"chars": 5435,
"preview": "import { resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport {\n deleteCleanupCandidates,\n par"
},
{
"path": "src/commands/config.ts",
"chars": 478,
"preview": "import type { Command } from 'commander';\nimport { CONFIG_FILE } from '../constants.js';\nimport { loadConfig } from '../"
},
{
"path": "src/commands/doctor.ts",
"chars": 6011,
"preview": "import { existsSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { Command } from 'commander';\nimport"
},
{
"path": "src/commands/groups/add.ts",
"chars": 1847,
"preview": "import type { Command } from 'commander';\nimport { SAFE_ID_RE } from '../../constants.js';\nimport { addGroupToConfig, lo"
},
{
"path": "src/commands/groups/list.ts",
"chars": 939,
"preview": "import type { Command } from 'commander';\nimport { loadConfig } from '../../core/config.js';\nimport { info } from '../.."
},
{
"path": "src/commands/groups/remove.ts",
"chars": 699,
"preview": "import type { Command } from 'commander';\nimport {\n loadConfig,\n removeGroupFromConfig,\n saveConfig,\n} from '../../co"
},
{
"path": "src/commands/init.ts",
"chars": 6573,
"preview": "import type { Command } from 'commander';\nimport { getAllBuiltInAdapters, resolveAdapter } from '../adapters/index.js';\n"
},
{
"path": "src/commands/loop.ts",
"chars": 15637,
"preview": "import { join, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport { getExecutionBoilerplate } "
},
{
"path": "src/commands/make-dir.ts",
"chars": 4084,
"preview": "import type { Command } from 'commander';\nimport { loadConfig, loadProjectConfig, mergeConfigs } from '../core/config.js"
},
{
"path": "src/commands/run.ts",
"chars": 4508,
"preview": "import { resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport { dispatch } from '../core/dispatc"
},
{
"path": "src/commands/skill.ts",
"chars": 14336,
"preview": "import type { Command } from 'commander';\nimport { info } from '../ui/logger.js';\n\nexport function registerSkillCommand("
},
{
"path": "src/commands/tools/add.ts",
"chars": 10443,
"preview": "import { accessSync, constants } from 'node:fs';\nimport { resolve } from 'node:path';\nimport type { Command } from 'comm"
},
{
"path": "src/commands/tools/discover.ts",
"chars": 925,
"preview": "import type { Command } from 'commander';\nimport { getAllBuiltInAdapters } from '../../adapters/index.js';\nimport { disc"
},
{
"path": "src/commands/tools/list.ts",
"chars": 1370,
"preview": "import type { Command } from 'commander';\nimport { resolveAdapter } from '../../adapters/index.js';\nimport { loadConfig "
},
{
"path": "src/commands/tools/remove.ts",
"chars": 1724,
"preview": "import { checkbox } from '@inquirer/prompts';\nimport type { Command } from 'commander';\nimport {\n loadConfig,\n removeT"
},
{
"path": "src/commands/tools/rename.ts",
"chars": 1131,
"preview": "import type { Command } from 'commander';\nimport { SAFE_ID_RE } from '../../constants.js';\nimport {\n loadConfig,\n rena"
},
{
"path": "src/commands/tools/test.ts",
"chars": 1622,
"preview": "import type { Command } from 'commander';\nimport { resolveAdapter } from '../../adapters/index.js';\nimport { loadConfig "
},
{
"path": "src/commands/upgrade.ts",
"chars": 5011,
"preview": "import type { Command } from 'commander';\nimport { VERSION } from '../constants.js';\nimport {\n detectInstallation,\n ge"
},
{
"path": "src/constants.ts",
"chars": 2490,
"preview": "import { homedir } from 'node:os';\nimport { join } from 'node:path';\n\n// ── XDG config ──\n\nconst xdgConfig = process.env"
},
{
"path": "src/core/amp-utils.ts",
"chars": 670,
"preview": "import { mkdirSync, writeFileSync } from 'node:fs';\nimport ampDeepSettings from '../../assets/amp-deep-settings.json';\ni"
},
{
"path": "src/core/boilerplate.ts",
"chars": 491,
"preview": "/**\n * Universal execution boilerplate appended to every generated prompt before dispatch.\n */\nexport function getExecut"
},
{
"path": "src/core/cleanup.ts",
"chars": 2935,
"preview": "import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\n\nexport type Cle"
},
{
"path": "src/core/config.ts",
"chars": 5007,
"preview": "import { existsSync, mkdirSync, readFileSync } from 'node:fs';\nimport { dirname, resolve } from 'node:path';\nimport { z "
},
{
"path": "src/core/context.ts",
"chars": 3918,
"preview": "import { execFileSync } from 'node:child_process';\nimport { readFileSync, statSync } from 'node:fs';\nimport { resolve } "
},
{
"path": "src/core/discovery.ts",
"chars": 6675,
"preview": "import { execFileSync } from 'node:child_process';\nimport {\n accessSync,\n constants,\n existsSync,\n readdirSync,\n re"
},
{
"path": "src/core/dispatcher.ts",
"chars": 4794,
"preview": "import { join } from 'node:path';\nimport pLimit from 'p-limit';\nimport { resolveAdapter } from '../adapters/index.js';\ni"
},
{
"path": "src/core/executor.ts",
"chars": 12229,
"preview": "import { type ChildProcess, execFileSync } from 'node:child_process';\nimport { delimiter, dirname, isAbsolute, normalize"
},
{
"path": "src/core/fs-utils.ts",
"chars": 675,
"preview": "import { randomUUID } from 'node:crypto';\nimport { renameSync, unlinkSync, writeFileSync } from 'node:fs';\n\n/**\n * Atomi"
},
{
"path": "src/core/loop.ts",
"chars": 7017,
"preview": "import { mkdirSync, readdirSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport type { Config, ReadO"
},
{
"path": "src/core/prompt-builder.ts",
"chars": 2573,
"preview": "import { mkdirSync } from 'node:fs';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { MAX_SLUG_LEN"
},
{
"path": "src/core/prompt-writer.ts",
"chars": 3350,
"preview": "import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'no"
},
{
"path": "src/core/repo-discovery.ts",
"chars": 3094,
"preview": "import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'no"
},
{
"path": "src/core/synthesis.ts",
"chars": 4334,
"preview": "import { existsSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { sanitizeId } from '../con"
},
{
"path": "src/core/text-utils.ts",
"chars": 623,
"preview": "import type { ExecResult, ToolReport } from '../types.js';\n\nexport function countWords(text: string): number {\n return "
},
{
"path": "src/core/upgrade.ts",
"chars": 21917,
"preview": "import { execFileSync, spawnSync } from 'node:child_process';\nimport { createHash } from 'node:crypto';\nimport {\n acces"
},
{
"path": "src/presets/index.ts",
"chars": 2422,
"preview": "import { existsSync, readdirSync, readFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimp"
},
{
"path": "src/presets/types.ts",
"chars": 429,
"preview": "import { z } from 'zod';\nimport type { ReadOnlyLevel } from '../types.js';\n\nexport interface PresetDefinition {\n name: "
},
{
"path": "src/types.ts",
"chars": 3699,
"preview": "import { z } from 'zod';\n\n// ── Read-only levels ──\n\nexport type ReadOnlyLevel = 'enforced' | 'bestEffort' | 'none';\n\n//"
},
{
"path": "src/ui/agent-reporter.ts",
"chars": 5554,
"preview": "import type { RunManifest, ToolReport } from '../types.js';\nimport { formatRunSummary } from './output.js';\nimport type "
},
{
"path": "src/ui/logger.ts",
"chars": 555,
"preview": "function isDebug(): boolean {\n return process.env.DEBUG === '1' || process.env.DEBUG === 'counselors';\n}\n\nexport functi"
},
{
"path": "src/ui/output.ts",
"chars": 5799,
"preview": "import { isAbsolute } from 'node:path';\nimport ora, { type Ora } from 'ora';\nimport type {\n DiscoveryResult,\n DoctorCh"
},
{
"path": "src/ui/prompts.ts",
"chars": 2718,
"preview": "import { checkbox, confirm, input, select } from '@inquirer/prompts';\n\nexport async function selectModelDetails(\n toolI"
},
{
"path": "src/ui/reporter.ts",
"chars": 1782,
"preview": "import type { RunManifest, ToolReport } from '../types.js';\nimport { AgentReporter } from './agent-reporter.js';\nimport "
},
{
"path": "src/ui/terminal-reporter.ts",
"chars": 9221,
"preview": "import type { RunManifest, ToolReport } from '../types.js';\nimport { formatRunSummary } from './output.js';\nimport type "
},
{
"path": "tests/fixtures/bin/fake-amp",
"chars": 372,
"preview": "#!/usr/bin/env node\n// Fake amp binary for testing (reads stdin)\nlet input = '';\nprocess.stdin.setEncoding('utf-8');\npro"
},
{
"path": "tests/fixtures/bin/fake-claude",
"chars": 340,
"preview": "#!/usr/bin/env node\n// Fake claude binary for testing\nconst args = process.argv.slice(2);\nconst prompt = args[args.lengt"
},
{
"path": "tests/fixtures/bin/fake-codex",
"chars": 328,
"preview": "#!/usr/bin/env node\n// Fake codex binary for testing\nconst args = process.argv.slice(2);\nconst prompt = args[args.length"
},
{
"path": "tests/fixtures/configs/valid.json",
"chars": 488,
"preview": "{\n \"version\": 1,\n \"defaults\": {\n \"timeout\": 540,\n \"outputDir\": \"./agents/counselors\",\n \"readOnly\": \"bestEffor"
},
{
"path": "tests/integration/cli.test.ts",
"chars": 30504,
"preview": "import { execSync } from 'node:child_process';\nimport {\n existsSync,\n mkdirSync,\n mkdtempSync,\n readFileSync,\n rmSy"
},
{
"path": "tests/unit/adapters/amp.test.ts",
"chars": 7988,
"preview": "import { existsSync } from 'node:fs';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport {\n AmpAdapt"
},
{
"path": "tests/unit/adapters/claude.test.ts",
"chars": 2591,
"preview": "import { describe, expect, it } from 'vitest';\nimport { ClaudeAdapter } from '../../../src/adapters/claude.js';\nimport t"
},
{
"path": "tests/unit/adapters/codex.test.ts",
"chars": 4036,
"preview": "import { describe, expect, it } from 'vitest';\nimport { CodexAdapter } from '../../../src/adapters/codex.js';\nimport typ"
},
{
"path": "tests/unit/adapters/custom.test.ts",
"chars": 2980,
"preview": "import { describe, expect, it } from 'vitest';\nimport { CustomAdapter } from '../../../src/adapters/custom.js';\nimport t"
},
{
"path": "tests/unit/adapters/gemini.test.ts",
"chars": 2735,
"preview": "import { describe, expect, it } from 'vitest';\nimport { GeminiAdapter } from '../../../src/adapters/gemini.js';\nimport t"
},
{
"path": "tests/unit/adapters/resolve.test.ts",
"chars": 1713,
"preview": "import { describe, expect, it } from 'vitest';\nimport { resolveAdapter } from '../../../src/adapters/index.js';\nimport t"
},
{
"path": "tests/unit/agent-reporter.test.ts",
"chars": 12704,
"preview": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { ToolReport } from '../../src/typ"
},
{
"path": "tests/unit/amp-utils.test.ts",
"chars": 1178,
"preview": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst mockMkdirSync = vi.fn();\nconst mockWriteFileSync ="
},
{
"path": "tests/unit/cleanup.test.ts",
"chars": 2303,
"preview": "import { mkdirSync, rmSync, utimesSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:pa"
},
{
"path": "tests/unit/config.test.ts",
"chars": 16265,
"preview": "import {\n existsSync,\n mkdirSync,\n rmSync,\n statSync,\n writeFileSync,\n} from 'node:fs';\nimport { tmpdir } from 'nod"
},
{
"path": "tests/unit/constants.test.ts",
"chars": 2534,
"preview": "import { describe, expect, it } from 'vitest';\nimport { SAFE_ID_RE, sanitizeId, sanitizePath } from '../../src/constants"
},
{
"path": "tests/unit/context.test.ts",
"chars": 4533,
"preview": "import { mkdirSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node"
},
{
"path": "tests/unit/discovery.test.ts",
"chars": 1926,
"preview": "import { join } from 'node:path';\nimport { describe, expect, it } from 'vitest';\nimport {\n buildBinaryCandidatesForScan"
},
{
"path": "tests/unit/dispatcher.test.ts",
"chars": 9195,
"preview": "import { mkdirSync, rmSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport "
},
{
"path": "tests/unit/execute-test.test.ts",
"chars": 10417,
"preview": "import { describe, expect, it } from 'vitest';\nimport { executeTest } from '../../src/core/executor.js';\nimport type { T"
},
{
"path": "tests/unit/executor.test.ts",
"chars": 15359,
"preview": "import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { del"
},
{
"path": "tests/unit/fs-utils.test.ts",
"chars": 2731,
"preview": "import {\n mkdirSync,\n readdirSync,\n readFileSync,\n rmSync,\n statSync,\n symlinkSync,\n} from 'node:fs';\nimport { tmp"
},
{
"path": "tests/unit/logger.test.ts",
"chars": 1471,
"preview": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { debug } from '../../src/ui/logger.js'"
},
{
"path": "tests/unit/loop-command.test.ts",
"chars": 7744,
"preview": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { Config } from '../../src/types.j"
},
{
"path": "tests/unit/loop.test.ts",
"chars": 6799,
"preview": "import { mkdirSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node"
},
{
"path": "tests/unit/output.test.ts",
"chars": 3096,
"preview": "import { describe, expect, it } from 'vitest';\nimport { formatTestResults, formatToolList } from '../../src/ui/output.js"
},
{
"path": "tests/unit/presets.test.ts",
"chars": 8722,
"preview": "import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'no"
},
{
"path": "tests/unit/prompt-builder.test.ts",
"chars": 4171,
"preview": "import { existsSync, mkdirSync, rmSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:pa"
},
{
"path": "tests/unit/prompt-writer.test.ts",
"chars": 4832,
"preview": "import { beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { Config } from '../../src/types.js';\n\nvi.moc"
},
{
"path": "tests/unit/prompts.test.ts",
"chars": 2054,
"preview": "import { describe, expect, it, vi } from 'vitest';\n\n// Mock @inquirer/prompts so we can control what the user \"selects\"\n"
},
{
"path": "tests/unit/repo-discovery.test.ts",
"chars": 4811,
"preview": "import { beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { Config } from '../../src/types.js';\n\nvi.moc"
},
{
"path": "tests/unit/reporter.test.ts",
"chars": 3370,
"preview": "import { describe, expect, it, vi } from 'vitest';\nimport { createReporter, NullReporter } from '../../src/ui/reporter.j"
},
{
"path": "tests/unit/run-shared.test.ts",
"chars": 10660,
"preview": "import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { joi"
},
{
"path": "tests/unit/synthesis.test.ts",
"chars": 5416,
"preview": "import { mkdirSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node"
},
{
"path": "tests/unit/terminal-reporter.test.ts",
"chars": 9264,
"preview": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { ToolReport } from '../../src/typ"
},
{
"path": "tests/unit/text-utils.test.ts",
"chars": 2041,
"preview": "import { describe, expect, it } from 'vitest';\nimport { buildToolReport, countWords } from '../../src/core/text-utils.js"
},
{
"path": "tests/unit/tools-add-custom-model.test.ts",
"chars": 10644,
"preview": "import { mkdirSync, rmSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport "
},
{
"path": "tests/unit/upgrade-exec.test.ts",
"chars": 3061,
"preview": "import { describe, expect, it, vi } from 'vitest';\nimport {\n type InstallDetection,\n performUpgrade,\n} from '../../src"
},
{
"path": "tests/unit/upgrade-standalone.test.ts",
"chars": 6076,
"preview": "import { createHash } from 'node:crypto';\nimport {\n chmodSync,\n existsSync,\n mkdtempSync,\n readFileSync,\n rmSync,\n "
},
{
"path": "tests/unit/upgrade.test.ts",
"chars": 4629,
"preview": "import { describe, expect, it } from 'vitest';\nimport {\n detectInstallMethod,\n getStandaloneAssetName,\n parseBrewVers"
},
{
"path": "tsconfig.json",
"chars": 454,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"Node16\",\n \"moduleResolution\": \"Node16\",\n \"strict\":"
},
{
"path": "tsup.config.ts",
"chars": 425,
"preview": "import { readFileSync } from 'node:fs';\nimport { defineConfig } from 'tsup';\n\nconst pkg = JSON.parse(readFileSync('./pac"
},
{
"path": "vitest.config.ts",
"chars": 177,
"preview": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n test: {\n globals: true,\n include: ["
}
]
About this extraction
This page contains the full source code of the aarondfrancis/counselors GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 117 files (520.3 KB), approximately 136.1k tokens, and a symbol index with 346 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.