diff --git a/.gitignore b/.gitignore
index f7107a8..7964f68 100755
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
adminBenches/cloudAdmin/
devBenches/flutterBench/
devBenches/frappeBench/
+devBenches/phpBench/
devBenches/pythonBench/
# Installation tracking (local only)
@@ -14,11 +15,23 @@ config/version-manifest.json
# Common ignores
.DS_Store
+__pycache__/
+*.py[cod]
*.log
*.tmp
node_modules/
+
+# Secret and machine-local environment files. Keep template examples tracked.
.env
+.env.*
+*.env
+!.env.example
+!.env.sample
+!.env.template
+*.backup
*.bak
+*.bak.*
+secrets/
*.swp
*.swo
@@ -32,8 +45,13 @@ Thumbs.db
# Bio bench repositories
bioBenches/gentecBench/
+bioBenches/simBench/
logs/
reports/cache-pilot/
.codex
.codex/
+.claude/dashboard.md
+.claude/speckit-history.md
+.claude/settings.local.json
sysBenches/opsBench/
+.worktrees/
diff --git a/PROTOCOL.md b/PROTOCOL.md
new file mode 100644
index 0000000..5447ac5
--- /dev/null
+++ b/PROTOCOL.md
@@ -0,0 +1,154 @@
+# Asynchronous Clarify Protocol
+
+A custom clarify step for [GitHub Spec Kit](https://github.com/github/spec-kit) +
+Claude Code. It fans question generation out across reviewer angles, logs the questions
+to a file, and finishes clarify **automatically** once they are answered — no manual
+re-running.
+
+## Why
+
+Spec Kit's stock `/speckit.clarify` is synchronous: it asks, you answer, all in one
+sitting. Real clarification has human latency — a domain expert may take a day to answer
+a compliance question. This protocol decouples **question generation** (cheap, parallel,
+done by AI) from **answering** (slow, human) from **application** (one edit to `spec.md`),
+and uses a polling loop so the loop, not you, watches for completion.
+
+## The four files
+
+| File | Role |
+|------|------|
+| `.claude/commands/openClarify.md` | Orchestrator. Resolves the feature dir, initializes the log, triggers the generation fan-out, registers the poll loop. Never answers questions. |
+| `.claude/commands/openClarify-resume.md` | Poll tick. Reads the log and branches cheaply; only the all-answered tick edits `spec.md`. Enforces the critical-class human gate. |
+| `templates/clarify-log.template.md` | The log schema: two top-of-file sentinels + per-question blocks. |
+| `PROTOCOL.md` | This document. |
+
+## Data flow
+
+```
+/openClarify [feature-dir]
+ ├─ verify spec.md exists
+ ├─ init clarify-log.md from template (GENERATION: PENDING, CLARIFY: IN_PROGRESS)
+ ├─ workflow: fan out reviewer angles ──┐
+ │ data-model ┐ │ each appends OPEN question blocks
+ │ edge-cases ┤ │ merge + dedupe, cap 25
+ │ security-compliance ┤ │ then flip GENERATION: COMPLETE
+ │ testability ┤ │
+ │ integration ┘ │
+ └─ register /loop 10m /openClarify-resume
+
+ ... humans answer blocks in clarify-log.md over time ...
+
+/openClarify-resume (every 10 min)
+ ├─ log missing? → no-op
+ ├─ CLARIFY: COMPLETE? → no-op, stop loop
+ ├─ GENERATION != COMPLETE → no-op (sentinel guard: no early firing)
+ ├─ any status: OPEN? → no-op (still waiting)
+ ├─ critical answered by architect-ai? → no-op (human escalation)
+ └─ all answered, criticals human-signed → edit spec.md, CLARIFY: COMPLETE, stop loop
+```
+
+## The log schema
+
+Two **sentinels** at the top of `clarify-log.md` are the entire coordination contract:
+
+- `GENERATION: PENDING | COMPLETE` — set `COMPLETE` only when the fan-out has written
+ every question. Until then the poller refuses to act.
+- `CLARIFY: IN_PROGRESS | COMPLETE` — set `COMPLETE` only after answers are applied to
+ `spec.md`.
+
+Each question is a block:
+
+```
+## Q3
+- id: q3
+- status: OPEN # OPEN | ANSWERED
+- class: normal # normal | critical
+- agent: security-compliance # which reviewer angle raised it
+- question: How long is PHI retained after account deletion?
+- answer:
+- answered_by: # human | architect-ai
+- ts:
+```
+
+## Three design guarantees
+
+1. **Sentinel guard against early firing.** The poller treats `GENERATION: COMPLETE` as a
+ precondition. A log that is mid-generation can momentarily show "zero OPEN questions"
+ simply because no questions have been written yet — the guard stops the poller from
+ misreading that as "all answered" and prematurely editing `spec.md`.
+
+2. **Critical-class human escalation.** A `class: critical` question (clinical / regulated /
+ safety-impacting) answered by `architect-ai` does **not** clear. Completion blocks until
+ a human re-answers or confirms it. The AI can draft; only a human signature releases the
+ gate.
+
+3. **Cheap read-and-branch ticks.** Nearly every poll tick just greps the sentinels and a
+ handful of `status:` lines, then exits. Exactly one tick — the one that sees everything
+ answered and all criticals human-signed — does the expensive `spec.md` edit. Polling
+ every 10 minutes is therefore nearly free.
+
+## Relationship to stock `/speckit.clarify` — audit pass
+
+This protocol **does the clarification work**, then stock `/speckit.clarify` runs **after**
+as an **audit**, not as the primary clarifier.
+
+Because `/openClarify-resume` writes answers in stock's canonical shape — a
+`## Clarifications` section with `### Session YYYY-MM-DD` and `- Q: … → A: …` bullets, plus
+the answer folded into the relevant spec section — a subsequent stock run sees those points
+as already resolved. Stock decides what to ask by scanning **spec sections** against its
+coverage taxonomy (Clear / Partial / Missing), so the folding in §3b is what actually makes
+the audit quiet, not the log bullets.
+
+**How to use the audit:** after our protocol marks `CLARIFY: COMPLETE`, run stock
+`/speckit.clarify` (the `speckit-clarify` skill) a few times.
+
+- **No new questions** → our five reviewer angles covered the spec to stock's standard. Proceed to `/speckit.plan`.
+- **New questions** → a real coverage gap. Most will land in taxonomy categories our angles
+ don't target: **functional scope & behavior, interaction/UX flow, non-functional
+ (performance, scalability, reliability, observability), constraints & tradeoffs, and
+ terminology consistency**. Our angles cover data-model, edge-cases, security/compliance,
+ testability, and integration — so those five categories are the expected blind spots.
+
+Treat stock's output as a **regression check on our generation coverage**. If a category
+keeps surfacing, add a reviewer angle for it to the fan-out in `/openClarify`.
+
+> Note: in this environment stock clarify is overridden (see `~/.claude` global config) to
+> ask up to **25** questions in **block form**, written to `/clarify-questions.md`
+> — so the audit produces a diffable file rather than a one-at-a-time interactive loop.
+
+```
+/openClarify → (async answers) → resume applies + canonical format → CLARIFY: COMPLETE
+ │
+ /speckit.clarify ×N (audit)
+ │
+ new questions? → new clarify cycle ; else → /speckit.plan
+```
+
+## Known gaps / operational notes
+
+- **Generation script is authored on first run.** The `workflow` keyword lets the Claude
+ Code runtime author the fan-out's internal script the first time `/openClarify`
+ runs. **Save that run as `/openClarify-generate`** so subsequent features reuse it
+ instead of re-authoring the fan-out each time.
+
+- **`/loop` is session-scoped with a 3-day cap.** It only survives while the session is
+ alive and stops after ~3 days. For human turnaround longer than that, swap the in-session
+ loop for an external scheduler:
+
+ ```
+ cron + claude -p /openClarify-resume
+ ```
+
+ e.g. a crontab entry running `claude -p "/openClarify-resume specs/my-feature/"` every
+ 15 minutes, which survives restarts and arbitrary human latency.
+
+## Usage
+
+```
+# 1. start (defaults to specs//)
+/openClarify
+
+# 2. humans edit clarify-log.md, filling answer / answered_by / status: ANSWERED
+
+# 3. nothing else to do — the loop applies answers to spec.md and stops itself
+```
diff --git a/README.md b/README.md
index 60bcba0..c056903 100755
--- a/README.md
+++ b/README.md
@@ -11,10 +11,11 @@ A layered Docker-based development environment system. Each "bench" is a self-co
This single command:
1. Configures your shell (zsh + Oh My Zsh + Powerlevel10k)
2. Checks workstation VPN clients and patches 0dcloud TUN MTU for large Git/Docker transfers
-3. Ensures Docker is running and Layer 0 base image exists
-4. Opens an interactive TUI to select benches, AI tools, and workstation tools
-5. Builds Docker images for selected benches
-6. Installs AI coding CLIs and workstation tools (Claude, Copilot, Codex, Pi, etc.)
+3. Installs or updates Wave Terminal widgets for workBenches
+4. Ensures Docker is running and Layer 0 base image exists
+5. Opens an interactive TUI to select benches, AI tools, and workstation tools
+6. Builds Docker images for selected benches
+7. Installs AI coding CLIs and workstation tools (Claude, Copilot, Codex, Pi, etc.)
After setup, open any bench in VS Code → "Reopen in Container" to start developing.
@@ -25,13 +26,14 @@ Safe to run repeatedly. Installed benches show `✓ up to date` and are skipped.
## Docker Image Layers
```
-Layer 0: workbench-base:latest — Ubuntu 24.04 + git, zsh, curl, AI CLIs, bun
- ├─ Layer 1a: dev-bench-base:latest — Python, Node.js LTS, npm, dev tools, testing tools, Playwright Chromium
+Layer 0: workbench-base:latest — Ubuntu 24.04 + git, zsh, curl, shared AI CLIs, bun
+ ├─ Layer 1a: dev-bench-base:latest — Python, Node.js LTS, npm, dev tools, OpenSpec, spec-kit, testing tools, Playwright Chromium
│ ├─ Layer 2: cpp-bench:latest — GCC, CMake, vcpkg
│ ├─ Layer 2: dotnet-bench:latest — .NET SDK 8/9
│ ├─ Layer 2: flutter-bench:latest — Flutter SDK, Dart, Android tools
│ ├─ Layer 2: frappe-bench:latest — MariaDB client, Redis, Nginx, bench CLI (Node.js 20)
- │ ├─ Layer 2: java-bench:latest — OpenJDK 21, Maven, Gradle, Spring CLI
+ │ ├─ Layer 2: java-bench:latest — OpenJDK 25, Maven, Gradle, Spring CLI
+ │ ├─ Layer 2: php-bench:latest — PHP 8.3, Composer, PHPUnit, Xdebug
│ ├─ Layer 2: py-bench:latest — Python dev tools (thin layer on 1a)
│ └─ Layer 2: go-bench:latest — Go toolchain
├─ Layer 1b: sys-bench-base:latest — Kubernetes, Terraform, cloud CLIs
@@ -67,6 +69,7 @@ bash scripts/ensure-layer3.sh --base java-bench:latest
setup.sh
├── Shell setup (zsh + Oh My Zsh + Powerlevel10k)
├── VPN setup (AmneziaVPN + 0dcloud checks, 0dcloud MTU patch)
+ ├── Wave Terminal widgets (terminal, projects, and workBench containers)
├── Docker check (is daemon running?)
├── Layer 0 check (build workbench-base:latest if missing)
├── Interactive TUI (scripts/interactive-setup.sh)
@@ -79,6 +82,39 @@ setup.sh
└── Summary + log file path
```
+## Wave Terminal Widgets
+
+`setup.sh` runs the Wave Terminal installer as a best-effort host setup step so
+all workBenches checkouts get the same desktop shortcuts. The installer comes
+from `opensoft/Install-Wave-Terminal`; setup prefers a sibling
+`../Install-Wave-Terminal` checkout when present, then falls back to cloning it
+into `~/.cache/workbenches/Install-Wave-Terminal`.
+
+Installed widgets include:
+
+| Widget | Behavior |
+|--------|----------|
+| `terminal` | Overrides Wave's built-in terminal to open `wsl://Ubuntu-24.04` instead of PowerShell |
+| `projects` | Opens the Wave files view at `$HOME/projects` on the WSL connection |
+| `pyBench` | Starts or repairs `py-bench`, then opens an interactive shell |
+| `flutterBench` | Starts or repairs `flutter-bench`, then opens an interactive shell |
+| `C++Bench` | Starts or repairs `cpp-bench`, then opens an interactive shell |
+| `cloudBench` | Starts or repairs `cloud-bench`, then opens an interactive shell |
+
+First-run setup offers consent-based work and personal AI profile onboarding,
+including GitHub credential-registry discovery and a local manual fallback.
+See [Shared AI provider profiles](docs/multi-provider-profiles.md).
+Multi-account Claude details remain in
+[Claude multi-account profiles](docs/claude-multi-account-profiles.md).
+The cross-provider ownership and composition model is documented in
+[AI credential ownership and profile composition](docs/ai-credential-ownership.md).
+
+The WSL connection defaults to `wsl://Ubuntu-24.04` and the projects widget
+defaults to `$HOME/projects`. Override them with `WAVE_WSL_CONNECTION` and
+`WAVE_PROJECTS_ROOT`. Widget font size defaults to `16`; override it with
+`WAVE_WIDGET_FONT_SIZE`. Set `WORKBENCHES_SKIP_WAVE_WIDGETS=1` to skip this
+step.
+
### Bench Processing Logic
| State | Action |
@@ -112,6 +148,7 @@ workBenches/
│ ├── frappeBench/ ← Frappe/ERPNext bench (opensoft/frappeBench)
│ ├── goBench/ ← Go bench (opensoft/goBench)
│ ├── javaBench/ ← Java bench (opensoft/javaBench)
+│ ├── phpBench/ ← PHP bench (opensoft/phpBench)
│ └── pyBench/ ← Python bench (opensoft/pyBench)
├── sysBenches/
│ ├── base-image/ ← Layer 1b: sys-bench-base Dockerfile
@@ -180,9 +217,10 @@ Installed and updated via the setup TUI:
|------|---------------|------|
| Claude Code CLI | Native installer | `claude login` |
| GitHub Copilot CLI | npm | `copilot auth login` |
-| OpenAI Codex CLI | npm | `OPENAI_API_KEY` or `codex login` |
-| Google Gemini CLI | npm | Google login (free tier: 60 req/min) |
-| OpenCode CLI | Manual | Additional setup required |
+| OpenAI Codex CLI | npm | `OPENAI_API_KEY`, `codex login`, or isolated `pcodex PROFILE` logins |
+| Google Gemini CLI | npm | Google login or isolated `pgemini PROFILE` login |
+| Grok Build | Native installer | Isolated `pgrok PROFILE` login |
+| OpenCode CLI with Z.AI GLM | Manual | Isolated `pglm PROFILE` Z.AI Coding Plan key |
| spec-kit | uv (pip) | None |
| OpenSpec | npm | None |
@@ -256,6 +294,7 @@ sysBenches/cloudBench/docs/amnezia-server-runbook.md
| frappeBench | [opensoft/frappeBench](https://github.com/opensoft/frappeBench) |
| goBench | [opensoft/goBench](https://github.com/opensoft/goBench) |
| javaBench | [opensoft/javaBench](https://github.com/opensoft/javaBench) |
+| phpBench | [opensoft/phpBench](https://github.com/opensoft/phpBench) |
| pyBench | [opensoft/pyBench](https://github.com/opensoft/pyBench) |
| gentecBench | [opensoft/gentecBench](https://github.com/opensoft/gentecBench) |
| simBench | [opensoft/simBench](https://github.com/opensoft/simBench) |
diff --git a/apps/credential-manager/README.md b/apps/credential-manager/README.md
new file mode 100644
index 0000000..5c492c7
--- /dev/null
+++ b/apps/credential-manager/README.md
@@ -0,0 +1,58 @@
+# Multiple AI Harness Account Manager
+
+This local-only dashboard manages account inventory and supported login flows
+for Claude Code, ChatGPT/Codex CLI, Grok Build, Google Antigravity, and Abacus
+AI. It is generic public tooling: real account names and email addresses belong
+in a separate private source-of-truth repository.
+
+Create a private manifest from the example:
+
+```bash
+mkdir -p ~/account-registry/config
+cp config/ai-harness-accounts.example.json \
+ ~/account-registry/config/ai-harness-accounts.json
+$EDITOR ~/account-registry/config/ai-harness-accounts.json
+```
+
+Run the dashboard:
+
+```bash
+python3 apps/credential-manager/credential_manager.py \
+ --source-repo ~/account-registry
+```
+
+Profiles created by `setup.sh` need no separate registry argument:
+
+```bash
+./scripts/check-ai-credentials.sh
+```
+
+This reads supported manifests in `~/.config/workbenches` by default, including
+the Claude, OpenAI, Gemini, Grok, and GLM profile manifests.
+
+Open `http://127.0.0.1:8765`. The server binds only to loopback. It reads
+non-secret account metadata, derives the source clone's Git remote, verifies
+supported profiles, and starts supported vendor login commands.
+
+Automated adapters:
+
+- Claude Code: `CLAUDE_CONFIG_DIR`, `claude auth login`, and
+ `claude auth status`.
+- ChatGPT/Codex CLI: `CODEX_HOME`, `codex login`, and
+ `codex login status`.
+- Grok Build: `GROK_HOME`, `grok login`, and `grok models`.
+
+Inventory/manual adapters:
+
+- Google Antigravity: the vendor stores sessions in the operating-system
+ keyring and does not document a profile-home override.
+- Abacus AI: the vendor documents `/login [email]` and `ABACUS_API_KEY`, but
+ not a profile-home override.
+
+The dashboard never reads, returns, copies, or stores credential contents.
+Authentication logs are local, mode `0600`, and may contain short-lived login
+URLs, so they must not be committed.
+
+See [Multiple AI harness account management](../../docs/ai-harness-account-management.md)
+for the manifest schema, provider commands, security model, and official
+documentation links.
diff --git a/apps/credential-manager/credential_manager.py b/apps/credential-manager/credential_manager.py
new file mode 100755
index 0000000..38a88fb
--- /dev/null
+++ b/apps/credential-manager/credential_manager.py
@@ -0,0 +1,330 @@
+#!/usr/bin/env python3
+"""Local-only dashboard for multiple AI harness accounts."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import threading
+import webbrowser
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+HOME = Path.home()
+STATE_DIR = HOME / ".local/state/workbenches/credential-manager"
+STATE_DIR.mkdir(parents=True, exist_ok=True)
+
+
+@dataclass(frozen=True)
+class Account:
+ provider: str
+ name: str
+ email: str
+ family: str = ""
+ plan: str = ""
+ workspace: str = ""
+ status: str = "active"
+ auth_mode: str = "browser"
+ secret_env: str = ""
+
+
+PROVIDER_ALIASES = {
+ "codex": "chatgpt",
+ "openai": "chatgpt",
+ "zai": "glm",
+ "z.ai": "glm",
+}
+
+PROVIDER_CLIS = {
+ "claude": "claude",
+ "chatgpt": "codex",
+ "grok": "grok",
+ "gemini": "gemini",
+ "glm": "opencode",
+ "antigravity": "agy",
+ "abacus": "abacusai",
+}
+
+
+def canonical_provider(provider: str) -> str:
+ provider = provider.strip().lower()
+ return PROVIDER_ALIASES.get(provider, provider)
+
+
+def git_repo_url(repo: Path) -> str:
+ try:
+ remote = subprocess.check_output(
+ ["git", "-C", str(repo), "remote", "get-url", "origin"],
+ text=True,
+ stderr=subprocess.DEVNULL,
+ ).strip()
+ except (OSError, subprocess.CalledProcessError):
+ return ""
+ if remote.startswith("git@github.com:"):
+ remote = "https://github.com/" + remote.removeprefix("git@github.com:")
+ if remote.endswith(".git"):
+ remote = remote[:-4]
+ return remote
+
+
+def load_accounts(repo: Path) -> list[Account]:
+ accounts: list[Account] = []
+ seen: set[tuple[str, str]] = set()
+ config = repo / "config" if (repo / "config").is_dir() else repo
+
+ unified = config / "ai-harness-accounts.json"
+ if unified.exists():
+ data = json.loads(unified.read_text())
+ for item in data.get("accounts", []):
+ provider = canonical_provider(item["provider"])
+ account = Account(
+ provider=provider,
+ name=item["name"],
+ email=item.get("email", ""),
+ family=item.get("family", ""),
+ plan=item.get("plan", ""),
+ workspace=item.get("workspace", ""),
+ status=item.get("status", "active"),
+ auth_mode=item.get("authMode", "browser"),
+ secret_env=item.get("secretEnv", ""),
+ )
+ accounts.append(account)
+ seen.add((account.provider, account.name))
+
+ legacy_specs = (
+ ("claude", config / "claude-profiles.json", "profiles"),
+ ("chatgpt", config / "openai-profiles.json", "profiles"),
+ ("grok", config / "grok-profiles.json", "profiles"),
+ ("gemini", config / "gemini-profiles.json", "profiles"),
+ ("glm", config / "glm-profiles.json", "profiles"),
+ ("antigravity", config / "antigravity-accounts.json", "accounts"),
+ ("abacus", config / "abacus-accounts.json", "accounts"),
+ )
+ for provider, path, key in legacy_specs:
+ if not path.exists():
+ continue
+ data = json.loads(path.read_text())
+ for item in data.get(key, []):
+ identity = (provider, item["name"])
+ if identity in seen:
+ continue
+ account = Account(
+ provider=provider,
+ name=item["name"],
+ email=item.get("email", ""),
+ family=item.get("family", ""),
+ plan=item.get("plan", ""),
+ workspace=item.get("workspace", ""),
+ status=item.get("status", "active"),
+ auth_mode=item.get("authMode", "browser"),
+ secret_env=item.get("secretEnv", ""),
+ )
+ accounts.append(account)
+ seen.add(identity)
+ return accounts
+
+
+def profile_home(account: Account) -> Path | None:
+ if account.provider == "claude":
+ return HOME / ".claude-profiles/profiles" / account.name
+ if account.provider == "chatgpt":
+ current = HOME / ".chatgpt-profiles/profiles" / account.name
+ legacy = HOME / ".openai-profiles/profiles" / account.name
+ return legacy if legacy.exists() and not current.exists() else current
+ if account.provider == "grok":
+ return HOME / ".grok-profiles/profiles" / account.name
+ if account.provider == "gemini":
+ return HOME / ".gemini-profiles/profiles" / account.name
+ if account.provider == "glm":
+ return HOME / ".glm-profiles/profiles" / account.name
+ return None
+
+
+def auth_command(account: Account) -> tuple[list[str], dict[str, str]] | None:
+ env = os.environ.copy()
+ directory = profile_home(account)
+ if account.provider == "claude" and directory:
+ directory.mkdir(parents=True, exist_ok=True)
+ metadata = directory / ".claude.json"
+ if not metadata.exists():
+ metadata.write_text('{"hasCompletedOnboarding": true}\n')
+ metadata.chmod(0o600)
+ env["CLAUDE_CONFIG_DIR"] = str(directory)
+ return ["claude", "auth", "login", "--claudeai", "--email", account.email], env
+ if account.provider == "chatgpt" and directory:
+ directory.mkdir(parents=True, exist_ok=True)
+ env["CODEX_HOME"] = str(directory)
+ config = directory / "config.toml"
+ if not config.exists():
+ config.write_text('forced_login_method = "chatgpt"\ncli_auth_credentials_store = "file"\n')
+ config.chmod(0o600)
+ command = ["codex", "login"]
+ if account.auth_mode == "device":
+ command.append("--device-auth")
+ return command, env
+ if account.provider == "grok" and directory:
+ directory.mkdir(parents=True, exist_ok=True)
+ env["GROK_HOME"] = str(directory)
+ command = ["grok", "login"]
+ if account.auth_mode == "device":
+ command.append("--device-auth")
+ return command, env
+ return None
+
+
+def verify(account: Account) -> dict:
+ directory = profile_home(account)
+ cli = PROVIDER_CLIS.get(account.provider)
+ identity_valid = (
+ ("@" in account.email or account.auth_mode == "api-key")
+ and not account.email.startswith("REPLACE_")
+ )
+ cli_available = bool(cli and shutil.which(cli))
+ auth_supported = account.provider in {"claude", "chatgpt", "grok"}
+ result = {
+ **account.__dict__,
+ "profileDirectory": str(directory) if directory else "",
+ "installed": bool(directory and directory.exists()) if directory else cli_available,
+ "authenticated": False,
+ "identityValid": identity_valid,
+ "cliAvailable": cli_available,
+ "detail": "Authentication adapter not available",
+ "canAuth": auth_supported and account.status != "planned" and identity_valid and cli_available,
+ }
+ if account.status == "planned":
+ result["detail"] = "Planned account"
+ return result
+ if not identity_valid:
+ result["detail"] = "Login email is still a manifest placeholder"
+ return result
+ if account.provider == "claude" and directory:
+ command = ["claude", "auth", "status", "--text"]
+ env_key = "CLAUDE_CONFIG_DIR"
+ elif account.provider == "chatgpt" and directory:
+ command = ["codex", "login", "status"]
+ env_key = "CODEX_HOME"
+ elif account.provider == "grok" and directory:
+ command = ["grok", "models"]
+ env_key = "GROK_HOME"
+ elif account.provider == "gemini" and directory:
+ credential = directory / ".gemini/oauth_creds.json"
+ result["authenticated"] = credential.is_file() and credential.stat().st_size > 0
+ result["detail"] = (
+ "Credential cache present; launch pgemini and run /about to verify"
+ if result["authenticated"]
+ else "Launch pgemini login PROFILE to sign in"
+ )
+ return result
+ elif account.provider == "glm" and directory:
+ result["detail"] = "Use pglm login PROFILE and select Z.AI Coding Plan"
+ return result
+ elif account.provider == "antigravity":
+ result["detail"] = "Session is managed by the operating-system keyring; launch agy to verify or switch accounts"
+ return result
+ elif account.provider == "abacus":
+ if account.secret_env and os.environ.get(account.secret_env):
+ result["detail"] = f"{account.secret_env} is present but has not been sent to Abacus for validation"
+ else:
+ result["detail"] = "Launch abacusai and use /login [email], or provide a per-account ABACUS_API_KEY"
+ return result
+ else:
+ return result
+ env = os.environ.copy()
+ env[env_key] = str(directory)
+ try:
+ check = subprocess.run(command, env=env, text=True, capture_output=True, timeout=15)
+ output = (check.stdout or check.stderr).strip()
+ result["authenticated"] = check.returncode == 0
+ result["detail"] = output[-500:] or ("Authenticated" if check.returncode == 0 else "Not authenticated")
+ except FileNotFoundError:
+ result["detail"] = f"{command[0]} CLI is not installed"
+ except subprocess.TimeoutExpired:
+ result["detail"] = "Credential check timed out"
+ return result
+
+
+HTML = r'''
+AI Harness Account Manager
+
AI Harness Account Manager
+
–Accounts
–Authenticated
–Need login
–Providers
+
Configured accountsCredentials remain in provider-owned profiles or keyrings.
| Harness | Profile | Email | Plan / workspace | Local | Credential | Detail | |
|---|
+'''
+
+
+class App:
+ def __init__(self, repo: Path):
+ self.repo = repo.resolve()
+ self.accounts = load_accounts(self.repo)
+ self.repo_url = git_repo_url(self.repo)
+ self.processes: dict[str, subprocess.Popen] = {}
+
+ def summary(self) -> dict:
+ return {"repositoryPath": str(self.repo), "repositoryUrl": self.repo_url, "accounts": [verify(a) for a in self.accounts]}
+
+ def authenticate(self, provider: str, name: str) -> str:
+ account = next((a for a in self.accounts if a.provider == provider and a.name == name), None)
+ if not account:
+ raise ValueError("Unknown account")
+ spec = auth_command(account)
+ if not spec:
+ raise ValueError(f"Interactive authentication is not supported for {provider}")
+ command, env = spec
+ if not shutil.which(command[0]):
+ raise ValueError(f"{command[0]} CLI is not installed")
+ key = f"{provider}-{name}"
+ log = STATE_DIR / f"{key}.log"
+ stream = log.open("w")
+ log.chmod(0o600)
+ self.processes[key] = subprocess.Popen(command, env=env, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
+ return f"Authentication started for {name}. Complete the provider browser flow, then refresh. Log: {log}"
+
+
+def handler_for(app: App):
+ class Handler(BaseHTTPRequestHandler):
+ def reply(self, status: int, body, content_type="application/json"):
+ payload = body.encode() if isinstance(body, str) else json.dumps(body).encode()
+ self.send_response(status); self.send_header("content-type", content_type); self.send_header("content-length", str(len(payload))); self.end_headers(); self.wfile.write(payload)
+ def do_GET(self):
+ path = urlparse(self.path).path
+ if path == "/": self.reply(200, HTML, "text/html; charset=utf-8")
+ elif path == "/api/summary": self.reply(200, app.summary())
+ else: self.reply(404, {"error": "Not found"})
+ def do_POST(self):
+ if urlparse(self.path).path != "/api/auth": return self.reply(404, {"error": "Not found"})
+ try:
+ length = int(self.headers.get("content-length", "0")); data = json.loads(self.rfile.read(length)); message = app.authenticate(data["provider"], data["name"]); self.reply(202, {"message": message})
+ except (ValueError, KeyError, json.JSONDecodeError) as exc: self.reply(400, {"error": str(exc)})
+ def log_message(self, fmt, *args): pass
+ return Handler
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--source-repo",
+ type=Path,
+ default=Path(os.environ.get("AI_HARNESS_ACCOUNT_REPO", Path.home() / ".config/workbenches")),
+ help="Local clone containing config/ai-harness-accounts.json or a legacy split manifest",
+ )
+ parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument("--no-browser", action="store_true")
+ args = parser.parse_args()
+ app = App(args.source_repo)
+ if not app.accounts: raise SystemExit("No supported account manifests found")
+ server = ThreadingHTTPServer(("127.0.0.1", args.port), handler_for(app))
+ url = f"http://127.0.0.1:{args.port}"
+ print(f"AI Harness Account Manager: {url}")
+ if not args.no_browser: threading.Timer(0.4, lambda: webbrowser.open(url)).start()
+ server.serve_forever()
+
+
+if __name__ == "__main__": main()
diff --git a/apps/credential-manager/test_generic_manager.py b/apps/credential-manager/test_generic_manager.py
new file mode 100644
index 0000000..a759d80
--- /dev/null
+++ b/apps/credential-manager/test_generic_manager.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Regression checks for the public multi-harness account manager."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+APP_DIR = Path(__file__).resolve().parent
+REPO = APP_DIR.parents[1]
+MODULE_PATH = APP_DIR / "credential_manager.py"
+SPEC = importlib.util.spec_from_file_location("credential_manager_under_test", MODULE_PATH)
+assert SPEC and SPEC.loader
+MANAGER = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = MANAGER
+SPEC.loader.exec_module(MANAGER)
+
+
+class GenericManagerTest(unittest.TestCase):
+ def test_example_loads_all_supported_harnesses(self):
+ example = REPO / "config/ai-harness-accounts.example.json"
+ with tempfile.TemporaryDirectory() as directory:
+ config = Path(directory) / "config"
+ config.mkdir()
+ (config / "ai-harness-accounts.json").write_text(example.read_text())
+ accounts = MANAGER.load_accounts(Path(directory))
+
+ self.assertEqual(
+ sorted(account.provider for account in accounts),
+ ["abacus", "antigravity", "chatgpt", "claude", "grok"],
+ )
+
+ def test_legacy_provider_names_are_normalized(self):
+ self.assertEqual(MANAGER.canonical_provider("openai"), "chatgpt")
+ self.assertEqual(MANAGER.canonical_provider("codex"), "chatgpt")
+ self.assertEqual(MANAGER.canonical_provider("gemini"), "gemini")
+ self.assertEqual(MANAGER.canonical_provider("zai"), "glm")
+
+ def test_direct_workstation_config_directory_is_supported(self):
+ with tempfile.TemporaryDirectory() as directory:
+ config = Path(directory)
+ (config / "claude-profiles.json").write_text(
+ json.dumps({
+ "version": 1,
+ "profiles": [{
+ "name": "work-example",
+ "family": "work-example",
+ "email": "user@example.org",
+ }],
+ })
+ )
+ accounts = MANAGER.load_accounts(config)
+
+ self.assertEqual(
+ [(account.provider, account.name) for account in accounts],
+ [("claude", "work-example")],
+ )
+
+ def test_example_contains_no_secret_values(self):
+ data = json.loads(
+ (REPO / "config/ai-harness-accounts.example.json").read_text()
+ )
+ forbidden_fields = {
+ "apikey",
+ "api_key",
+ "accesstoken",
+ "access_token",
+ "refreshtoken",
+ "refresh_token",
+ "sessionkey",
+ }
+ for account in data["accounts"]:
+ self.assertTrue(forbidden_fields.isdisjoint(key.lower() for key in account))
+
+ def test_claude_example_provides_work_and_personal_profiles(self):
+ data = json.loads(
+ (REPO / "config/claude-profiles.example.json").read_text()
+ )
+ profiles = {(profile["name"], profile["family"]) for profile in data["profiles"]}
+ self.assertIn(("work", "work"), profiles)
+ self.assertIn(("personal", "personal"), profiles)
+
+ def test_codex_example_provides_work_and_personal_profiles(self):
+ data = json.loads(
+ (REPO / "config/openai-profiles.example.json").read_text()
+ )
+ profiles = {(profile["name"], profile["family"]) for profile in data["profiles"]}
+ self.assertIn(("work-chatgpt-1", "work"), profiles)
+ self.assertIn(("personal-chatgpt-1", "personal"), profiles)
+
+ def test_codex_profile_setup_and_alias_isolate_codex_home(self):
+ with tempfile.TemporaryDirectory(dir="/tmp") as directory:
+ root = Path(directory)
+ home = root / "home"
+ codex_home = home / ".codex"
+ config = root / "config"
+ capture = root / "capture"
+ codex_home.mkdir(parents=True)
+ config.mkdir()
+ capture.mkdir()
+ (codex_home / "config.toml").write_text(
+ 'model = "gpt-5.5"\n\n[tui]\nstatus_line = ["model-name"]\n'
+ )
+ manifest = config / "openai-profiles.json"
+ manifest.write_text(json.dumps({
+ "version": 1,
+ "profiles": [{
+ "name": "work-chatgpt-1",
+ "family": "work",
+ "email": "user@company.example",
+ "aliases": ["work1"],
+ }],
+ }))
+ fake_codex = root / "codex"
+ fake_codex.write_text(
+ '#!/bin/sh\n'
+ 'printf "%s\\n" "$CODEX_HOME" > "$CAPTURE_DIR/home"\n'
+ 'printf "%s\\n" "$@" > "$CAPTURE_DIR/args"\n'
+ )
+ fake_codex.chmod(0o755)
+ env = {
+ **os.environ,
+ "HOME": str(home),
+ "XDG_CONFIG_HOME": str(root / "xdg"),
+ "CODEX_BIN": str(fake_codex),
+ "CODEX_PROFILES_MANIFEST": str(manifest),
+ "CODEX_PROFILES_HOME": str(home / ".chatgpt-profiles"),
+ "CAPTURE_DIR": str(capture),
+ }
+ subprocess.run(
+ [str(REPO / "scripts/setup-codex-profiles.sh")],
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ subprocess.run(
+ [str(REPO / "scripts/codex-profile"), "status", "work1"],
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ profile = home / ".chatgpt-profiles/profiles/work-chatgpt-1"
+ profile_config = (profile / "config.toml").read_text()
+ self.assertEqual((capture / "home").read_text().strip(), str(profile))
+ self.assertIn('forced_login_method = "chatgpt"', profile_config)
+ self.assertIn('cli_auth_credentials_store = "file"', profile_config)
+ self.assertEqual(
+ (capture / "args").read_text().splitlines()[-2:],
+ ["login", "status"],
+ )
+ self.assertEqual(profile.stat().st_mode & 0o777, 0o700)
+ self.assertEqual((profile / ".profile.json").stat().st_mode & 0o777, 0o600)
+
+ def test_public_account_surface_has_no_private_identifiers(self):
+ targets = [
+ REPO / "apps/credential-manager",
+ REPO / "config/ai-harness-accounts.example.json",
+ REPO / "config/claude-profiles.example.json",
+ REPO / "config/openai-profiles.example.json",
+ REPO / "docs/ai-harness-account-management.md",
+ REPO / "docs/ai-credentials-management.md",
+ REPO / "docs/claude-multi-account-profiles.md",
+ REPO / "docs/codex-multi-account-profiles.md",
+ REPO / "scripts/claude-profile",
+ REPO / "scripts/codex-profile",
+ REPO / "scripts/setup-claude-profiles.sh",
+ REPO / "scripts/setup-codex-profiles.sh",
+ REPO / "sysBenches/cloudBench/devcontainer.example",
+ ]
+ forbidden = (
+ "br" + "ett",
+ "open" + "soft",
+ "far" + "heap",
+ "med" + "x",
+ )
+ violations: list[str] = []
+ for target in targets:
+ files = [target] if target.is_file() else target.rglob("*")
+ for path in files:
+ if not path.is_file() or "__pycache__" in path.parts:
+ continue
+ text = path.read_text(errors="ignore").lower()
+ for identifier in forbidden:
+ if identifier in text:
+ violations.append(f"{path.relative_to(REPO)}: {identifier}")
+ self.assertEqual(violations, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/credential-manager/test_profile_composer.py b/apps/credential-manager/test_profile_composer.py
new file mode 100644
index 0000000..2e96252
--- /dev/null
+++ b/apps/credential-manager/test_profile_composer.py
@@ -0,0 +1,157 @@
+import importlib.util
+import json
+import pathlib
+import tempfile
+import unittest
+
+
+SCRIPT = pathlib.Path(__file__).parents[2] / "scripts" / "compose-ai-profiles.py"
+SPEC = importlib.util.spec_from_file_location("compose_ai_profiles", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(MODULE)
+
+
+class ProfileComposerTest(unittest.TestCase):
+ def write_source(self, root, owner_type, owner_id, profiles):
+ source = root / owner_id / "ai"
+ source.mkdir(parents=True)
+ (source / "source.json").write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "kind": "workbenches-ai-profile-source",
+ "owner": {"type": owner_type, "id": owner_id},
+ "profiles": profiles,
+ }
+ ),
+ encoding="utf-8",
+ )
+ return source
+
+ def test_tenant_grant_and_personal_source_are_composed(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ tenant = self.write_source(
+ root,
+ "tenant",
+ "example-tenant",
+ {
+ "claude": [
+ {"name": "team-001", "email": "team-001@example.com", "family": "company"},
+ {"name": "hidden-001", "email": "hidden@example.com", "family": "company"},
+ ],
+ "openai": [],
+ },
+ )
+ grant = tenant / "grants" / "users"
+ grant.mkdir(parents=True)
+ (grant / "engineer.json").write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "user": "engineer",
+ "profiles": {"claude": ["team-*"], "openai": []},
+ }
+ ),
+ encoding="utf-8",
+ )
+ personal = self.write_source(
+ root,
+ "user",
+ "engineer",
+ {
+ "claude": [
+ {"name": "personal-1", "email": "engineer@example.com", "family": "personal"}
+ ],
+ "openai": [],
+ },
+ )
+
+ result = MODULE.compose([str(tenant), str(personal)], "engineer")
+
+ self.assertEqual([profile["name"] for profile in result["claude"]], ["team-001", "personal-1"])
+
+ def test_duplicate_alias_is_rejected(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ personal = self.write_source(
+ root,
+ "user",
+ "engineer",
+ {
+ "claude": [
+ {"name": "one", "email": "one@example.com", "family": "personal", "aliases": ["shared"]},
+ {"name": "two", "email": "two@example.com", "family": "personal", "aliases": ["shared"]},
+ ],
+ "openai": [],
+ },
+ )
+
+ with self.assertRaises(MODULE.ProfileError):
+ MODULE.compose([str(personal)], "engineer")
+
+ def test_five_provider_parity_is_enforced(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ profile = {
+ "name": "team-001",
+ "email": "team-001@example.com",
+ "family": "company",
+ "aliases": ["team001"],
+ }
+ profiles = {provider: [dict(profile)] for provider in MODULE.PROVIDERS}
+ source = self.write_source(root, "user", "engineer", profiles)
+ source_json = source / "source.json"
+ payload = json.loads(source_json.read_text())
+ payload["providerParity"] = [
+ {"providers": list(MODULE.PROVIDERS), "profiles": ["team-*"]}
+ ]
+ source_json.write_text(json.dumps(payload), encoding="utf-8")
+
+ result = MODULE.compose([str(source)], "engineer")
+ self.assertTrue(all(len(result[provider]) == 1 for provider in MODULE.PROVIDERS))
+
+ payload["profiles"]["glm"][0]["email"] = "wrong@example.com"
+ source_json.write_text(json.dumps(payload), encoding="utf-8")
+ with self.assertRaises(MODULE.ProfileError):
+ MODULE.compose([str(source)], "engineer")
+
+ def test_versioned_credential_contract_requires_account_and_secret_reference(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ personal = self.write_source(
+ root,
+ "user",
+ "engineer",
+ {
+ "claude": [
+ {
+ "name": "personal-1",
+ "email": "engineer@example.com",
+ "family": "personal",
+ "accountId": "engineer.claude.personal-1.account",
+ "credentialId": "engineer.claude.personal-1.credential",
+ "authentication": {
+ "type": "subscription_oauth",
+ "credentialRef": "ai/secrets/claude/personal-1.credentials.sops.yaml",
+ "escrowStatus": "not-escrowed",
+ },
+ }
+ ],
+ "openai": [],
+ },
+ )
+ payload = json.loads((personal / "source.json").read_text())
+ payload["credentialContractVersion"] = 1
+ (personal / "source.json").write_text(json.dumps(payload))
+ self.assertEqual(MODULE.compose([str(personal)], "engineer")["claude"][0]["name"], "personal-1")
+
+ del payload["profiles"]["claude"][0]["authentication"]
+ (personal / "source.json").write_text(json.dumps(payload))
+ with self.assertRaises(MODULE.ProfileError):
+ MODULE.compose([str(personal)], "engineer")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/credential-manager/test_profile_onboarding.py b/apps/credential-manager/test_profile_onboarding.py
new file mode 100644
index 0000000..9802db4
--- /dev/null
+++ b/apps/credential-manager/test_profile_onboarding.py
@@ -0,0 +1,151 @@
+import json
+import os
+import pathlib
+import subprocess
+import tempfile
+import unittest
+
+
+REPO = pathlib.Path(__file__).parents[2]
+SCRIPT = REPO / "scripts/onboard-ai-profiles.py"
+
+
+class ProfileOnboardingTest(unittest.TestCase):
+ def run_onboarding(self, home: pathlib.Path, answers: dict):
+ answer_file = home / "answers.json"
+ answer_file.write_text(json.dumps(answers))
+ output = home / ".config/workbenches"
+ result = subprocess.run(
+ [str(SCRIPT), "--answers", str(answer_file), "--output-dir", str(output)],
+ env={**os.environ, "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config")},
+ text=True,
+ capture_output=True,
+ )
+ return result, output
+
+ def test_manual_company_and_personal_profiles_cover_selected_providers(self):
+ with tempfile.TemporaryDirectory(dir="/tmp") as temporary:
+ home = pathlib.Path(temporary)
+ answers = {
+ "consent": True,
+ "githubUser": "engineer",
+ "companies": [
+ {
+ "name": "Example Company",
+ "email": "engineer@example.com",
+ "githubOrg": "example-company",
+ "providers": ["all"],
+ "registry": "manual",
+ }
+ ],
+ "personal": {
+ "githubOrg": "engineer",
+ "registry": "manual",
+ "accounts": [
+ {
+ "email": "person@example.net",
+ "providers": ["claude", "gpt"],
+ }
+ ],
+ },
+ }
+
+ result, output = self.run_onboarding(home, answers)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ expected = {"claude": 2, "openai": 2, "gemini": 1, "grok": 1, "glm": 1}
+ for provider, count in expected.items():
+ data = json.loads((output / f"{provider}-profiles.json").read_text())
+ self.assertEqual(len(data["profiles"]), count)
+ state = output / "ai-profile-onboarding.json"
+ self.assertEqual(state.stat().st_mode & 0o777, 0o600)
+ self.assertIn("Existing standard provider credential homes were preserved", result.stdout)
+
+ def test_registry_sources_are_composed_with_user_grants(self):
+ with tempfile.TemporaryDirectory(dir="/tmp") as temporary:
+ home = pathlib.Path(temporary)
+ tenant = home / "company-registry/ai"
+ personal = home / "personal-registry/ai"
+ (tenant / "grants/users").mkdir(parents=True)
+ personal.mkdir(parents=True)
+ (tenant / "source.json").write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "kind": "workbenches-ai-profile-source",
+ "owner": {"type": "tenant", "id": "example-company"},
+ "profiles": {
+ "claude": [
+ {
+ "name": "team-001",
+ "email": "team-001@example.com",
+ "family": "company",
+ }
+ ]
+ },
+ }
+ )
+ )
+ (tenant / "grants/users/engineer.json").write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "user": "engineer",
+ "profiles": {"claude": ["team-*"]},
+ }
+ )
+ )
+ (personal / "source.json").write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "kind": "workbenches-ai-profile-source",
+ "owner": {"type": "user", "id": "engineer"},
+ "profiles": {
+ "openai": [
+ {
+ "name": "personal-chatgpt",
+ "email": "person@example.net",
+ "family": "personal",
+ }
+ ]
+ },
+ }
+ )
+ )
+ answers = {
+ "consent": True,
+ "githubUser": "engineer",
+ "companies": [
+ {
+ "name": "Example Company",
+ "email": "engineer@example.com",
+ "githubOrg": "example-company",
+ "registry": str(tenant.parent),
+ }
+ ],
+ "personal": {
+ "githubOrg": "engineer",
+ "registry": str(personal.parent),
+ "accounts": [{"email": "person@example.net", "providers": ["openai"]}],
+ },
+ }
+
+ result, output = self.run_onboarding(home, answers)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ claude = json.loads((output / "claude-profiles.json").read_text())
+ openai = json.loads((output / "openai-profiles.json").read_text())
+ self.assertEqual([item["name"] for item in claude["profiles"]], ["team-001"])
+ self.assertEqual([item["name"] for item in openai["profiles"]], ["personal-chatgpt"])
+
+ def test_declined_consent_writes_nothing(self):
+ with tempfile.TemporaryDirectory(dir="/tmp") as temporary:
+ home = pathlib.Path(temporary)
+ result, output = self.run_onboarding(home, {"consent": False})
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertFalse(output.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/credential-manager/test_provider_account_normalizer.py b/apps/credential-manager/test_provider_account_normalizer.py
new file mode 100644
index 0000000..7975aa2
--- /dev/null
+++ b/apps/credential-manager/test_provider_account_normalizer.py
@@ -0,0 +1,60 @@
+import importlib.util
+import json
+import pathlib
+import sys
+import tempfile
+import unittest
+
+
+SCRIPT = pathlib.Path(__file__).parents[2] / "scripts" / "normalize-ai-provider-accounts.py"
+SPEC = importlib.util.spec_from_file_location("normalize_ai_provider_accounts", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+sys.modules[SPEC.name] = MODULE
+SPEC.loader.exec_module(MODULE)
+
+
+class ProviderAccountNormalizerTest(unittest.TestCase):
+ def test_normalizes_account_and_truthful_escrow_state(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ ai = root / "ai"
+ ai.mkdir()
+ source = ai / "source.json"
+ source.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "kind": "workbenches-ai-profile-source",
+ "owner": {"type": "tenant", "id": "example"},
+ "profiles": {
+ "claude": [
+ {
+ "name": "team-001",
+ "email": "team-001@example.com",
+ "family": "team",
+ }
+ ]
+ },
+ }
+ )
+ )
+
+ normalized = MODULE.normalize(source)
+ profile = normalized["profiles"]["claude"][0]
+ self.assertEqual(normalized["credentialContractVersion"], 1)
+ self.assertEqual(profile["accountId"], "example.claude.team-001.account")
+ self.assertEqual(profile["authentication"]["type"], "subscription_oauth")
+ self.assertEqual(profile["authentication"]["escrowStatus"], "not-escrowed")
+
+ secret = root / profile["authentication"]["credentialRef"]
+ secret.parent.mkdir(parents=True)
+ secret.write_text("ciphertext")
+ self.assertEqual(
+ MODULE.normalize(source)["profiles"]["claude"][0]["authentication"]["escrowStatus"],
+ "available",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/credential-manager/test_provider_profiles.py b/apps/credential-manager/test_provider_profiles.py
new file mode 100644
index 0000000..6ddfd71
--- /dev/null
+++ b/apps/credential-manager/test_provider_profiles.py
@@ -0,0 +1,87 @@
+import json
+import os
+import pathlib
+import subprocess
+import tempfile
+import unittest
+
+
+REPO = pathlib.Path(__file__).parents[2]
+
+
+class ProviderProfilesTest(unittest.TestCase):
+ def test_provider_launchers_isolate_state_and_resolve_aliases(self):
+ cases = {
+ "gemini": ("pgemini", "GEMINI_BIN", "GEMINI_CLI_HOME"),
+ "grok": ("pgrok", "GROK_BIN", "GROK_HOME"),
+ "glm": ("pglm", "OPENCODE_BIN", "XDG_DATA_HOME"),
+ }
+ with tempfile.TemporaryDirectory(dir="/tmp") as temporary:
+ root = pathlib.Path(temporary)
+ home = root / "home"
+ config = home / ".config/workbenches"
+ config.mkdir(parents=True)
+ fake = root / "fake-cli"
+ fake.write_text(
+ "#!/bin/sh\n"
+ 'printf "%s\\n" "${GEMINI_CLI_HOME:-${GROK_HOME:-${XDG_DATA_HOME:-}}}" > "$CAPTURE"\n'
+ )
+ fake.chmod(0o755)
+
+ for provider, (launcher, binary_env, expected_env) in cases.items():
+ manifest = config / f"{provider}-profiles.json"
+ manifest.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "profiles": [
+ {
+ "name": "team-001",
+ "email": "team-001@example.com",
+ "family": "company",
+ "aliases": ["team001"],
+ }
+ ],
+ }
+ )
+ )
+ env = {
+ **os.environ,
+ "HOME": str(home),
+ "XDG_CONFIG_HOME": str(home / ".config"),
+ binary_env: str(fake),
+ "CAPTURE": str(root / f"{provider}.capture"),
+ }
+ subprocess.run(
+ [
+ str(REPO / "scripts/setup-provider-profiles.sh"),
+ "--provider",
+ provider,
+ "--manifest",
+ str(manifest),
+ ],
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ subprocess.run(
+ [str(home / ".local/bin" / launcher), "team001"],
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ profile = home / f".{provider}-profiles/profiles/team-001"
+ expected = profile if expected_env != "XDG_DATA_HOME" else profile / "xdg/data"
+ self.assertEqual((root / f"{provider}.capture").read_text().strip(), str(expected))
+ self.assertEqual(profile.stat().st_mode & 0o777, 0o700, provider)
+ self.assertEqual(
+ (profile / ".profile.json").stat().st_mode & 0o777,
+ 0o600,
+ provider,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/base-image/Dockerfile b/base-image/Dockerfile
index 0a4b607..344df0c 100644
--- a/base-image/Dockerfile
+++ b/base-image/Dockerfile
@@ -1,3 +1,4 @@
+# syntax=docker/dockerfile:1.7
# Layer 0: System Base Image
# Ubuntu 24.04 with system tools and AI coding CLIs
# Used by ALL benches (Frappe, Flutter, .NET, Bio, Admin, etc.)
@@ -9,7 +10,7 @@ FROM ubuntu:24.04
# Container version labels
LABEL layer="0"
LABEL layer.name="workbench-base"
-LABEL layer.version="2.0.0"
+LABEL layer.version="2.0.3"
LABEL layer.description="System base with AI CLIs for all workBenches (user-agnostic)"
# Everything runs as root — no user in this layer
@@ -65,23 +66,28 @@ RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
# GITHUB CLI
# ========================================
-RUN for attempt in 1 2 3; do \
- curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /tmp/githubcli-archive-keyring.gpg && break; \
- if [ "$attempt" = "3" ]; then exit 1; fi; \
- sleep $((attempt * 5)); \
- done \
- && gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg /tmp/githubcli-archive-keyring.gpg \
- && rm -f /tmp/githubcli-archive-keyring.gpg \
- && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages focal main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
- && for attempt in 1 2 3; do \
- apt-get update \
- && apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=60 -o Acquire::https::Timeout=60 -y install gh \
- && break; \
- if [ "$attempt" = "3" ]; then exit 1; fi; \
- sleep $((attempt * 10)); \
- done \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/*
+ARG GH_VERSION=2.96.0
+RUN set -eux; \
+ gh_version="${GH_VERSION}"; \
+ if [ "$gh_version" = "latest" ]; then \
+ gh_version="$(curl --http1.1 -fsSL --retry 3 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 60 https://api.github.com/repos/cli/cli/releases/latest | jq -r '.tag_name // empty' | sed 's/^v//')"; \
+ fi; \
+ if [ -z "$gh_version" ] || [ "$gh_version" = "null" ]; then \
+ gh_version="2.96.0"; \
+ fi; \
+ case "$(dpkg --print-architecture)" in \
+ amd64) gh_arch="amd64" ;; \
+ arm64) gh_arch="arm64" ;; \
+ armhf) gh_arch="armv6" ;; \
+ 386) gh_arch="386" ;; \
+ *) echo "Unsupported architecture for GitHub CLI: $(dpkg --print-architecture)" >&2; exit 1 ;; \
+ esac; \
+ gh_url="https://github.com/cli/cli/releases/download/v${gh_version}/gh_${gh_version}_linux_${gh_arch}.tar.gz"; \
+ timeout 900s wget -q --https-only --timeout=30 --tries=3 --waitretry=2 -O /tmp/gh.tar.gz "$gh_url" \
+ || curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 90 -o /tmp/gh.tar.gz "$gh_url"; \
+ tar -xzf /tmp/gh.tar.gz -C /tmp; \
+ install -m 0755 "/tmp/gh_${gh_version}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \
+ rm -rf /tmp/gh.tar.gz "/tmp/gh_${gh_version}_linux_${gh_arch}"
# ========================================
# DOCKER CLI & COMPOSE
@@ -112,18 +118,18 @@ RUN YQ_VERSION=$(curl --retry 5 --retry-all-errors --connect-timeout 30 -fsSL ht
# Install zoxide (smarter cd) - install to /usr/local/bin
RUN curl --retry 5 --retry-all-errors --connect-timeout 30 -fsSL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash -s -- --bin-dir /usr/local/bin
-# Install Node.js 22 for modern AI CLI builds and npm-based tools.
+# Install Node.js 24 LTS for modern AI CLI builds and npm-based tools.
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
- && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+ && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable || true
-# Install tldr (simplified man pages) - using npm version
-RUN npm install -g tldr \
+# Install npm itself and tldr (simplified man pages) from npm's latest dist-tags.
+RUN npm install -g npm@latest tldr \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -134,15 +140,20 @@ RUN npm install -g tldr \
# Install uv to /usr/local/bin (system-wide)
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
-# Install spec-kit system-wide
-RUN uv tool install specify-cli --from git+https://github.com/github/spec-kit.git --python-preference system \
- || echo "spec-kit installation skipped (non-fatal)"
+# Ensure spec-driven CLIs are not inherited from cached Layer 0 state.
+RUN npm uninstall -g @fission-ai/openspec || true \
+ && rm -f /usr/bin/openspec /usr/local/bin/openspec \
+ && rm -rf /usr/lib/node_modules/@fission-ai/openspec /usr/local/lib/node_modules/@fission-ai/openspec \
+ && uv tool uninstall specify-cli || true \
+ && env UV_TOOL_BIN_DIR=/usr/local/bin UV_TOOL_DIR=/opt/uv/tools uv tool uninstall specify-cli || true \
+ && rm -f /root/.local/bin/specify /usr/local/bin/specify \
+ && rm -rf /root/.local/share/uv/tools/specify-cli /opt/uv/tools/specify-cli
# ========================================
# BUN RUNTIME (system-wide at /opt/bun)
# ========================================
-ARG BUN_VERSION=1.3.5
+ARG BUN_VERSION=1.3.14
ENV BUN_INSTALL=/opt/bun
RUN curl -fsSL https://bun.sh/install | BUN_VERSION=${BUN_VERSION} bash \
&& chmod -R a+rx /opt/bun
@@ -154,30 +165,79 @@ ENV PATH="/opt/bun/bin:${PATH}"
# Copy and run AI CLI installation script
COPY install-ai-clis.sh /tmp/
-ARG AI_CLI_REFRESH=2026-06-18
+ARG AI_CLI_REFRESH=2026-07-12
RUN echo "AI CLI refresh marker: ${AI_CLI_REFRESH}"
-RUN bash /tmp/install-ai-clis.sh && rm /tmp/install-ai-clis.sh
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=cache,target=/root/.npm \
+ bash /tmp/install-ai-clis.sh && rm /tmp/install-ai-clis.sh
+
+# Provider-specific multi-account launchers. Credential profiles are host state
+# mounted under provider-specific profile roots; only launchers belong in the
+# image.
+COPY files/claude-profile /usr/local/bin/claude-profile
+COPY files/codex-profile /usr/local/bin/codex-profile
+COPY files/provider-profile /usr/local/bin/provider-profile
+COPY files/claude-statusline-command.sh /usr/local/share/workbenches/claude/statusline-command.sh
+RUN chmod 0755 /usr/local/bin/claude-profile \
+ /usr/local/bin/codex-profile \
+ /usr/local/bin/provider-profile \
+ /usr/local/share/workbenches/claude/statusline-command.sh \
+ && ln -sfn claude-profile /usr/local/bin/pclaude \
+ && ln -sfn codex-profile /usr/local/bin/pcodex \
+ && ln -sfn provider-profile /usr/local/bin/gemini-profile \
+ && ln -sfn provider-profile /usr/local/bin/pgemini \
+ && ln -sfn provider-profile /usr/local/bin/grok-profile \
+ && ln -sfn provider-profile /usr/local/bin/pgrok \
+ && ln -sfn provider-profile /usr/local/bin/glm-profile \
+ && ln -sfn provider-profile /usr/local/bin/zai-profile \
+ && ln -sfn provider-profile /usr/local/bin/pglm \
+ && ln -sfn provider-profile /usr/local/bin/pzai \
+ && mkdir -p /etc/skel/.local/bin \
+ && ln -sfn /usr/local/bin/claude /etc/skel/.local/bin/claude
# ========================================
# ZSH CONFIGURATION (into /etc/skel for future users)
# ========================================
-# Install Oh-My-Zsh into /etc/skel. Use direct clones because the installer
-# wraps git clone and gives us no retry control in flaky networks.
-RUN for attempt in 1 2 3; do \
- git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git /etc/skel/.oh-my-zsh && break; \
- rm -rf /etc/skel/.oh-my-zsh; \
- if [ "$attempt" = "3" ]; then exit 1; fi; \
- sleep $((attempt * 5)); \
- done
-
-# Install Powerlevel10k theme
-RUN for attempt in 1 2 3; do \
- git clone --depth=1 https://github.com/romkatv/powerlevel10k.git /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k && break; \
- rm -rf /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k; \
- if [ "$attempt" = "3" ]; then exit 1; fi; \
- sleep $((attempt * 5)); \
- done
+# Install Oh-My-Zsh into /etc/skel from a bounded tarball download.
+RUN mkdir -p /etc/skel/.oh-my-zsh \
+ && timeout 1800s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 1200 \
+ -o /tmp/ohmyzsh.tar.gz https://github.com/ohmyzsh/ohmyzsh/archive/refs/heads/master.tar.gz \
+ && tar -xzf /tmp/ohmyzsh.tar.gz -C /etc/skel/.oh-my-zsh --strip-components=1 \
+ && rm -f /tmp/ohmyzsh.tar.gz /root/.zshrc
+
+# Install Powerlevel10k theme from a bounded tarball download.
+RUN mkdir -p /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k \
+ && timeout 1800s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 1200 \
+ -o /tmp/powerlevel10k.tar.gz https://github.com/romkatv/powerlevel10k/archive/refs/heads/master.tar.gz \
+ && tar -xzf /tmp/powerlevel10k.tar.gz -C /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k --strip-components=1 \
+ && rm -f /tmp/powerlevel10k.tar.gz
+
+# Overlay latest upstream binaries for CLI tools where Ubuntu apt lags.
+RUN set -eux; \
+ case "$(dpkg --print-architecture)" in \
+ amd64) nvim_arch="x86_64"; fzf_arch="amd64"; jq_arch="amd64" ;; \
+ arm64) nvim_arch="arm64"; fzf_arch="arm64"; jq_arch="arm64" ;; \
+ *) echo "Unsupported architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
+ esac; \
+ nvim_tag="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 https://api.github.com/repos/neovim/neovim/releases/latest | jq -r '.tag_name')"; \
+ timeout 3600s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 3600 \
+ -o /tmp/nvim.tar.gz "https://github.com/neovim/neovim/releases/download/${nvim_tag}/nvim-linux-${nvim_arch}.tar.gz"; \
+ rm -rf /opt/nvim; \
+ tar -xzf /tmp/nvim.tar.gz -C /opt; \
+ mv "/opt/nvim-linux-${nvim_arch}" /opt/nvim; \
+ ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim; \
+ fzf_tag="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 https://api.github.com/repos/junegunn/fzf/releases/latest | jq -r '.tag_name')"; \
+ fzf_version="${fzf_tag#v}"; \
+ timeout 3600s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 3600 \
+ -o /tmp/fzf.tar.gz "https://github.com/junegunn/fzf/releases/download/${fzf_tag}/fzf-${fzf_version}-linux_${fzf_arch}.tar.gz"; \
+ tar -xzf /tmp/fzf.tar.gz -C /tmp; \
+ install -m 0755 /tmp/fzf /usr/local/bin/fzf; \
+ jq_tag="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 https://api.github.com/repos/jqlang/jq/releases/latest | jq -r '.tag_name')"; \
+ timeout 3600s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 3600 \
+ -o /usr/local/bin/jq "https://github.com/jqlang/jq/releases/download/${jq_tag}/jq-linux-${jq_arch}"; \
+ chmod 0755 /usr/local/bin/jq; \
+ rm -f /tmp/nvim.tar.gz /tmp/fzf.tar.gz /tmp/fzf
# Create /etc/skel/.zshrc template
RUN echo 'export ZSH="$HOME/.oh-my-zsh"' > /etc/skel/.zshrc && \
@@ -201,14 +261,139 @@ RUN echo 'eval "$(zoxide init bash)"' >> /etc/skel/.bashrc && \
echo 'export PATH="/opt/grok/bin:$HOME/.local/bin:/opt/bun/bin:$PATH"' >> /etc/skel/.bashrc && \
echo '[[ -r /opt/grok/completions/bash/grok.bash ]] && source /opt/grok/completions/bash/grok.bash' >> /etc/skel/.bashrc
-# Provide AI helper aliases system-wide so all benches inherit them even when
+# Provide AI helpers system-wide so all benches inherit them even when
# a workspace does not mount a host shell profile.
-RUN echo '' >> /etc/zsh/zshrc && \
- echo '# WorkBench AI helpers' >> /etc/zsh/zshrc && \
- echo 'alias yolo="claude --dangerously-skip-permissions --teammate-mode tmux"' >> /etc/zsh/zshrc && \
- echo '' >> /etc/bash.bashrc && \
- echo '# WorkBench AI helpers' >> /etc/bash.bashrc && \
- echo 'alias yolo="claude --dangerously-skip-permissions --teammate-mode tmux"' >> /etc/bash.bashrc
+RUN cat <<'EOF' >> /etc/zsh/zshrc
+
+# WorkBench AI helpers
+_yolo_shell_quote() {
+ local quoted="" arg
+
+ for arg in "$@"; do
+ quoted="${quoted} $(printf '%q' "$arg")"
+ done
+
+ printf '%s\n' "${quoted# }"
+}
+
+unalias yolo 2>/dev/null || true
+yolo() {
+ local session_name command_string prompt_file
+ local -a prompt_args
+
+ if ! command -v claude >/dev/null 2>&1; then
+ echo "yolo: Claude CLI not found on PATH" >&2
+ return 1
+ fi
+
+ if ! command -v tmux >/dev/null 2>&1; then
+ echo "yolo: tmux not found on PATH" >&2
+ return 1
+ fi
+
+ prompt_file=""
+ for candidate_prompt_file in \
+ "$HOME/.claude/prompts/speckit-dashboard-full.md" \
+ "/usr/local/share/ct/claude/prompts/speckit-dashboard-full.md" \
+ "$HOME/.claude/prompts/speckit-dashboard-bootstrap.md"; do
+ if [ -r "$candidate_prompt_file" ]; then
+ prompt_file="$candidate_prompt_file"
+ break
+ fi
+ done
+ prompt_args=()
+ if [ -n "$prompt_file" ]; then
+ prompt_args=(--append-system-prompt-file "$prompt_file")
+ fi
+
+ if [ -n "${TMUX:-}" ]; then
+ claude --dangerously-skip-permissions --teammate-mode tmux "${prompt_args[@]}" "$@"
+ return $?
+ fi
+
+ session_name="yolo-$(date +%Y%m%d%H%M%S)-$$"
+ command_string=$(_yolo_shell_quote \
+ claude \
+ --dangerously-skip-permissions \
+ --teammate-mode tmux \
+ "${prompt_args[@]}" \
+ "$@") || return 1
+
+ tmux new-session -d -s "$session_name" -c "$PWD" "exec $command_string" || {
+ echo "yolo: failed to start tmux session" >&2
+ return 1
+ }
+
+ tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true
+ tmux attach-session -t "$session_name"
+}
+EOF
+
+RUN cat <<'EOF' >> /etc/bash.bashrc
+
+# WorkBench AI helpers
+_yolo_shell_quote() {
+ local quoted="" arg
+
+ for arg in "$@"; do
+ quoted="${quoted} $(printf '%q' "$arg")"
+ done
+
+ printf '%s\n' "${quoted# }"
+}
+
+unalias yolo 2>/dev/null || true
+yolo() {
+ local session_name command_string prompt_file
+ local -a prompt_args
+
+ if ! command -v claude >/dev/null 2>&1; then
+ echo "yolo: Claude CLI not found on PATH" >&2
+ return 1
+ fi
+
+ if ! command -v tmux >/dev/null 2>&1; then
+ echo "yolo: tmux not found on PATH" >&2
+ return 1
+ fi
+
+ prompt_file=""
+ for candidate_prompt_file in \
+ "$HOME/.claude/prompts/speckit-dashboard-full.md" \
+ "/usr/local/share/ct/claude/prompts/speckit-dashboard-full.md" \
+ "$HOME/.claude/prompts/speckit-dashboard-bootstrap.md"; do
+ if [ -r "$candidate_prompt_file" ]; then
+ prompt_file="$candidate_prompt_file"
+ break
+ fi
+ done
+ prompt_args=()
+ if [ -n "$prompt_file" ]; then
+ prompt_args=(--append-system-prompt-file "$prompt_file")
+ fi
+
+ if [ -n "${TMUX:-}" ]; then
+ claude --dangerously-skip-permissions --teammate-mode tmux "${prompt_args[@]}" "$@"
+ return $?
+ fi
+
+ session_name="yolo-$(date +%Y%m%d%H%M%S)-$$"
+ command_string=$(_yolo_shell_quote \
+ claude \
+ --dangerously-skip-permissions \
+ --teammate-mode tmux \
+ "${prompt_args[@]}" \
+ "$@") || return 1
+
+ tmux new-session -d -s "$session_name" -c "$PWD" "exec $command_string" || {
+ echo "yolo: failed to start tmux session" >&2
+ return 1
+ }
+
+ tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true
+ tmux attach-session -t "$session_name"
+}
+EOF
# ========================================
# OPENCODE CONFIGURATION (into /etc/skel)
@@ -221,22 +406,11 @@ COPY files/opencode/oh-my-opencode.json /etc/skel/.config/opencode/
COPY files/opencode/agent/ /etc/skel/.config/opencode/agent/
COPY files/opencode/context/ /etc/skel/.config/opencode/context/
-# ========================================
-# OPSX COMMANDS & SKILLS (Claude Code, into /etc/skel)
-# ========================================
-# Upgraded OpenSpec workflows with agent team orchestration.
-# Uses opsx-* / opsx: prefix — openspec init/update won't overwrite these.
-
-RUN mkdir -p /etc/skel/.claude/commands/opsx \
- && mkdir -p /etc/skel/.claude/skills/opsx-clarify \
- && mkdir -p /etc/skel/.claude/skills/opsx-analyze
-COPY files/claude/commands/opsx/ /etc/skel/.claude/commands/opsx/
-COPY files/claude/skills/opsx-clarify/ /etc/skel/.claude/skills/opsx-clarify/
-COPY files/claude/skills/opsx-analyze/ /etc/skel/.claude/skills/opsx-analyze/
-
# ========================================
# SPECKIT SKILLS (Claude Code, into /etc/skel)
# ========================================
+# OpenSpec/opsx commands are installed with the devBench OpenSpec layer, where
+# the openspec CLI is present. Layer 0 keeps only non-OpenSpec Claude defaults.
# Agent-team-enhanced Speckit workflows. Each skill upgrades the corresponding
# project-level /speckit.* command with parallel specialist agents.
# Uses speckit-* prefix — speckit init/update won't overwrite these.
diff --git a/base-image/build.sh b/base-image/build.sh
index a5e96da..cb23a74 100755
--- a/base-image/build.sh
+++ b/base-image/build.sh
@@ -13,20 +13,24 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Parse arguments (--user is accepted but ignored for backward compat)
+NO_CACHE="${NO_CACHE:-false}"
while [[ $# -gt 0 ]]; do
case $1 in
--user) shift 2 ;;
+ --no-cache) NO_CACHE=true; shift ;;
*) shift ;;
esac
done
echo "Configuration:"
echo " Tag: workbench-base:latest (user-agnostic)"
+echo " No cache: $NO_CACHE"
echo ""
# Build the image
echo "Building workbench-base:latest..."
docker build \
+ $([ "$NO_CACHE" = true ] && printf '%s\n' "--no-cache") \
-t "workbench-base:latest" \
.
diff --git a/base-image/files/claude-profile b/base-image/files/claude-profile
new file mode 100755
index 0000000..889ccba
--- /dev/null
+++ b/base-image/files/claude-profile
@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+manifest="${CLAUDE_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/claude-profiles.json}"
+base="${CLAUDE_PROFILES_HOME:-$HOME/.claude-profiles}"
+command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; }
+
+configure_profile_runtime() {
+ local config_dir="$1" statusline_source statusline_link settings settings_tmp statusline_command default_model
+
+ statusline_source="$base/shared/statusline-command.sh"
+ if [[ ! -f "$statusline_source" && -f /usr/local/share/workbenches/claude/statusline-command.sh ]]; then
+ statusline_source=/usr/local/share/workbenches/claude/statusline-command.sh
+ fi
+
+ if [[ -f "$statusline_source" ]]; then
+ statusline_link="$config_dir/statusline-command.sh"
+ if [[ -L "$statusline_link" || ! -e "$statusline_link" ]]; then
+ ln -sfn "$statusline_source" "$statusline_link"
+ fi
+ fi
+
+ settings="$config_dir/settings.json"
+ if [[ -e "$settings" ]] && ! jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
+ echo "Claude profile settings are not valid JSON: $settings" >&2
+ return 1
+ fi
+
+ statusline_command='bash "${CLAUDE_CONFIG_DIR}/statusline-command.sh"'
+ default_model='claude-fable-5'
+ settings_tmp="$(mktemp "$config_dir/.settings.XXXXXX.tmp")"
+ if [[ -f "$settings" ]]; then
+ jq --arg command "$statusline_command" --arg model "$default_model" '
+ .statusLine = {type: "command", command: $command, refreshInterval: 10}
+ | .permissions = (.permissions // {})
+ | .permissions.defaultMode = "bypassPermissions"
+ | .effortLevel = (.effortLevel // "xhigh")
+ | .model = $model
+ ' "$settings" > "$settings_tmp"
+ else
+ jq -n --arg command "$statusline_command" --arg model "$default_model" '{
+ statusLine: {type: "command", command: $command, refreshInterval: 10},
+ permissions: {defaultMode: "bypassPermissions"},
+ effortLevel: "xhigh",
+ model: $model
+ }' > "$settings_tmp"
+ fi
+ chmod 600 "$settings_tmp"
+ mv -f "$settings_tmp" "$settings"
+}
+
+claude_bin="${CLAUDE_BIN:-$(command -v claude || true)}"
+if [[ -z "$claude_bin" && -x "$HOME/.local/bin/claude" ]]; then
+ claude_bin="$HOME/.local/bin/claude"
+fi
+
+# Native Claude records ~/.local/bin/claude as its expected installation path.
+# Bench images install the immutable CLI in /usr/local/bin, so provide a
+# per-user compatibility link to keep Claude's installer health check accurate.
+if [[ "$claude_bin" == "/usr/local/bin/claude" ]]; then
+ if mkdir -p "$HOME/.local/bin" 2>/dev/null; then
+ if [[ ! -e "$HOME/.local/bin/claude" && ! -L "$HOME/.local/bin/claude" ]]; then
+ ln -s /usr/local/bin/claude "$HOME/.local/bin/claude"
+ elif [[ -L "$HOME/.local/bin/claude" && ! -e "$HOME/.local/bin/claude" ]]; then
+ ln -sfn /usr/local/bin/claude "$HOME/.local/bin/claude"
+ fi
+ fi
+fi
+
+profile_metadata() {
+ local requested="$1" normalized row metadata candidate
+ normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')"
+ if [[ -f "$manifest" ]]; then
+ row="$(jq -r --arg name "$normalized" \
+ '.profiles[]
+ | select(
+ ((.name | ascii_downcase) == $name)
+ or any(.aliases[]?; (ascii_downcase == $name))
+ )
+ | [.name,.email,.family] | @tsv' \
+ "$manifest" | head -n 1)"
+ if [[ -n "$row" ]]; then
+ printf '%s\n' "$row"
+ return 0
+ fi
+ fi
+
+ for metadata in "$base"/profiles/*/.profile.json; do
+ [[ -f "$metadata" ]] || continue
+ candidate="$(jq -r --arg name "$normalized" '
+ if (((.name // "") | ascii_downcase) == $name)
+ or any(.aliases[]?; (ascii_downcase == $name))
+ then "match" else empty end
+ ' "$metadata")"
+ if [[ "$candidate" == "match" ]]; then
+ jq -r '[.name,.email,.family] | @tsv' "$metadata"
+ return 0
+ fi
+ done
+ return 1
+}
+
+action="${1:-list}"
+if [[ "$action" == "list" || "$action" == "login" || "$action" == "status" || "$action" == "run" ]]; then
+ [[ $# -eq 0 ]] || shift
+else
+ action="run"
+fi
+
+case "$action" in
+ list)
+ {
+ if [[ -f "$manifest" ]]; then
+ jq -r '.profiles[] | [.name, .family, .email, ((.aliases // []) | join(","))] | @tsv' "$manifest"
+ fi
+ for metadata in "$base"/profiles/*/.profile.json; do
+ [[ -f "$metadata" ]] || continue
+ jq -r '[.name,.family,.email,((.aliases // []) | join(","))] | @tsv' "$metadata"
+ done
+ } | awk -F '\t' '!seen[tolower($1)]++'
+ ;;
+ login|status|run)
+ profile="${1:?Usage: claude-profile $action PROFILE [arguments]}"; shift
+ row="$(profile_metadata "$profile" || true)"
+ [[ -n "$row" ]] || { echo "Unknown Claude profile: $profile" >&2; exit 2; }
+ IFS=$'\t' read -r profile email family <<<"$row"
+ config_dir="$base/profiles/$profile"
+ [[ -n "$claude_bin" ]] || { echo "Claude CLI not found." >&2; exit 1; }
+ configure_profile_runtime "$config_dir"
+ case "$action" in
+ login) CLAUDE_CONFIG_DIR="$config_dir" "$claude_bin" auth login --claudeai --email "$email" "$@" ;;
+ status) CLAUDE_CONFIG_DIR="$config_dir" "$claude_bin" auth status "${@---text}" ;;
+ run) CLAUDE_CONFIG_DIR="$config_dir" exec "$claude_bin" \
+ --allow-dangerously-skip-permissions \
+ --dangerously-skip-permissions \
+ --permission-mode bypassPermissions \
+ "$@" ;;
+ esac
+ ;;
+ *) echo "Usage: claude-profile {list|login|status|run} [PROFILE]" >&2; exit 2 ;;
+esac
diff --git a/base-image/files/claude-statusline-command.sh b/base-image/files/claude-statusline-command.sh
new file mode 100644
index 0000000..fcf70d6
--- /dev/null
+++ b/base-image/files/claude-statusline-command.sh
@@ -0,0 +1,359 @@
+#!/usr/bin/env bash
+# Two-line Claude Code status line. Claude supplies session data as JSON on stdin.
+
+input=$(cat)
+if ! jq -e . >/dev/null 2>&1 <<<"$input"; then
+ exit 0
+fi
+
+# Parse the input once. Missing fields intentionally become empty strings.
+mapfile -t data < <(jq -r '[
+ (.workspace.current_dir // .cwd // ""),
+ (.workspace.repo.name // ""),
+ (.workspace.git_worktree // .worktree.name // ""),
+ (.worktree.branch // ""),
+ (.model.display_name // .model.id // ""),
+ (.context_window.used_percentage // 0),
+ (.effort.level // ""),
+ (.thinking.enabled // ""),
+ (.rate_limits.five_hour.used_percentage // ""),
+ (.rate_limits.five_hour.resets_at // ""),
+ (.rate_limits.seven_day.used_percentage // ""),
+ (.rate_limits.seven_day.resets_at // ""),
+ (.session_name // ""),
+ (.agent.name // ""),
+ (.pr.number // ""),
+ (.pr.review_state // ""),
+ (.permission_mode // .permissions.mode // "")
+] | .[]' <<<"$input")
+
+cwd=${data[0]:-}
+repo_name=${data[1]:-}
+worktree_name=${data[2]:-}
+worktree_branch=${data[3]:-}
+model=${data[4]:-}
+used_pct=${data[5]:-}
+effort=${data[6]:-}
+thinking=${data[7]:-}
+five_hour_pct=${data[8]:-}
+five_hour_reset=${data[9]:-}
+seven_day_pct=${data[10]:-}
+seven_day_reset=${data[11]:-}
+session_name=${data[12]:-}
+agent_name=${data[13]:-}
+pr_number=${data[14]:-}
+pr_state=${data[15]:-}
+permission_mode=${data[16]:-}
+
+columns=${COLUMNS:-120}
+[[ $columns =~ ^[0-9]+$ ]] || columns=120
+
+reset=$'\033[0m'
+dim=$'\033[2m'
+cyan=$'\033[36m'
+yellow=$'\033[33m'
+magenta=$'\033[35m'
+blue=$'\033[34m'
+green=$'\033[32m'
+red=$'\033[31m'
+sep="${dim}|${reset}"
+
+join_parts() {
+ local out="" part
+ for part in "$@"; do
+ [[ -z $part ]] && continue
+ if [[ -z $out ]]; then
+ out=$part
+ else
+ out+=" $sep $part"
+ fi
+ done
+ printf '%s' "$out"
+}
+
+truncate_middle() {
+ local value=$1 max=$2 side
+ if (( ${#value} <= max )); then
+ printf '%s' "$value"
+ return
+ fi
+ side=$(( (max - 3) / 2 ))
+ printf '%s...%s' "${value:0:side}" "${value: -side}"
+}
+
+pct_integer() {
+ local value=$1 rounded
+ rounded=$(printf '%.0f' "$value" 2>/dev/null) || rounded=0
+ (( rounded < 0 )) && rounded=0
+ (( rounded > 100 )) && rounded=100
+ printf '%s' "$rounded"
+}
+
+pct_color() {
+ local value=$1
+ if (( value >= 80 )); then
+ printf '%s' "$red"
+ elif (( value >= 60 )); then
+ printf '%s' "$yellow"
+ else
+ printf '%s' "$green"
+ fi
+}
+
+context_segment() {
+ local pct color filled empty bar_width bar empty_bar
+ pct=$(pct_integer "$used_pct")
+ color=$(pct_color "$pct")
+ bar_width=$(( columns >= 100 ? 12 : 8 ))
+ filled=$(( pct * bar_width / 100 ))
+ empty=$(( bar_width - filled ))
+ printf -v bar '%*s' "$filled" ''
+ bar=${bar// /=}
+ printf -v empty_bar '%*s' "$empty" ''
+ empty_bar=${empty_bar// /-}
+ printf '%s[%s%s] %s%%%s' "$color" "$bar" "$empty_bar" "$pct" "$reset"
+}
+
+rate_segment() {
+ local label=$1 raw_pct=$2 reset_at=$3 pct color reset_text="" bar_width filled empty bar empty_bar
+ bar_width=$(( columns >= 100 ? 10 : 8 ))
+ if [[ -z $raw_pct ]]; then
+ printf -v empty_bar '%*s' "$bar_width" ''
+ empty_bar=${empty_bar// /-}
+ printf '%s%s [%s] --%s' "$dim" "$label" "$empty_bar" "$reset"
+ return
+ fi
+ pct=$(pct_integer "$raw_pct")
+ color=$(pct_color "$pct")
+ filled=$(( pct * bar_width / 100 ))
+ empty=$(( bar_width - filled ))
+ printf -v bar '%*s' "$filled" ''
+ bar=${bar// /=}
+ printf -v empty_bar '%*s' "$empty" ''
+ empty_bar=${empty_bar// /-}
+ if (( pct >= 80 )) && [[ $reset_at =~ ^[0-9]+$ ]]; then
+ reset_text=$(date -d "@$reset_at" '+ %H:%M' 2>/dev/null || true)
+ fi
+ printf '%s%s [%s%s] %s%%%s%s' "$color" "$label" "$bar" "$empty_bar" "$pct" "$reset" "$reset_text"
+}
+
+repeat_char() {
+ local char=$1 count=$2 value
+ (( count <= 0 )) && return
+ printf -v value '%*s' "$count" ''
+ printf '%s' "${value// /$char}"
+}
+
+countdown_segment() {
+ local label=$1 reset_at=$2 window_seconds=$3 unit=$4
+ local now remaining elapsed bar_width marker_pos bar color amount reset_text
+
+ bar_width=$(( columns >= 100 ? 8 : 6 ))
+ if [[ ! $reset_at =~ ^[0-9]+$ ]]; then
+ bar=$(repeat_char '-' "$bar_width")
+ printf '%s%s [%s] waiting%s' "$dim" "$label" "$bar" "$reset"
+ return
+ fi
+
+ now=$(date +%s)
+ remaining=$(( reset_at - now ))
+ (( remaining < 0 )) && remaining=0
+ elapsed=$(( window_seconds - remaining ))
+ (( elapsed < 0 )) && elapsed=0
+ (( elapsed > window_seconds )) && elapsed=$window_seconds
+ marker_pos=$(( elapsed * (bar_width - 1) / window_seconds ))
+ bar="$(repeat_char '=' "$marker_pos")>$(repeat_char '-' "$((bar_width - marker_pos - 1))")"
+
+ if (( remaining * 100 <= window_seconds * 15 )); then
+ color=$red
+ elif (( remaining * 100 <= window_seconds * 40 )); then
+ color=$yellow
+ else
+ color=$green
+ fi
+
+ if [[ $unit == minutes ]]; then
+ amount="$(( (remaining + 59) / 60 ))m"
+ reset_text=$(TZ="${STATUSLINE_TZ:-America/Los_Angeles}" date -d "@$reset_at" '+%-I:%M%p %Z' 2>/dev/null || true)
+ else
+ if (( remaining >= 86400 )); then
+ amount="$(( remaining / 86400 ))d"
+ else
+ amount="$(( (remaining + 3599) / 3600 ))h"
+ fi
+ if (( columns >= 100 )); then
+ reset_text=$(TZ="${STATUSLINE_TZ:-America/Los_Angeles}" date -d "@$reset_at" '+%a %b %d %-I:%M%p %Z' 2>/dev/null || true)
+ else
+ reset_text=$(TZ="${STATUSLINE_TZ:-America/Los_Angeles}" date -d "@$reset_at" '+%b %d %-I:%M%p' 2>/dev/null || true)
+ fi
+ fi
+
+ printf '%s%s [%s] %s left%s %s@ %s%s' "$color" "$label" "$bar" "$amount" "$reset" "$dim" "$reset_text" "$reset"
+}
+
+fable_weekly_limit() {
+ local config_dir credentials cache now modified token tmp response_pct response_reset
+
+ # The status-line payload currently exposes only the aggregate seven-day
+ # limit. Claude's own read-only usage endpoint also returns scoped weekly
+ # limits, including Fable. Cache it because this script refreshes often.
+ config_dir=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
+ credentials="$config_dir/.credentials.json"
+ cache="$config_dir/cache/statusline-usage.json"
+ now=$(date +%s)
+ modified=0
+ [[ -f $cache ]] && modified=$(stat -c %Y "$cache" 2>/dev/null || printf '0')
+
+ if (( now - modified >= 60 )) && [[ -r $credentials ]] && command -v curl >/dev/null 2>&1; then
+ token=$(jq -r '.claudeAiOauth.accessToken // empty' "$credentials" 2>/dev/null)
+ if [[ -n $token ]]; then
+ mkdir -p "${cache%/*}"
+ tmp="$cache.$$"
+ if curl -fsS --connect-timeout 1 --max-time 3 \
+ 'https://api.anthropic.com/api/oauth/usage' \
+ -H "Authorization: Bearer $token" \
+ -H 'anthropic-version: 2023-06-01' \
+ -H 'anthropic-beta: oauth-2025-04-20' \
+ -o "$tmp" \
+ && jq -e '.limits | type == "array"' "$tmp" >/dev/null 2>&1; then
+ chmod 600 "$tmp"
+ mv -f "$tmp" "$cache"
+ else
+ rm -f "$tmp"
+ fi
+ fi
+ fi
+
+ [[ -r $cache ]] || return 0
+ response_pct=$(jq -r '
+ [.limits[]?
+ | select(.kind == "weekly_scoped")
+ | select((.scope.model.display_name // "" | ascii_downcase) == "fable")
+ | .percent] | first // empty
+ ' "$cache" 2>/dev/null)
+ response_reset=$(jq -r '
+ [.limits[]?
+ | select(.kind == "weekly_scoped")
+ | select((.scope.model.display_name // "" | ascii_downcase) == "fable")
+ | .resets_at] | first // empty
+ ' "$cache" 2>/dev/null)
+ if [[ -n $response_reset ]]; then
+ response_reset=$(date -d "$response_reset" +%s 2>/dev/null || printf '')
+ fi
+ [[ -n $response_pct ]] && printf '%s\t%s\n' "$response_pct" "$response_reset"
+}
+
+# Build a compact path. Narrow terminals favor repository and leaf names.
+short_cwd=${cwd/#$HOME/\~}
+if (( columns < 100 )); then
+ leaf=${cwd##*/}
+ if [[ -n $repo_name && $repo_name != "$leaf" ]]; then
+ short_cwd="$repo_name/$leaf"
+ elif [[ -n $repo_name ]]; then
+ short_cwd=$repo_name
+ else
+ short_cwd=$leaf
+ fi
+fi
+path_max=$(( columns >= 120 ? 52 : (columns >= 80 ? 34 : 24) ))
+short_cwd=$(truncate_middle "$short_cwd" "$path_max")
+
+# One bounded Git command supplies branch, dirty counts, and ahead/behind state.
+git_branch=$worktree_branch
+git_status_text=""
+if [[ -n $cwd ]]; then
+ if command -v timeout >/dev/null 2>&1; then
+ git_output=$(GIT_OPTIONAL_LOCKS=0 timeout 1s git -C "$cwd" status --porcelain=v1 --branch --untracked-files=normal 2>/dev/null)
+ else
+ git_output=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" status --porcelain=v1 --branch --untracked-files=normal 2>/dev/null)
+ fi
+ if [[ -n $git_output ]]; then
+ staged=0
+ modified=0
+ untracked=0
+ ahead=0
+ behind=0
+ while IFS= read -r status_line; do
+ if [[ $status_line == '## '* ]]; then
+ branch_header=${status_line#'## '}
+ [[ -z $git_branch ]] && git_branch=${branch_header%%...*}
+ [[ $branch_header =~ ahead[[:space:]]+([0-9]+) ]] && ahead=${BASH_REMATCH[1]}
+ [[ $branch_header =~ behind[[:space:]]+([0-9]+) ]] && behind=${BASH_REMATCH[1]}
+ elif [[ ${status_line:0:2} == '??' ]]; then
+ ((untracked++))
+ else
+ [[ ${status_line:0:1} != ' ' ]] && ((staged++))
+ [[ ${status_line:1:1} != ' ' ]] && ((modified++))
+ fi
+ done <<<"$git_output"
+ (( staged > 0 )) && git_status_text+=" +$staged"
+ (( modified > 0 )) && git_status_text+=" ~$modified"
+ (( untracked > 0 )) && git_status_text+=" ?$untracked"
+ (( ahead > 0 )) && git_status_text+=" a$ahead"
+ (( behind > 0 )) && git_status_text+=" b$behind"
+ fi
+fi
+
+line1_parts=("${dim}[WORK]${reset}" "${cyan}${short_cwd}${reset}")
+if [[ -n $git_branch ]]; then
+ branch_max=$(( columns >= 100 ? 30 : 18 ))
+ git_branch=$(truncate_middle "$git_branch" "$branch_max")
+ line1_parts+=("${yellow}${git_branch}${reset}${git_status_text}")
+fi
+if [[ -n $pr_number && $columns -ge 80 ]]; then
+ pr_text="PR #$pr_number"
+ [[ -n $pr_state ]] && pr_text+=" $pr_state"
+ line1_parts+=("${blue}${pr_text}${reset}")
+fi
+
+line2_parts=("${dim}[MODEL]${reset}")
+model_text=$model
+[[ -n $effort ]] && model_text+=" $effort"
+[[ $thinking == true && $columns -ge 100 ]] && model_text+=" think"
+[[ -n $model_text ]] && line2_parts+=("${magenta}${model_text}${reset}")
+line2_parts+=("CTX $(context_segment)")
+
+fable_weekly_pct=$(jq -r '.rate_limits.seven_day_fable.used_percentage // empty' <<<"$input")
+fable_weekly_reset=$(jq -r '.rate_limits.seven_day_fable.resets_at // empty' <<<"$input")
+if [[ -z $fable_weekly_pct ]]; then
+ IFS=$'\t' read -r fable_weekly_pct fable_weekly_reset < <(fable_weekly_limit)
+fi
+
+line3_parts=(
+ "${dim}[LIMIT]${reset}"
+ "$(rate_segment '5h' "$five_hour_pct" "$five_hour_reset")"
+ "$(rate_segment '7d All' "$seven_day_pct" "$seven_day_reset")"
+)
+[[ -n $fable_weekly_pct ]] && line3_parts+=("$(rate_segment '7d Fable' "$fable_weekly_pct" "$fable_weekly_reset")")
+
+identity=""
+if [[ -n $agent_name ]]; then
+ identity="agent:$agent_name"
+elif [[ -n $session_name ]]; then
+ identity="session:$session_name"
+elif [[ -n $worktree_name ]]; then
+ identity="wt:$worktree_name"
+fi
+runtime_target=""
+# AgentTower needs the exact tmux session name for `tmux attach -t`.
+if [[ -n ${TMUX:-} ]] && command -v tmux >/dev/null 2>&1; then
+ tmux_session=$(tmux display-message -p '#S' 2>/dev/null || true)
+ if [[ -n $tmux_session ]]; then
+ runtime_target="tmux:${tmux_session}"
+ [[ -n ${TMUX_PANE:-} ]] && runtime_target+="/${TMUX_PANE}"
+ fi
+fi
+[[ -z $runtime_target && -n $identity ]] && runtime_target=$identity
+[[ -n $runtime_target ]] && line1_parts+=("${blue}${runtime_target}${reset}")
+
+weekly_reset=${fable_weekly_reset:-$seven_day_reset}
+line4_parts=(
+ "${dim}[RESET]${reset}"
+ "$(countdown_segment '5h' "$five_hour_reset" 18000 minutes)"
+ "$(countdown_segment '7d' "$weekly_reset" 604800 days)"
+)
+
+printf '%s\n' "$(join_parts "${line1_parts[@]}")"
+printf '%s\n' "$(join_parts "${line2_parts[@]}")"
+printf '%s\n' "$(join_parts "${line3_parts[@]}")"
+printf '%s\n' "$(join_parts "${line4_parts[@]}")"
diff --git a/base-image/files/codex-profile b/base-image/files/codex-profile
new file mode 100755
index 0000000..0a346f6
--- /dev/null
+++ b/base-image/files/codex-profile
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+manifest="${CODEX_PROFILES_MANIFEST:-${CHATGPT_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/openai-profiles.json}}"
+base="${CODEX_PROFILES_HOME:-${CHATGPT_PROFILES_HOME:-$HOME/.chatgpt-profiles}}"
+command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; }
+
+codex_bin="${CODEX_BIN:-$(command -v codex || true)}"
+if [[ -z "$codex_bin" && -x "$HOME/.local/bin/codex" ]]; then
+ codex_bin="$HOME/.local/bin/codex"
+fi
+
+profile_metadata() {
+ local requested="$1" normalized row metadata candidate
+ normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')"
+ if [[ -f "$manifest" ]]; then
+ row="$(jq -r --arg name "$normalized" \
+ '.profiles[]
+ | select(
+ ((.name | ascii_downcase) == $name)
+ or any(.aliases[]?; (ascii_downcase == $name))
+ )
+ | [.name,.email,.family] | @tsv' \
+ "$manifest" | head -n 1)"
+ if [[ -n "$row" ]]; then
+ printf '%s\n' "$row"
+ return 0
+ fi
+ fi
+
+ for metadata in "$base"/profiles/*/.profile.json; do
+ [[ -f "$metadata" ]] || continue
+ candidate="$(jq -r --arg name "$normalized" '
+ if (((.name // "") | ascii_downcase) == $name)
+ or any(.aliases[]?; (ascii_downcase == $name))
+ then "match" else empty end
+ ' "$metadata")"
+ if [[ "$candidate" == "match" ]]; then
+ jq -r '[.name,.email,.family] | @tsv' "$metadata"
+ return 0
+ fi
+ done
+ return 1
+}
+
+action="${1:-list}"
+if [[ "$action" == "list" || "$action" == "login" || "$action" == "status" || "$action" == "logout" || "$action" == "run" ]]; then
+ [[ $# -eq 0 ]] || shift
+else
+ action="run"
+fi
+
+case "$action" in
+ list)
+ {
+ if [[ -f "$manifest" ]]; then
+ jq -r '.profiles[] | [.name, .family, .email, ((.aliases // []) | join(","))] | @tsv' "$manifest"
+ fi
+ for metadata in "$base"/profiles/*/.profile.json; do
+ [[ -f "$metadata" ]] || continue
+ jq -r '[.name,.family,.email,((.aliases // []) | join(","))] | @tsv' "$metadata"
+ done
+ } | awk -F '\t' '!seen[tolower($1)]++'
+ ;;
+ login|status|logout|run)
+ profile="${1:?Usage: codex-profile $action PROFILE [arguments]}"; shift
+ row="$(profile_metadata "$profile" || true)"
+ [[ -n "$row" ]] || { echo "Unknown Codex profile: $profile" >&2; exit 2; }
+ IFS=$'\t' read -r profile email family <<<"$row"
+ config_dir="$base/profiles/$profile"
+ [[ -d "$config_dir" ]] || { echo "Codex profile is not configured: $config_dir" >&2; exit 2; }
+ [[ -n "$codex_bin" ]] || { echo "Codex CLI not found." >&2; exit 1; }
+
+ export CODEX_PROFILE_NAME="$profile"
+ export CODEX_PROFILE_EMAIL="$email"
+ export CODEX_PROFILE_FAMILY="$family"
+ codex_auth_config=(
+ -c 'forced_login_method="chatgpt"'
+ -c 'cli_auth_credentials_store="file"'
+ )
+
+ case "$action" in
+ login)
+ printf 'Starting ChatGPT login for %s (%s).\n' "$profile" "$email" >&2
+ CODEX_HOME="$config_dir" "$codex_bin" "${codex_auth_config[@]}" login "$@"
+ ;;
+ status)
+ CODEX_HOME="$config_dir" "$codex_bin" "${codex_auth_config[@]}" login status "$@"
+ ;;
+ logout)
+ CODEX_HOME="$config_dir" "$codex_bin" "${codex_auth_config[@]}" logout "$@"
+ ;;
+ run)
+ CODEX_HOME="$config_dir" exec "$codex_bin" "${codex_auth_config[@]}" "$@"
+ ;;
+ esac
+ ;;
+ *) echo "Usage: codex-profile {list|login|status|logout|run} [PROFILE]" >&2; exit 2 ;;
+esac
diff --git a/base-image/files/opencode/opencode.json b/base-image/files/opencode/opencode.json
index 4ff68d8..eef5102 100644
--- a/base-image/files/opencode/opencode.json
+++ b/base-image/files/opencode/opencode.json
@@ -3,8 +3,8 @@
"default_agent": "openagent",
"plugin": [
"oh-my-opencode",
- "opencode-gemini-auth@1.3.6",
- "opencode-openai-codex-auth@4.2.0"
+ "opencode-gemini-auth@1.4.15",
+ "opencode-openai-codex-auth@4.4.0"
],
"provider": {
"anthropic": {
diff --git a/base-image/files/provider-profile b/base-image/files/provider-profile
new file mode 100755
index 0000000..f0ad474
--- /dev/null
+++ b/base-image/files/provider-profile
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+invoked_as="$(basename "$0")"
+case "$invoked_as" in
+ pgemini|gemini-profile) provider=gemini ;;
+ pgrok|grok-profile) provider=grok ;;
+ pglm|pzai|glm-profile|zai-profile) provider=glm ;;
+ *) provider="${AI_PROFILE_PROVIDER:-}" ;;
+esac
+
+case "$provider" in
+ gemini)
+ manifest="${GEMINI_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/gemini-profiles.json}"
+ base="${GEMINI_PROFILES_HOME:-$HOME/.gemini-profiles}"
+ cli="${GEMINI_BIN:-$(command -v gemini || true)}"
+ label="Gemini"
+ ;;
+ grok)
+ manifest="${GROK_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/grok-profiles.json}"
+ base="${GROK_PROFILES_HOME:-$HOME/.grok-profiles}"
+ if [[ -z "${GROK_PROFILES_HOME:-}" && -e "$base" && ! -w "$base" ]]; then
+ base="${XDG_DATA_HOME:-$HOME/.local/share}/workbenches/grok-profiles"
+ fi
+ cli="${GROK_BIN:-$(command -v grok || true)}"
+ label="Grok"
+ ;;
+ glm)
+ manifest="${GLM_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/glm-profiles.json}"
+ base="${GLM_PROFILES_HOME:-$HOME/.glm-profiles}"
+ cli="${OPENCODE_BIN:-$(command -v opencode || true)}"
+ label="Z.AI GLM"
+ ;;
+ *)
+ echo "Invoke as pgemini, pgrok, or pglm." >&2
+ exit 2
+ ;;
+esac
+
+command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; }
+
+profile_metadata() {
+ local requested="$1" normalized
+ normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')"
+ jq -r --arg name "$normalized" '
+ .profiles[]
+ | select(
+ ((.name | ascii_downcase) == $name)
+ or any(.aliases[]?; (ascii_downcase == $name))
+ )
+ | [.name,.email,.family] | @tsv
+ ' "$manifest" | head -n 1
+}
+
+configure_environment() {
+ local profile_dir="$1"
+ export AI_PROFILE_NAME="$profile"
+ export AI_PROFILE_EMAIL="$email"
+ export AI_PROFILE_FAMILY="$family"
+ case "$provider" in
+ gemini) export GEMINI_CLI_HOME="$profile_dir" ;;
+ grok) export GROK_HOME="$profile_dir" ;;
+ glm)
+ export XDG_CONFIG_HOME="$profile_dir/xdg/config"
+ export XDG_DATA_HOME="$profile_dir/xdg/data"
+ export XDG_CACHE_HOME="$profile_dir/xdg/cache"
+ export XDG_STATE_HOME="$profile_dir/xdg/state"
+ ;;
+ esac
+}
+
+action="${1:-list}"
+if [[ "$action" == "list" || "$action" == "login" || "$action" == "status" || "$action" == "logout" || "$action" == "run" || "$action" == "path" ]]; then
+ [[ $# -eq 0 ]] || shift
+else
+ action=run
+fi
+
+case "$action" in
+ list)
+ jq -r '.profiles[] | [.name,.family,.email,((.aliases // []) | join(",")),(.status // "active")] | @tsv' "$manifest"
+ ;;
+ login|status|logout|run|path)
+ profile_requested="${1:?Usage: $invoked_as $action PROFILE [arguments]}"; shift
+ row="$(profile_metadata "$profile_requested")"
+ [[ -n "$row" ]] || { echo "Unknown $label profile: $profile_requested" >&2; exit 2; }
+ IFS=$'\t' read -r profile email family <<<"$row"
+ profile_dir="$base/profiles/$profile"
+ [[ -d "$profile_dir" ]] || { echo "$label profile is not configured: $profile_dir" >&2; exit 2; }
+ [[ "$action" == path ]] && { printf '%s\n' "$profile_dir"; exit 0; }
+ [[ -n "$cli" ]] || { echo "$label CLI not found." >&2; exit 1; }
+ configure_environment "$profile_dir"
+
+ case "$provider:$action" in
+ gemini:login)
+ printf 'Starting Gemini sign-in for %s (%s). Complete the Google flow in Gemini.\n' "$profile" "$email" >&2
+ exec "$cli" "$@"
+ ;;
+ gemini:status)
+ if [[ -s "$profile_dir/.gemini/oauth_creds.json" ]]; then
+ echo "Gemini credential cache is present for $profile. Launch pgemini $profile and run /about to verify the account."
+ else
+ echo "Gemini credentials are not present for $profile."
+ exit 1
+ fi
+ ;;
+ gemini:logout) exec "$cli" auth logout "$@" ;;
+ gemini:run) exec "$cli" "$@" ;;
+ grok:login) exec "$cli" login "$@" ;;
+ grok:status) exec "$cli" models "$@" ;;
+ grok:logout) exec "$cli" logout "$@" ;;
+ grok:run) exec "$cli" "$@" ;;
+ glm:login)
+ echo "Select Z.AI Coding Plan when OpenCode asks for the provider." >&2
+ exec "$cli" auth login "$@"
+ ;;
+ glm:status) exec "$cli" auth list "$@" ;;
+ glm:logout) exec "$cli" auth logout "$@" ;;
+ glm:run) exec "$cli" "$@" ;;
+ esac
+ ;;
+ *) echo "Usage: $invoked_as {list|login|status|logout|run|path} [PROFILE]" >&2; exit 2 ;;
+esac
diff --git a/base-image/install-ai-clis.sh b/base-image/install-ai-clis.sh
index 2e78a31..396b677 100755
--- a/base-image/install-ai-clis.sh
+++ b/base-image/install-ai-clis.sh
@@ -1,6 +1,6 @@
#!/bin/bash
# Shared AI CLI Installation Script
-# Version: 2.0.2
+# Version: 2.0.5
#
# USER-AGNOSTIC: Runs as root, installs to system-wide paths.
# All npm globals go to /usr/local (default root prefix).
@@ -9,11 +9,12 @@
# Claude Code goes to /usr/local/bin.
#
# Installs:
-# - OpenCode (from Opensoft/opencode fork)
-# - oh-my-opencode plugin (from git: darrenhinde/oh-my-opencode)
+# - OpenCode (installed from the official npm platform package)
+# - oh-my-opencode plugin (installed from the published npm package)
# Includes built-in agents: Sisyphus, oracle, librarian, explore, frontend, etc.
# - Auth plugins (opencode-gemini-auth, opencode-openai-codex-auth)
-# - Other AI CLIs (Codex, Antigravity, Copilot, etc.)
+# - Other AI CLIs (Codex, Gemini, Copilot, etc.)
+# - Google Antigravity CLI (agy), checksum-gated opt-in
# - Claude Code (via native installer, not npm)
#
# Note: OpenAgents agent files (openagent.md, opencoder.md) are copied via
@@ -29,8 +30,15 @@ set -e
# DEBUG AND TIMEOUT CONFIGURATION
# ========================================
DEBUG="${DEBUG:-1}"
-COMMAND_TIMEOUT="${COMMAND_TIMEOUT:-300}" # 5 minutes per command
-BUN_OPERATIONS_TIMEOUT="${BUN_OPERATIONS_TIMEOUT:-180}" # 3 minutes for bun ops
+COMMAND_TIMEOUT="${COMMAND_TIMEOUT:-300}" # 5 minutes per general command
+NPM_INSTALL_TIMEOUT="${NPM_INSTALL_TIMEOUT:-600}" # 10 minutes for npm package installs
+GIT_CLONE_TIMEOUT="${GIT_CLONE_TIMEOUT:-900}" # 15 minutes for slow GitHub clones
+RELEASE_DOWNLOAD_TIMEOUT="${RELEASE_DOWNLOAD_TIMEOUT:-3600}" # 60 minutes for slow GitHub release assets
+BUN_OPERATIONS_TIMEOUT="${BUN_OPERATIONS_TIMEOUT:-900}" # 15 minutes for bun ops
+UV_TOOL_INSTALL_TIMEOUT="${UV_TOOL_INSTALL_TIMEOUT:-3600}" # 60 minutes for Python tool installs
+INSTALL_ANTIGRAVITY_CLI="${INSTALL_ANTIGRAVITY_CLI:-0}"
+ANTIGRAVITY_INSTALL_URL="${ANTIGRAVITY_INSTALL_URL:-https://antigravity.google/cli/install.sh}"
+ANTIGRAVITY_INSTALL_SHA256="${ANTIGRAVITY_INSTALL_SHA256:-}"
log_debug() {
if [ "$DEBUG" = "1" ]; then
@@ -69,6 +77,22 @@ run_with_timeout() {
fi
}
+ensure_system_uv_tool_paths() {
+ mkdir -p "$SYSTEM_UV_TOOL_DIR" "$SYSTEM_UV_TOOL_BIN_DIR" /root/.local/share/uv
+ ln -sfn "$SYSTEM_UV_TOOL_DIR" /root/.local/share/uv/tools
+}
+
+run_system_uv_tool_install() {
+ local description="$1"
+ shift
+
+ ensure_system_uv_tool_paths
+ run_with_timeout "$UV_TOOL_INSTALL_TIMEOUT" "$description" env \
+ UV_TOOL_BIN_DIR="$SYSTEM_UV_TOOL_BIN_DIR" \
+ UV_TOOL_DIR="$SYSTEM_UV_TOOL_DIR" \
+ uv tool install "$@" --python-preference system
+}
+
check_system_resources() {
log_debug "Checking system resources..."
log_debug "Memory: $(free -h | head -2)"
@@ -77,6 +101,51 @@ check_system_resources() {
log_debug "Available disk in /tmp: $(df -h /tmp | tail -1)"
}
+resolve_claude_js_fallback_version() {
+ if [ -n "${CLAUDE_CODE_JS_FALLBACK_VERSION:-}" ]; then
+ printf '%s\n' "$CLAUDE_CODE_JS_FALLBACK_VERSION"
+ return 0
+ fi
+
+ curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 \
+ https://registry.npmjs.org/@anthropic-ai%2fclaude-code \
+ | jq -r '.versions | to_entries[] | select(.value.bin.claude == "cli.js") | .key' \
+ | sort -V \
+ | tail -1
+}
+
+install_claude_js_fallback() {
+ local fallback_version
+
+ fallback_version="$(resolve_claude_js_fallback_version)"
+ if [ -z "$fallback_version" ]; then
+ log_error "Could not resolve a JS-based Claude Code fallback version"
+ return 1
+ fi
+
+ log_info "Installing Claude Code JS fallback ${fallback_version}..."
+ rm -f /usr/local/bin/claude /usr/bin/claude
+ hash -r || true
+ run_with_timeout "$NPM_INSTALL_TIMEOUT" "Claude Code JS fallback npm install" \
+ npm install -g "@anthropic-ai/claude-code@${fallback_version}"
+}
+
+ensure_claude_runnable() {
+ if command -v claude >/dev/null 2>&1 && claude --version >/dev/null 2>&1; then
+ log_info "Claude Code runnable check passed: $(claude --version)"
+ return 0
+ fi
+
+ log_error "Claude Code native binary failed runnable check; falling back to JS package"
+ if install_claude_js_fallback && command -v claude >/dev/null 2>&1 && claude --version >/dev/null 2>&1; then
+ log_info "Claude Code fallback runnable check passed: $(claude --version)"
+ return 0
+ fi
+
+ log_error "Claude Code fallback did not produce a runnable claude CLI"
+ return 1
+}
+
log_info "=========================================="
log_info "Installing AI CLI Tools"
log_info "=========================================="
@@ -91,39 +160,9 @@ check_system_resources
# Bun is pre-installed at /opt/bun by Dockerfile
export BUN_INSTALL="${BUN_INSTALL:-/opt/bun}"
-export PATH="/opt/bun/bin:/usr/local/bin:$HOME/.local/bin:$PATH"
-
-ensure_cli_on_path() {
- local cli="$1"
- shift
-
- if command -v "$cli" >/dev/null 2>&1; then
- return 0
- fi
-
- local candidate
- for candidate in "$@"; do
- if [ -n "$candidate" ] && [ -x "$candidate" ]; then
- ln -sf "$candidate" "/usr/local/bin/$cli"
- log_info "Linked $cli to /usr/local/bin/$cli"
- return 0
- fi
- done
-
- return 1
-}
-
-install_opencode_npm_fallback() {
- log_info "Falling back to npm package opencode-ai..."
- if run_with_timeout "$COMMAND_TIMEOUT" "OpenCode npm fallback install" npm install -g opencode-ai; then
- ensure_cli_on_path opencode \
- "$(npm config get prefix 2>/dev/null)/bin/opencode" \
- "$HOME/.npm-global/bin/opencode" \
- "$HOME/.local/bin/opencode" || log_error "OpenCode npm fallback installed but opencode is not on PATH"
- else
- log_error "OpenCode npm fallback failed (continuing)"
- fi
-}
+export PATH="/opt/bun/bin:$PATH"
+SYSTEM_UV_TOOL_DIR="${SYSTEM_UV_TOOL_DIR:-/opt/uv/tools}"
+SYSTEM_UV_TOOL_BIN_DIR="${SYSTEM_UV_TOOL_BIN_DIR:-/usr/local/bin}"
log_debug "Verifying Bun installation"
if which bun >/dev/null 2>&1; then
@@ -133,11 +172,6 @@ else
log_error "Bun not found in PATH (expected at /opt/bun/bin)"
fi
-log_info "Installing OpenSpec..."
-if ! run_with_timeout "$COMMAND_TIMEOUT" "OpenSpec npm install" npm install -g @fission-ai/openspec@latest; then
- log_error "OpenSpec installation failed (continuing)"
-fi
-
log_info "Installing Claude Code CLI (native installer)..."
# Native installer downloads to $HOME/.claude/downloads/ then runs 'claude install'
# which places a launcher in ~/.local/bin/. Since we run as root, we need to
@@ -164,62 +198,132 @@ else
log_error "Claude Code binary not found after installation (continuing)"
fi
+ensure_claude_runnable || true
+
log_info "Installing OpenAI Codex CLI..."
-if ! run_with_timeout "$COMMAND_TIMEOUT" "Codex npm install" npm install -g @openai/codex; then
+if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Codex npm install" npm install -g @openai/codex@latest; then
log_error "Codex installation failed (continuing)"
fi
-ensure_cli_on_path codex \
- "$(npm config get prefix 2>/dev/null)/bin/codex" \
- "$HOME/.npm-global/bin/codex" \
- "$HOME/.local/bin/codex" || true
-
-log_info "Installing Antigravity CLI..."
-if ! run_with_timeout "$COMMAND_TIMEOUT" "Antigravity CLI install" bash -c 'curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr/local/bin'; then
- log_error "Antigravity CLI installation failed (continuing)"
+
+log_info "Installing Google Gemini CLI..."
+if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Gemini npm install" npm install -g @google/gemini-cli; then
+ log_error "Gemini CLI installation failed (continuing)"
+fi
+
+if [ "$INSTALL_ANTIGRAVITY_CLI" = "1" ] || [ "$INSTALL_ANTIGRAVITY_CLI" = "true" ]; then
+ log_info "Installing Google Antigravity CLI..."
+ if [ -z "$ANTIGRAVITY_INSTALL_SHA256" ]; then
+ log_error "Antigravity install requested but ANTIGRAVITY_INSTALL_SHA256 is not set (skipping)"
+ else
+ antigravity_installer="$(mktemp)"
+ if run_with_timeout "120" "Antigravity installer download" \
+ curl -fsSL "$ANTIGRAVITY_INSTALL_URL" -o "$antigravity_installer" &&
+ printf '%s %s\n' "$ANTIGRAVITY_INSTALL_SHA256" "$antigravity_installer" | sha256sum -c - >/dev/null 2>&1 &&
+ run_with_timeout "300" "Antigravity CLI install" bash "$antigravity_installer" --skip-aliases --skip-path; then
+ if [ -x "$HOME/.local/bin/agy" ] && [ ! -x /usr/local/bin/agy ]; then
+ cp "$HOME/.local/bin/agy" /usr/local/bin/agy
+ chmod +x /usr/local/bin/agy
+ fi
+ log_info "Antigravity CLI installed to $(command -v agy || printf '/usr/local/bin/agy')"
+ else
+ log_error "Antigravity CLI installation failed or checksum verification failed (continuing)"
+ fi
+ rm -f "$antigravity_installer"
+ fi
+else
+ log_info "Skipping Google Antigravity CLI install; set INSTALL_ANTIGRAVITY_CLI=1 and ANTIGRAVITY_INSTALL_SHA256 to enable"
fi
-ensure_cli_on_path agy \
- "/usr/local/bin/agy" \
- "$HOME/.local/bin/agy" || true
log_info "Installing GitHub Copilot CLI..."
-if ! run_with_timeout "$COMMAND_TIMEOUT" "GitHub Copilot npm install" npm install -g @githubnext/github-copilot-cli; then
+if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "GitHub Copilot npm install" npm install -g @github/copilot; then
log_error "GitHub Copilot installation failed (continuing)"
fi
log_info "Installing Grok CLI (xAI)..."
mkdir -p /opt/grok/bin
-if run_with_timeout "$COMMAND_TIMEOUT" "Grok native install" bash -o pipefail -c 'curl -fsSL https://x.ai/cli/install.sh | GROK_BIN_DIR=/opt/grok/bin bash'; then
+if run_with_timeout "$COMMAND_TIMEOUT" "Grok native install" bash -o pipefail -c \
+ 'curl -fsSL https://x.ai/cli/install.sh | GROK_BIN_DIR=/opt/grok/bin bash'; then
mkdir -p /opt/grok/completions
if [ -d "$HOME/.grok/completions" ]; then
cp -a "$HOME/.grok/completions/." /opt/grok/completions/
fi
chmod -R a+rX /opt/grok
- ensure_cli_on_path grok \
- "/opt/grok/bin/grok" \
- "/usr/local/bin/grok" \
- "$HOME/.grok/bin/grok" || log_error "Grok installed but grok is not on PATH"
- ensure_cli_on_path agent \
- "/opt/grok/bin/agent" \
- "/usr/local/bin/agent" \
- "$HOME/.grok/bin/agent" || true
+ ln -sfn /opt/grok/bin/grok /usr/local/bin/grok
+ if [ -x /opt/grok/bin/agent ]; then
+ ln -sfn /opt/grok/bin/agent /usr/local/bin/agent
+ fi
else
log_error "Grok CLI native installation failed (continuing)"
fi
-log_info "Installing OpenCode AI (from Opensoft fork)..."
-# OpenCode: open source AI coding agent (https://github.com/Opensoft/opencode)
-# Install from Opensoft fork instead of npm (sst version)
-log_debug "Cloning OpenCode repository from Opensoft..."
-if ! run_with_timeout "$COMMAND_TIMEOUT" "OpenCode git clone" git clone --depth 1 https://github.com/Opensoft/opencode.git /tmp/opencode; then
- log_error "Failed to clone OpenCode repository (skipping OpenCode installation)"
- install_opencode_npm_fallback
-else
+install_opencode_release() {
+ local opencode_version="${OPENCODE_VERSION:-latest}"
+ local opencode_arch
+ local opencode_url
+ local opencode_dir="/tmp/opencode-release"
+ local opencode_bin
+
+ case "$(uname -m)" in
+ x86_64|amd64)
+ opencode_arch="x64"
+ ;;
+ aarch64|arm64)
+ opencode_arch="arm64"
+ ;;
+ *)
+ log_error "Unsupported OpenCode release architecture: $(uname -m)"
+ return 1
+ ;;
+ esac
+
+ if [ "$opencode_version" = "latest" ]; then
+ opencode_version=$(curl --http1.1 -fsSL --retry 3 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 60 \
+ https://registry.npmjs.org/opencode-ai/latest | jq -r '.version // empty')
+ fi
+
+ if [ -z "$opencode_version" ] || [ "$opencode_version" = "null" ]; then
+ log_error "Could not resolve latest OpenCode release version"
+ return 1
+ fi
+
+ opencode_url="https://registry.npmjs.org/opencode-linux-${opencode_arch}/-/opencode-linux-${opencode_arch}-${opencode_version}.tgz"
+ rm -rf "$opencode_dir"
+ mkdir -p "$opencode_dir"
+
+ if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "OpenCode release download" \
+ curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 \
+ -o "$opencode_dir/opencode.tgz" "$opencode_url"; then
+ return 1
+ fi
+
+ if ! tar -xzf "$opencode_dir/opencode.tgz" -C "$opencode_dir"; then
+ log_error "Failed to extract OpenCode npm platform archive"
+ return 1
+ fi
+
+ opencode_bin=$(find "$opencode_dir" -type f -name opencode -perm /111 2>/dev/null | head -1)
+ if [ -z "$opencode_bin" ] || [ ! -x "$opencode_bin" ]; then
+ log_error "OpenCode binary not found in release archive"
+ return 1
+ fi
+
+ install -m 0755 "$opencode_bin" /usr/local/bin/opencode
+ log_info "OpenCode ${opencode_version} installed to /usr/local/bin/opencode from npm platform package"
+}
+
+build_opencode_from_source() {
+ log_debug "Cloning OpenCode repository from upstream..."
+ if ! run_with_timeout "$GIT_CLONE_TIMEOUT" "OpenCode git clone" git -c http.version=HTTP/1.1 clone --depth 1 --single-branch --no-tags --filter=blob:none https://github.com/anomalyco/opencode.git /tmp/opencode; then
+ log_error "Failed to clone OpenCode repository"
+ return 1
+ fi
+
cd /tmp/opencode
log_debug "Current directory: $(pwd)"
log_debug "Directory contents: $(ls -la | head -20)"
log_info "Running bun install for OpenCode..."
- OPENCODE_BUILD_TIMEOUT="${OPENCODE_BUILD_TIMEOUT:-300}" # fall back to npm if the source build drags
+ OPENCODE_BUILD_TIMEOUT=900 # 15 minutes for single-target compile
if command -v bun >/dev/null 2>&1; then
log_debug "Bun is available, using it for installation"
case "$(uname -m)" in
@@ -262,11 +366,18 @@ else
chmod +x /usr/local/bin/opencode
log_info "OpenCode installed to /usr/local/bin/opencode"
else
- log_error "OpenCode binary not found after build"
- install_opencode_npm_fallback
+ log_error "OpenCode binary not found after build (continuing)"
fi
cd -
+}
+
+log_info "Installing OpenCode AI..."
+# OpenCode: open source AI coding agent (https://github.com/anomalyco/opencode).
+# Prefer official release binaries; fall back to source build only if needed.
+if ! install_opencode_release; then
+ log_error "OpenCode release install failed, falling back to source build"
+ build_opencode_from_source || log_error "OpenCode source build failed"
fi
# ========================================
@@ -274,62 +385,125 @@ fi
# ========================================
# Note: OpenAgents agent files (openagent.md, opencoder.md) are provided
# via Dockerfile COPY step, not installed here
-log_info "Installing oh-my-opencode plugin from git..."
-# oh-my-opencode: OpenCode plugin from darrenhinde's fork (not published to npm)
-# Using darrenhinde's fork which may include customizations for OpenAgents integration
-# Install from GitHub repository
-log_debug "Cloning oh-my-opencode..."
-if ! run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode git clone" git clone --depth 1 https://github.com/darrenhinde/oh-my-opencode.git /tmp/oh-my-opencode; then
- log_error "Failed to clone oh-my-opencode (skipping oh-my-opencode installation)"
-else
- cd /tmp/oh-my-opencode
- log_debug "Current directory: $(pwd)"
- log_debug "Directory contents: $(ls -la | head -20)"
-
- log_info "Building oh-my-opencode..."
- if command -v bun >/dev/null 2>&1; then
- log_debug "Using Bun for oh-my-opencode installation"
- if run_with_timeout "$BUN_OPERATIONS_TIMEOUT" "oh-my-opencode bun install" bun install; then
- if ! run_with_timeout "$BUN_OPERATIONS_TIMEOUT" "oh-my-opencode bun build" bun run build; then
- log_error "oh-my-opencode build failed"
- fi
- else
- log_error "oh-my-opencode bun install timeout, trying npm fallback"
- if run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm install" npm install; then
- run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm build" npm run build || log_error "npm build failed"
- else
- log_error "oh-my-opencode npm install failed"
- fi
- fi
- else
- log_debug "Using npm for oh-my-opencode installation"
- if run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm install" npm install; then
- run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm build" npm run build || log_error "npm build failed"
- else
- log_error "oh-my-opencode npm install failed"
- fi
+install_oh_my_opencode_package() {
+ local tmp_dir metadata version tarball_url omo_arch platform_package platform_metadata platform_tarball_url plugin_src shared_skills_src wrapper_src bin_name
+
+ tmp_dir="$(mktemp -d)"
+ log_debug "Resolving latest oh-my-opencode package metadata..."
+ if ! metadata="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 60 https://registry.npmjs.org/oh-my-opencode/latest)"; then
+ log_error "Failed to resolve oh-my-opencode npm metadata"
+ rm -rf "$tmp_dir"
+ return 1
fi
-
- log_debug "Installing oh-my-opencode plugin..."
- mkdir -p /opt/opencode/plugin
- cp -r /tmp/oh-my-opencode /opt/opencode/plugin/oh-my-opencode
- cd /opt/opencode/plugin/oh-my-opencode
-
- log_debug "Running bun install in plugin directory..."
- if command -v bun >/dev/null 2>&1; then
- run_with_timeout "$BUN_OPERATIONS_TIMEOUT" "oh-my-opencode plugin bun install" bun install || log_error "Plugin bun install failed"
- else
- run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode plugin npm install" npm install || log_error "Plugin npm install failed"
+
+ version="$(printf '%s' "$metadata" | jq -r '.version')"
+ tarball_url="$(printf '%s' "$metadata" | jq -r '.dist.tarball')"
+ if [ -z "$version" ] || [ "$version" = "null" ] || [ -z "$tarball_url" ] || [ "$tarball_url" = "null" ]; then
+ log_error "Invalid oh-my-opencode npm metadata"
+ rm -rf "$tmp_dir"
+ return 1
fi
- cd -
-
+
+ case "$(uname -m)" in
+ x86_64|amd64)
+ omo_arch="x64"
+ ;;
+ aarch64|arm64)
+ omo_arch="arm64"
+ ;;
+ *)
+ log_error "Unsupported oh-my-opencode architecture: $(uname -m)"
+ rm -rf "$tmp_dir"
+ return 1
+ ;;
+ esac
+
+ platform_package="oh-my-opencode-linux-${omo_arch}"
+ if ! platform_metadata="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 60 "https://registry.npmjs.org/${platform_package}/${version}")"; then
+ log_error "Failed to resolve ${platform_package} ${version} npm metadata"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ platform_tarball_url="$(printf '%s' "$platform_metadata" | jq -r '.dist.tarball')"
+ if [ -z "$platform_tarball_url" ] || [ "$platform_tarball_url" = "null" ]; then
+ log_error "Invalid ${platform_package} npm metadata"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ log_info "Installing oh-my-opencode ${version} from npm tarballs..."
+ mkdir -p "$tmp_dir/main" "$tmp_dir/platform"
+
+ if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "oh-my-opencode package download" curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 -o "$tmp_dir/oh-my-opencode.tgz" "$tarball_url"; then
+ log_error "Failed to download oh-my-opencode package tarball"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "oh-my-opencode platform package download" curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 -o "$tmp_dir/oh-my-opencode-platform.tgz" "$platform_tarball_url"; then
+ log_error "Failed to download ${platform_package} package tarball"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ if ! tar -xzf "$tmp_dir/oh-my-opencode.tgz" -C "$tmp_dir/main"; then
+ log_error "Failed to extract oh-my-opencode package tarball"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ if ! tar -xzf "$tmp_dir/oh-my-opencode-platform.tgz" -C "$tmp_dir/platform"; then
+ log_error "Failed to extract ${platform_package} package tarball"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ plugin_src="$tmp_dir/main/package/packages/omo-codex/plugin"
+ shared_skills_src="$tmp_dir/main/package/packages/shared-skills"
+ wrapper_src="$tmp_dir/platform/package/bin/oh-my-opencode.js"
+ if [ ! -d "$plugin_src" ] || [ ! -d "$shared_skills_src" ] || [ ! -f "$wrapper_src" ]; then
+ log_error "oh-my-opencode package is missing expected plugin payload"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ rm -rf /opt/oh-my-opencode
+ mkdir -p /opt/oh-my-opencode
+ cp -a "$tmp_dir/main/package"/. /opt/oh-my-opencode/
+ install -m 0755 "$wrapper_src" /opt/oh-my-opencode/oh-my-opencode-wrapper.js
+
+ for bin_name in oh-my-opencode oh-my-openagent omo lazycodex lazycodex-ai; do
+ cat > "/usr/local/bin/$bin_name" <<'EOF'
+#!/bin/sh
+export OMO_WRAPPER_PACKAGE_ROOT="${OMO_WRAPPER_PACKAGE_ROOT:-/opt/oh-my-opencode}"
+export OMO_INVOCATION_NAME="${0##*/}"
+exec node /opt/oh-my-opencode/oh-my-opencode-wrapper.js "$@"
+EOF
+ chmod 0755 "/usr/local/bin/$bin_name"
+ done
+
+ log_debug "Installing oh-my-opencode plugin payload..."
+ rm -rf /opt/opencode/plugin/oh-my-opencode /opt/opencode/shared-skills
+ mkdir -p /opt/opencode/plugin/oh-my-opencode /opt/opencode/shared-skills /opt/opencode/plugin/oh-my-opencode/node_modules/@oh-my-opencode
+ cp -a "$plugin_src"/. /opt/opencode/plugin/oh-my-opencode/
+ cp -a "$shared_skills_src"/. /opt/opencode/shared-skills/
+ ln -sfn /opt/opencode/shared-skills /opt/opencode/plugin/oh-my-opencode/node_modules/@oh-my-opencode/shared-skills
+
+ rm -rf "$tmp_dir"
+}
+
+log_info "Installing oh-my-opencode package and plugin..."
+if ! install_oh_my_opencode_package; then
+ log_error "Failed to install oh-my-opencode"
+else
log_info "Installing auth plugins..."
cd /opt/opencode/plugin
if command -v bun >/dev/null 2>&1; then
log_debug "Installing Gemini auth plugin via bun..."
- run_with_timeout "$COMMAND_TIMEOUT" "Gemini auth plugin" bun add opencode-gemini-auth@1.3.6 || log_error "Gemini auth plugin install failed"
+ run_with_timeout "$COMMAND_TIMEOUT" "Gemini auth plugin" bun add opencode-gemini-auth@1.4.15 || log_error "Gemini auth plugin install failed"
log_debug "Installing Codex auth plugin via bun..."
- run_with_timeout "$COMMAND_TIMEOUT" "Codex auth plugin" bun add opencode-openai-codex-auth@4.2.0 || log_error "Codex auth plugin install failed"
+ run_with_timeout "$COMMAND_TIMEOUT" "Codex auth plugin" bun add opencode-openai-codex-auth@4.4.0 || log_error "Codex auth plugin install failed"
else
log_debug "Bun not available for auth plugins, skipping"
fi
@@ -342,15 +516,14 @@ fi
log_info "Installing Letta Code..."
# Letta Code: memory-first coding agent (https://github.com/letta-ai/letta-code)
-if ! run_with_timeout "$COMMAND_TIMEOUT" "Letta Code npm install" npm install -g @letta-ai/letta-code; then
+if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Letta Code npm install" npm install -g @letta-ai/letta-code@latest; then
log_error "Letta Code installation failed (continuing)"
fi
log_info "Installing NotebookLM tools..."
# notebooklm-py: Python CLI + API for NotebookLM (notebooklm command)
# Base install only — no browser deps needed in container; auth mounted from host
-if run_with_timeout "$COMMAND_TIMEOUT" "notebooklm-py install" uv tool install notebooklm-py --python-preference system; then
- [ -f "$HOME/.local/bin/notebooklm" ] && ln -sf "$HOME/.local/bin/notebooklm" /usr/local/bin/notebooklm
+if run_system_uv_tool_install "notebooklm-py install" notebooklm-py; then
log_info "notebooklm-py CLI installed (notebooklm)"
else
log_error "notebooklm-py installation failed (continuing)"
@@ -358,11 +531,9 @@ fi
# notebooklm-mcp-cli: MCP server + nlm CLI for AI agent integration
# Auth is done on the host (requires browser); tokens mounted into container
-# uv tool install puts binaries in ~/.local/bin (root), so symlink to /usr/local/bin
-if run_with_timeout "$COMMAND_TIMEOUT" "NotebookLM MCP CLI install" uv tool install notebooklm-mcp-cli --python-preference system; then
- for bin in nlm notebooklm-mcp; do
- [ -f "$HOME/.local/bin/$bin" ] && ln -sf "$HOME/.local/bin/$bin" "/usr/local/bin/$bin"
- done
+# Install into a shared uv tools directory instead of root's home so bench
+# users can execute the launchers from /usr/local/bin.
+if run_system_uv_tool_install "NotebookLM MCP CLI install" notebooklm-mcp-cli; then
log_info "NotebookLM MCP CLI installed (nlm, notebooklm-mcp)"
else
log_error "NotebookLM MCP CLI installation failed (continuing)"
@@ -373,32 +544,40 @@ log_info "AI CLI Tools Installation Complete!"
log_info "=========================================="
log_info ""
-required_clis=(claude codex agy opencode)
+required_clis=(claude codex gemini copilot opencode omo letta notebooklm nlm)
missing_clis=()
for cli in "${required_clis[@]}"; do
- ensure_cli_on_path "$cli" \
- "$(npm config get prefix 2>/dev/null)/bin/$cli" \
- "$HOME/.npm-global/bin/$cli" \
- "$HOME/.local/bin/$cli" || true
if ! command -v "$cli" >/dev/null 2>&1; then
missing_clis+=("$cli")
fi
done
+if command -v claude >/dev/null 2>&1 && ! claude --version >/dev/null 2>&1; then
+ missing_clis+=("claude(runnable)")
+fi
+
if [ "${#missing_clis[@]}" -gt 0 ]; then
log_error "Missing required AI CLIs after installation: ${missing_clis[*]}"
exit 1
fi
log_info "Installed tools:"
-log_info " - OpenSpec"
log_info " - Claude Code (claude) [native installer]"
log_info " - OpenAI Codex (codex)"
-log_info " - Antigravity CLI (agy)"
-log_info " - GitHub Copilot (copilot)"
-log_info " - Grok (grok)"
+log_info " - Google Gemini (gemini)"
+if command -v agy >/dev/null 2>&1; then
+ log_info " - Google Antigravity CLI (agy)"
+else
+ log_info " - Google Antigravity CLI (agy) [checksum-gated opt-in, skipped]"
+fi
+log_info " - GitHub Copilot CLI (copilot)"
+if command -v grok >/dev/null 2>&1; then
+ log_info " - Grok CLI (grok)"
+else
+ log_info " - Grok CLI (grok) [install skipped or failed]"
+fi
log_info " - OpenCode (opencode)"
-log_info " - oh-my-opencode (darrenhinde fork with built-in agents)"
+log_info " - oh-my-opencode (omo)"
log_info " - Letta Code (letta)"
log_info " - NotebookLM CLI (notebooklm) [auth via host browser]"
log_info " - NotebookLM MCP CLI (nlm) [auth via host browser]"
diff --git a/bench-config.json.backup b/bench-config.json.backup
deleted file mode 100755
index ba19bf6..0000000
--- a/bench-config.json.backup
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "infrastructure": {
- "specKit": {
- "install": "uv tool install specify-cli --from git+https://github.com/github/spec-kit.git",
- "run": "uvx --from git+https://github.com/github/spec-kit.git specify init --here",
- "description": "GitHub Spec Kit - installed via uvx (always fetches latest)"
- }
- },
- "benches": {
- "cloudBench": {
- "url": "git@github.com:opensoft/cloudBench.git",
- "path": "sysBenches/cloudBench",
- "description": "Cloud infrastructure and operations tools"
- },
- "pythonBench": {
- "url": "git@github.com:opensoft/pythonBench.git",
- "path": "devBench/pythonBench",
- "description": "Python development environment and tools"
- },
- "javaBench": {
- "url": "git@github.com:opensoft/javaBench.git",
- "path": "devBench/javaBench",
- "description": "Java development environment and tools"
- },
- "dotNetBench": {
- "url": "git@github.com:opensoft/dotNetBench.git",
- "path": "devBench/dotNetBench",
- "description": ".NET development environment and tools"
- },
- "flutterBench": {
- "url": "git@github.com:opensoft/flutterBench.git",
- "path": "devBench/flutterBench",
- "description": "Flutter/Dart development environment and tools",
- "project_scripts": [
- {
- "name": "flutter",
- "script": "scripts/new-flutter-project.sh",
- "description": "Create a new Flutter project with DevContainer setup",
- "includes_speckit": true
- },
- {
- "name": "dartwing",
- "script": "scripts/new-dartwing-project.sh",
- "description": "Create a new DartWing project with specialized configuration",
- "includes_speckit": true
- }
- ]
- },
- "cppBench": {
- "url": "git@github.com:opensoft/cppBench.git",
- "path": "devBench/cppBench",
- "description": "C++ development environment and tools"
- }
- }
-}
diff --git a/bioBenches/base-image/Dockerfile b/bioBenches/base-image/Dockerfile
index 6809a2e..e5b8559 100644
--- a/bioBenches/base-image/Dockerfile
+++ b/bioBenches/base-image/Dockerfile
@@ -8,7 +8,7 @@ FROM workbench-base:latest
# Container version labels
LABEL layer="1c"
LABEL layer.name="bio-bench-base"
-LABEL layer.version="2.0.0"
+LABEL layer.version="2.0.2"
LABEL layer.description="Bio base with Miniconda, bioconda and conda-forge channels (user-agnostic)"
# Everything runs as root
@@ -62,7 +62,10 @@ RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkg
&& conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r \
&& conda config --system --prepend channels conda-forge \
&& conda config --system --prepend channels bioconda \
- && conda config --system --set channel_priority strict
+ && conda config --system --set channel_priority strict \
+ && CONDA_VERSION="$(curl -fsSL https://api.github.com/repos/conda/conda/releases/latest | jq -r '.tag_name')" \
+ && conda install -n base -y --override-channels -c conda-forge "conda=${CONDA_VERSION}" \
+ && conda clean -afy
# ========================================
# SHELL CONFIGURATION (into /etc/skel)
diff --git a/bioBenches/base-image/build.sh b/bioBenches/base-image/build.sh
index 704ec0c..2edf744 100755
--- a/bioBenches/base-image/build.sh
+++ b/bioBenches/base-image/build.sh
@@ -18,9 +18,11 @@ LEGACY_IMAGE="$(legacy_family_base_image bio)"
cd "$SCRIPT_DIR"
# Parse arguments (--user is accepted but ignored for backward compat)
+NO_CACHE="${NO_CACHE:-false}"
while [[ $# -gt 0 ]]; do
case $1 in
--user) shift 2 ;;
+ --no-cache) NO_CACHE=true; shift ;;
*) shift ;;
esac
done
@@ -28,6 +30,7 @@ done
echo "Configuration:"
echo " Tag: $CANONICAL_IMAGE (user-agnostic)"
echo " Legacy alias: $LEGACY_IMAGE"
+echo " No cache: $NO_CACHE"
echo ""
# Check if Layer 0 exists
@@ -43,6 +46,7 @@ fi
# Build the image
echo "Building $CANONICAL_IMAGE..."
docker build \
+ $([ "$NO_CACHE" = true ] && printf '%s\n' "--no-cache") \
-t "$CANONICAL_IMAGE" \
.
tag_family_base_legacy_alias bio
diff --git a/config/ai-harness-accounts.example.json b/config/ai-harness-accounts.example.json
new file mode 100644
index 0000000..4b3ef13
--- /dev/null
+++ b/config/ai-harness-accounts.example.json
@@ -0,0 +1,56 @@
+{
+ "version": 1,
+ "accounts": [
+ {
+ "provider": "claude",
+ "name": "personal-claude-1",
+ "family": "personal",
+ "email": "user@example.com",
+ "plan": "max",
+ "workspace": "personal",
+ "authMode": "browser",
+ "status": "active"
+ },
+ {
+ "provider": "chatgpt",
+ "name": "company-chatgpt-1",
+ "family": "company",
+ "email": "developer@company.example",
+ "plan": "business",
+ "workspace": "company",
+ "authMode": "browser",
+ "status": "active"
+ },
+ {
+ "provider": "grok",
+ "name": "personal-grok-1",
+ "family": "personal",
+ "email": "user@example.com",
+ "plan": "supergrok",
+ "workspace": "personal",
+ "authMode": "browser",
+ "status": "active"
+ },
+ {
+ "provider": "antigravity",
+ "name": "personal-antigravity-1",
+ "family": "personal",
+ "email": "user@gmail.com",
+ "plan": "individual",
+ "workspace": "personal",
+ "authMode": "keyring",
+ "status": "active"
+ },
+ {
+ "provider": "abacus",
+ "name": "company-abacus-1",
+ "family": "company",
+ "email": "developer@company.example",
+ "plan": "team",
+ "workspace": "company",
+ "authMode": "api-key",
+ "secretEnv": "ABACUS_API_KEY_COMPANY_1",
+ "status": "active"
+ }
+ ]
+}
diff --git a/config/ai-profile-source.example.json b/config/ai-profile-source.example.json
new file mode 100644
index 0000000..e438e92
--- /dev/null
+++ b/config/ai-profile-source.example.json
@@ -0,0 +1,48 @@
+{
+ "version": 1,
+ "kind": "workbenches-ai-profile-source",
+ "owner": {
+ "type": "user",
+ "id": "example-engineer"
+ },
+ "credentialContractVersion": 1,
+ "profiles": {
+ "claude": [
+ {
+ "name": "personal-claude-1",
+ "email": "engineer@example.com",
+ "family": "personal",
+ "aliases": ["personal-claude"],
+ "accountId": "example-engineer.claude.personal-claude-1.account",
+ "credentialId": "example-engineer.claude.personal-claude-1.credential",
+ "authentication": {
+ "type": "subscription_oauth",
+ "credentialRef": "ai/secrets/claude/personal-claude-1.credentials.sops.yaml",
+ "escrowStatus": "not-escrowed"
+ }
+ }
+ ],
+ "openai": [
+ {
+ "name": "personal-chatgpt-1",
+ "email": "engineer@example.com",
+ "family": "personal",
+ "aliases": ["personal-chatgpt"],
+ "plan": "plus",
+ "workspace": "personal",
+ "status": "active",
+ "accountId": "example-engineer.openai.personal-chatgpt-1.account",
+ "credentialId": "example-engineer.openai.personal-chatgpt-1.credential",
+ "authentication": {
+ "type": "workspace_access_token",
+ "credentialRef": "ai/secrets/openai/personal-chatgpt-1.credentials.sops.yaml",
+ "escrowStatus": "not-escrowed"
+ }
+ }
+ ],
+ "gemini": [],
+ "grok": [],
+ "glm": []
+ },
+ "providerParity": []
+}
diff --git a/config/bench-config.json b/config/bench-config.json
index 5f7acee..f852930 100755
--- a/config/bench-config.json
+++ b/config/bench-config.json
@@ -14,7 +14,7 @@
"ai_keywords": ["cloud", "infrastructure", "devops", "kubernetes", "docker", "deployment", "admin", "monitoring", "logging"]
},
"pyBench": {
- "url": "git@github.com:opensoft/pyBench.git",
+ "url": "git@github.com:opensoft/pyBench.git",
"path": "devBenches/pyBench",
"description": "Python development environment and tools",
"ai_keywords": ["python", "django", "flask", "fastapi", "pandas", "numpy", "machine learning", "ml", "data science", "AI", "artificial intelligence", "web scraping"],
diff --git a/config/claude-profiles.example.json b/config/claude-profiles.example.json
new file mode 100644
index 0000000..876c66d
--- /dev/null
+++ b/config/claude-profiles.example.json
@@ -0,0 +1,7 @@
+{
+ "version": 1,
+ "profiles": [
+ { "name": "work", "family": "work", "email": "user@company.example" },
+ { "name": "personal", "family": "personal", "email": "user@example.com" }
+ ]
+}
diff --git a/config/claude/workflows/deep-swarm-code-review.js b/config/claude/workflows/deep-swarm-code-review.js
new file mode 100644
index 0000000..b91cbb2
--- /dev/null
+++ b/config/claude/workflows/deep-swarm-code-review.js
@@ -0,0 +1,438 @@
+export const meta = {
+ name: 'deep-swarm-code-review',
+ description: 'Swarm of expert subagents deep-reviews a PR / branch / uncommitted diff, adversarially verifies each finding, and (in PR mode) automatically posts all confirmed findings as inline comments on the PR',
+ whenToUse: 'Deep multi-agent code review. Auto-targets: open PR for the current branch, else committed branch-vs-main, else uncommitted working-tree changes. In PR mode it ALWAYS posts all confirmed findings to the PR automatically (set args.post=false to suppress, args.dedupeAgainstExisting=true to skip findings already commented on the PR). Override with args {mode:"pr"|"branch"|"uncommitted", prNumber, base}.',
+ phases: [
+ { title: 'Scope', detail: 'detect review target (PR / branch / uncommitted) + partition the diff' },
+ { title: 'Review', detail: 'multi-pass swarm — each pass adds expert lenses, finer file units, deeper digging' },
+ { title: 'Verify', detail: 'independent skeptic verifies each finding + validates the diff line' },
+ { title: 'Post', detail: 'auto-publish one consolidated GitHub review of all confirmed findings (PR mode)' },
+ ],
+}
+
+// ============================================================================
+// args (all optional — workflow auto-detects when omitted):
+// mode: 'pr' | 'branch' | 'uncommitted'
+// prNumber: number (pr mode)
+// base: base ref for committed diffs (default auto: origin/main || main)
+// post: boolean — post results to GitHub (default true in pr mode)
+// repoRoot: absolute path (default: current working dir of agents)
+// ============================================================================
+const cfg = args || {}
+const REPO_ROOT = cfg.repoRoot || '.'
+const POST = cfg.post !== false // PR mode auto-posts unless explicitly disabled
+const DEDUPE = cfg.dedupeAgainstExisting === true // skip findings already commented on the PR
+
+// ---- Phase 0: detect the review target -------------------------------------
+phase('Scope')
+
+const SCOPE_SCHEMA = {
+ type: 'object',
+ required: ['mode', 'base', 'summary'],
+ properties: {
+ mode: { type: 'string', enum: ['pr', 'branch', 'uncommitted'] },
+ prNumber: { type: 'integer' },
+ base: { type: 'string' },
+ branch: { type: 'string' },
+ summary: { type: 'string' },
+ },
+}
+
+let scope
+if (cfg.mode) {
+ scope = { mode: cfg.mode, prNumber: cfg.prNumber, base: cfg.base || 'origin/main', summary: 'from args' }
+} else {
+ scope = await agent(
+ `Determine what this code review should target. Repo root: ${REPO_ROOT}. Use Bash (git, gh).
+
+Decide ONE mode, in this priority order:
+1. 'pr' — if 'gh pr view --json number,baseRefName,headRefName' shows an OPEN PR for the CURRENT branch. Capture prNumber and base (the PR's baseRefName, e.g. origin/main or main).
+2. 'uncommitted' — else if 'git status --porcelain' shows tracked changes (the working tree is dirty). base = HEAD.
+3. 'branch' — else review committed work on this branch vs its base. base = whichever of 'origin/main' or 'main' exists (prefer origin/main). If the current branch IS main/master with no PR and a clean tree, still pick 'branch' with base = the previous commit's parent (HEAD~1) and note it in summary.
+
+Return the chosen mode, base ref string (usable in 'git diff ...HEAD' for pr/branch, or literally 'HEAD' for uncommitted), prNumber if pr, branch name, and a one-line summary of what will be reviewed.`,
+ { label: 'scope:detect', phase: 'Scope', schema: SCOPE_SCHEMA },
+ )
+}
+
+const MODE = scope.mode
+const BASE = scope.base || 'origin/main'
+const PRNUM = scope.prNumber || cfg.prNumber
+log(`Target: ${MODE}${PRNUM ? ' #' + PRNUM : ''} (base=${BASE}) — ${scope.summary}`)
+
+// How each reviewer obtains its slice of the diff, by mode.
+function diffSpec(files) {
+ const fileArgs = files.map(f => `'${f}'`).join(' ')
+ if (MODE === 'uncommitted') {
+ return `Review UNCOMMITTED changes only:\n git -C ${REPO_ROOT} diff HEAD -- ${fileArgs}\n (also 'git -C ${REPO_ROOT} status --porcelain -- ${fileArgs}' for new untracked files).`
+ }
+ return `BASE detection (run first):\n BASE=$(git -C ${REPO_ROOT} merge-base HEAD ${BASE} 2>/dev/null || git -C ${REPO_ROOT} merge-base HEAD main || echo ${BASE})\nThen review the committed diff:\n git -C ${REPO_ROOT} diff "$BASE"...HEAD -- ${fileArgs}`
+}
+
+// ---- Phase 1 setup: discover changed files and group them ------------------
+// A reviewer agent reads the changed-file list and partitions it into coherent
+// subsystem groups, so the workflow adapts to whatever diff it is pointed at.
+const GROUPS_SCHEMA = {
+ type: 'object',
+ required: ['groups'],
+ properties: {
+ groups: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['name', 'persona', 'files'],
+ properties: {
+ name: { type: 'string' },
+ persona: { type: 'string' },
+ files: { type: 'array', items: { type: 'string' } },
+ },
+ },
+ },
+ },
+}
+
+const listCmd = MODE === 'uncommitted'
+ ? `git -C ${REPO_ROOT} diff --name-only HEAD; git -C ${REPO_ROOT} ls-files --others --exclude-standard`
+ : `BASE=$(git -C ${REPO_ROOT} merge-base HEAD ${BASE} 2>/dev/null || git -C ${REPO_ROOT} merge-base HEAD main || echo ${BASE}); git -C ${REPO_ROOT} diff --name-only "$BASE"...HEAD`
+
+const partition = await agent(
+ `List the changed files for this review and partition them into coherent review groups.
+
+Run:
+ ${listCmd}
+
+Then group the changed files into 8–24 subsystem groups so each group is a coherent unit one expert can review well (group by directory / language / feature; keep related scripts together; isolate large/high-risk files into their own group). For each group give: a short kebab 'name', a 'persona' (the kind of expert best suited — e.g. "a defensive Bash engineer", "a Docker layered-build expert", "a senior Python engineer", "a PowerShell automation expert", "a config/JSON correctness reviewer", "a refactor-safety auditor for renamed/removed paths"), and the exact repo-relative 'files' (every changed file must appear in exactly one group). Aim to cover EVERY changed file.`,
+ { label: 'scope:partition', phase: 'Scope', schema: GROUPS_SCHEMA },
+)
+
+const GROUPS = (partition.groups || []).filter(g => g.files && g.files.length)
+if (!GROUPS.length) {
+ log('No changed files found — nothing to review.')
+ return { mode: MODE, base: BASE, confirmedCount: 0, confirmed: [] }
+}
+log(`Swarm: ${GROUPS.length} expert reviewers over ${GROUPS.reduce((n, g) => n + g.files.length, 0)} changed files`)
+
+// ---- shared reviewer guidance ----------------------------------------------
+const SHARED_RULES = `
+You are reviewing a real change. Work from the actual diff and full file context — do NOT speculate.
+
+SCOPE: Report only problems introduced or touched by THIS diff. Ignore pre-existing issues in unchanged lines.
+
+LOOK FOR (weight by real impact):
+- Correctness / logic bugs, wrong conditionals, off-by-one, bad expansion, unset-var use.
+- Shell robustness: unquoted expansions, word-splitting, missing 'set -euo pipefail' where it matters, ignored exit codes, fragile parsing, non-portable bashisms in /bin/sh, eval misuse, unguarded cd, unsafe rm globs.
+- Security: command injection, curl|bash of untrusted input, secret/token leakage, unsafe temp files, world-readable creds, permissions.
+- Cross-platform / cross-shell parity (bash vs zsh vs PowerShell; macOS vs Linux: sed -i, mktemp, readlink).
+- Dockerfile: cache busting, missing cleanup/--no-install-recommends, root vs user, version pinning where it matters, COPY/chmod correctness.
+- Config/JSON/YAML: invalid syntax, wrong keys, broken references to renamed/removed paths.
+- Dead code, broken cross-file refs, renames the diff didn't propagate everywhere.
+
+PRECISION (critical for the next stage):
+- "file" MUST be the repo-relative path exactly as in the diff.
+- "line" MUST be a line number in the NEW (post-change) file — a line on the RIGHT side of the diff (an added '+' line, or a context line inside a changed hunk). Read the file to get the exact number. Prefer an added '+' line.
+- "body" is GitHub-Markdown: state the concrete problem, why it matters, and a specific fix (a short \`\`\`suggestion\`\`\` block is ideal).
+- Quality over quantity. Skip pure style nits with no functional impact. A finding you are not fairly confident is real does more harm than good downstream.
+
+Return findings via the structured tool. An empty list is a valid answer.`
+
+function reviewerPrompt(u) {
+ const known = (u.known && u.known.length)
+ ? `\nALREADY-REPORTED in this area by earlier reviewers — do NOT repeat these. Find DIFFERENT, deeper, or adjacent problems the others missed:\n${u.known.map(k => ` - ${k.file}:${k.line} — ${k.title}`).join('\n')}\n`
+ : ''
+ const deep = u.depth
+ ? 'This is a DEEP pass: read each touched file in full, trace control/data flow into the sibling files it sources or calls (and that call it), and reason about non-obvious failure modes, edge cases, and cross-file interactions — not just surface-level bugs.\n'
+ : ''
+ return `You are ${u.persona}. Repo root: ${REPO_ROOT}.
+Review lens for this pass: ${u.lensDesc}
+
+Review these changed files:
+${u.files.map(f => ' - ' + f).join('\n')}
+
+${diffSpec(u.files)}
+
+Use Bash (git diff / grep) and Read freely to get the diff and full surrounding context before judging.
+${deep}${known}${SHARED_RULES}`
+}
+
+const FINDINGS_SCHEMA = {
+ type: 'object',
+ required: ['findings'],
+ properties: {
+ findings: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['file', 'line', 'severity', 'category', 'title', 'body', 'confidence'],
+ properties: {
+ file: { type: 'string' },
+ line: { type: 'integer' },
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
+ category: { type: 'string' },
+ title: { type: 'string' },
+ body: { type: 'string' },
+ confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
+ },
+ },
+ },
+ },
+}
+
+const VERDICT_SCHEMA = {
+ type: 'object',
+ required: ['keep', 'reason'],
+ properties: {
+ keep: { type: 'boolean' },
+ reason: { type: 'string' },
+ adjustedLine: { type: 'integer' },
+ adjustedSeverity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
+ refinedBody: { type: 'string' },
+ inDiff: { type: 'boolean' },
+ },
+}
+
+function verifyPrompt(f) {
+ return `You are an independent, skeptical senior reviewer. A prior reviewer raised the finding below. REFUTE it unless it clearly holds up. Default keep=false when uncertain, when it is style-only, or when it concerns unchanged/pre-existing code.
+
+Repo root: ${REPO_ROOT}
+Finding file: ${f.file}
+Claimed NEW-file line: ${f.line}
+Severity: ${f.severity} | Category: ${f.category}
+Title: ${f.title}
+Body:
+${f.body}
+
+Steps:
+1. ${diffSpec([f.file]).split('\n').join('\n ')}
+ Confirm the cited line is part of this diff (an added '+' line or context line inside a changed hunk). Set inDiff. If the issue is real but the line is slightly off, put the correct NEW-file line (one that IS in the diff) in adjustedLine.
+2. Read surrounding code to confirm the problem is REAL with practical impact, not a misreading.
+3. Decide keep (true only if real, impactful, tied to changed lines). One-sentence reason. Optionally set adjustedSeverity and improve refinedBody (GitHub-Markdown).
+
+Return via the structured tool.`
+}
+
+// ---- Multi-pass swarm — wider lenses + finer granularity + deeper digging each pass
+// Each pass adds more expert lenses, splits the diff into finer units, and tells
+// every reviewer what earlier passes already found so it hunts for NEW, deeper issues.
+const PASSES = Math.max(1, cfg.passes || 3)
+const MAX_UNITS = cfg.maxReviewersPerPass || 120 // per-pass reviewer cap (cost guard)
+
+const LENS_SETS = [
+ // Pass 1 — broad sweep, one generalist per subsystem
+ [{ key: 'core', desc: 'overall correctness, logic bugs, and the highest-impact robustness problems' }],
+ // Pass 2 — specialist quartet, applied per subsystem
+ [
+ { key: 'security', desc: 'security: command/regex injection, secret & token handling, file permissions, unsafe temp files, curl|bash of untrusted input' },
+ { key: 'robustness', desc: 'shell robustness & portability: quoting/word-splitting, set -euo pipefail interactions, ignored exit codes, GNU-vs-BSD/macOS, bash-vs-zsh-vs-POSIX' },
+ { key: 'consistency', desc: 'cross-file consistency: renamed/removed paths, parser parity between sibling scripts, docs/READMEs that contradict behavior, broken references' },
+ { key: 'control-flow', desc: 'control-flow & tool/API semantics: wrong conditionals, early/no-op exits, broken orchestration, misused CLIs/builtins, idempotency on re-run' },
+ ],
+ // Pass 3+ — full battery, per-file granularity, deep flow tracing
+ [
+ { key: 'concurrency', desc: 'concurrency, races, locking, and idempotency under parallel or repeated invocation' },
+ { key: 'error-handling', desc: 'error handling & failure modes: partial failures, missing guards, silent skips, cleanup/trap correctness' },
+ { key: 'edge-cases', desc: 'edge cases & input validation: empty/whitespace/unicode/missing inputs, unusual paths, boundary conditions' },
+ { key: 'perf-resource', desc: 'performance & resource use: redundant work, repeated network/subprocess calls, unbounded loops, leaks' },
+ { key: 'docs-ux', desc: 'documentation/UX accuracy: help text, READMEs, comments, and error messages vs actual behavior' },
+ ],
+]
+
+function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out }
+const sevRank = { critical: 0, high: 1, medium: 2, low: 3 }
+
+const confirmedAll = []
+const seenByFile = {}
+function titleKey(t) { return (t || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').split(/\s+/).filter(Boolean).slice(0, 6).join(' ') }
+// Dup only if same file AND within ±3 lines AND (identical line OR similar title).
+// Different-angle findings (e.g. a security vs a perf issue) on nearby lines survive.
+function isNew(f) {
+ const tk = titleKey(f.title)
+ for (const e of (seenByFile[f.file] || [])) {
+ if (Math.abs(e.line - f.line) <= 3 && (e.line === f.line || e.tkey === tk)) return false
+ }
+ return true
+}
+function remember(f) { (seenByFile[f.file] = seenByFile[f.file] || []).push({ line: f.line, tkey: titleKey(f.title) }) }
+function knownFor(files) { const s = new Set(files); return confirmedAll.filter(f => s.has(f.file)).map(f => ({ file: f.file, line: f.line, title: f.title })) }
+function normalize(x) {
+ return {
+ file: x.file,
+ line: (Number.isInteger(x.verdict.adjustedLine) ? x.verdict.adjustedLine : x.line),
+ severity: x.verdict.adjustedSeverity || x.severity,
+ category: x.category, title: x.title,
+ body: x.verdict.refinedBody || x.body,
+ confidence: x.confidence, group: x.group,
+ inDiff: x.verdict.inDiff !== false, verifyReason: x.verdict.reason,
+ }
+}
+
+let rawTotal = 0
+for (let p = 1; p <= PASSES; p++) {
+ // widen across passes: each pass applies a DISTINCT tier of lenses (it does NOT
+ // re-run earlier tiers — re-running 'core' every pass just rediscovers pass-1
+ // findings and wastes the budget). Reviewers still get earlier findings as context.
+ const tier = Math.min(p - 1, LENS_SETS.length - 1)
+ const lenses = LENS_SETS[tier]
+ // deepen: finer file chunks + full-file deep reads on later passes
+ const chunkSize = p <= 1 ? 999 : (p === 2 ? 3 : 2)
+ const deep = p >= 3
+
+ // Build per-lens unit lists, then interleave round-robin so that if the per-pass
+ // cap trims, it trims EVENLY across lenses and subsystems (never starves a lens).
+ const perLens = lenses.map(lens => {
+ const arr = []
+ for (const g of GROUPS) for (const fc of chunk(g.files, chunkSize)) {
+ arr.push({
+ name: `${g.name}/${lens.key}`,
+ persona: lens.key === 'core' ? g.persona : `${g.persona}, reviewing specifically through a ${lens.key} lens`,
+ lensDesc: lens.desc, files: fc, depth: deep, known: knownFor(fc),
+ })
+ }
+ return arr
+ })
+ const totalUnits = perLens.reduce((n, a) => n + a.length, 0)
+ let units = []
+ for (let i = 0; units.length < totalUnits; i++) {
+ for (const arr of perLens) if (i < arr.length) units.push(arr[i])
+ }
+ if (units.length > MAX_UNITS) { log(`Pass ${p}: ${totalUnits} reviewer units → capped to ${MAX_UNITS} (interleaved across lenses; raise args.maxReviewersPerPass for fuller coverage)`); units = units.slice(0, MAX_UNITS) }
+
+ phase(`Pass ${p} · Review`)
+ log(`Pass ${p}/${PASSES}: ${units.length} expert reviewers — lenses [${lenses.map(l => l.key).join(', ')}], chunk=${chunkSize}${deep ? ', deep' : ''}`)
+
+ const reviewed = await pipeline(
+ units,
+ u => agent(reviewerPrompt(u), { label: `p${p}:${u.name}`, phase: `Pass ${p} · Review`, schema: FINDINGS_SCHEMA })
+ .then(r => ({ u, findings: (r && r.findings) || [] }))
+ .catch(() => ({ u, findings: [] })),
+ (res) => parallel((res.findings).map(f => () =>
+ agent(verifyPrompt(f), { label: `p${p}:verify:${(f.file || '').split('/').pop()}:${f.line}`, phase: `Pass ${p} · Verify`, schema: VERDICT_SCHEMA })
+ .then(v => ({ ...f, group: res.u.name, verdict: v }))
+ .catch(() => null)
+ )),
+ )
+
+ const passRaw = reviewed.flat().filter(Boolean)
+ rawTotal += passRaw.length
+ const passConfirmed = passRaw.filter(x => x.verdict && x.verdict.keep).map(normalize)
+ let added = 0
+ for (const f of passConfirmed) { if (isNew(f)) { confirmedAll.push(f); remember(f); added++ } }
+ log(`Pass ${p}: +${added} net-new confirmed (running total ${confirmedAll.length})`)
+
+ if (budget.total && budget.remaining() < 80000) { log(`Budget low (${Math.round(budget.remaining() / 1000)}k left) — stopping after pass ${p}.`); break }
+}
+
+confirmedAll.sort((a, b) => (sevRank[a.severity] - sevRank[b.severity]) || a.file.localeCompare(b.file) || a.line - b.line)
+const counts = confirmedAll.reduce((m, f) => (m[f.severity] = (m[f.severity] || 0) + 1, m), {})
+log(`Total confirmed (deduped) across passes: ${confirmedAll.length} from ${rawTotal} raw — ${JSON.stringify(counts)}`)
+
+// ---- Phase 3: auto-post ONE consolidated GitHub review (PR mode) -----------
+// In PR mode the workflow ALWAYS posts every confirmed finding (unless post=false).
+// The posting agent follows an exact, deterministic procedure so it is reliable
+// unattended: it parses the diff with the embedded python script (no guessing
+// about which lines are commentable) and submits one COMMENT review.
+let posted = { attempted: false }
+if (POST && MODE === 'pr' && PRNUM && confirmedAll.length) {
+ phase('Post')
+ const payload = JSON.stringify({ prNumber: PRNUM, counts, dedupe: DEDUPE, findings: confirmedAll })
+ const postResult = await agent(
+ `Publish the verified findings below as ONE consolidated GitHub pull-request review on PR #${PRNUM}, with each finding as an inline comment. Repo root: ${REPO_ROOT}. This DOES publish to GitHub — that is the intended behavior, post everything that maps. Use Bash (gh, git, python3).
+
+FINDINGS JSON (write it to a temp file, e.g. /tmp/swarm_findings.json):
+${payload}
+
+Run EXACTLY this procedure (do not improvise the diff parsing):
+
+STEP 1 — fetch the diff and owner/repo:
+ gh pr diff ${PRNUM} > /tmp/pr_${PRNUM}.diff
+ OWNER_REPO=$(gh repo view --json owner,name --jq '.owner.login + "/" + .name')
+
+STEP 2 — run this python3 script verbatim (it parses commentable RIGHT-side lines, snaps each finding to a valid line within ±3, optionally dedupes against existing PR comments, and writes the review payload):
+
+ cat > /tmp/build_review.py <<'PY'
+ import json, re, subprocess, sys
+ PR = "${PRNUM}"
+ data = json.load(open('/tmp/swarm_findings.json'))
+ findings = data['findings']; counts = data['counts']; dedupe = data.get('dedupe', False)
+ # 1. valid RIGHT-side (new-file) line numbers per path
+ valid = {}; cur=None; new_ln=None
+ for line in open('/tmp/pr_%s.diff' % PR):
+ if line.startswith('diff --git '): cur=None; new_ln=None; continue
+ if line.startswith('+++ '):
+ p=line[4:].strip(); cur=None if p=='/dev/null' else (p[2:] if p.startswith('b/') else p)
+ if cur: valid.setdefault(cur,set())
+ continue
+ if line.startswith('@@'):
+ m=re.search(r'\\+(\\d+)(?:,(\\d+))?',line); new_ln=int(m.group(1)) if m else None; continue
+ if cur is None or new_ln is None: continue
+ if line.startswith('+') and not line.startswith('+++'): valid[cur].add(new_ln); new_ln+=1
+ elif line.startswith('-') and not line.startswith('---'): pass
+ elif line.startswith('\\\\'): pass
+ else: valid[cur].add(new_ln); new_ln+=1
+ # 2. optional dedupe vs existing PR comments (existing comments file passed via EXISTING_JSON env)
+ import os
+ posted={}
+ if dedupe and os.environ.get('EXISTING_JSON'):
+ for c in json.load(open(os.environ['EXISTING_JSON'])):
+ ln=c.get('line') or c.get('original_line')
+ if c.get('path') and ln: posted.setdefault(c['path'],[]).append(ln)
+ # 3. map findings
+ comments=[]; unmapped=[]
+ for f in findings:
+ if dedupe and any(abs(l-f['line'])<=6 for l in posted.get(f['file'],[])):
+ continue
+ vs=valid.get(f['file']); ln=f['line']; chosen=None
+ if vs:
+ if ln in vs: chosen=ln
+ else:
+ cands=[l for l in vs if abs(l-ln)<=3]
+ if cands: chosen=min(cands,key=lambda l:(abs(l-ln),l))
+ if chosen is not None:
+ comments.append({"path":f['file'],"line":chosen,"side":"RIGHT","body":"**[%s]** %s"%(f['severity'],f['body'])})
+ else:
+ unmapped.append(f)
+ # 4. summary body
+ hi=[f for f in findings if f['severity']=='high' or f['severity']=='critical']
+ lines=["## 🤖 AI Swarm Code Review",""]
+ lines.append("Deep multi-agent review: expert subagents partitioned the diff by subsystem; every finding was adversarially verified by an independent skeptic before posting.")
+ lines.append("")
+ lines.append("**Confirmed findings: %d** — %s. %d posted as inline comments below." % (len(findings), json.dumps(counts), len(comments)))
+ if hi:
+ lines.append(""); lines.append("Highlights (high severity):")
+ for f in hi[:6]: lines.append("- **%s** — %s" % (f['file'].split('/')[-1], f['title']))
+ if unmapped:
+ lines.append(""); lines.append("Findings that could not be mapped to a diff line (shown here instead):")
+ for f in unmapped: lines.append("- **[%s] %s:%s** — %s" % (f['severity'], f['file'], f['line'], f['title']))
+ lines.append(""); lines.append("_Advisory; severities are the swarm's estimate. Generated with Claude Code._")
+ payload={"event":"COMMENT","body":"\\n".join(lines),"comments":comments}
+ json.dump(payload, open('/tmp/review_payload.json','w'))
+ print("MAPPED",len(comments),"UNMAPPED",len(unmapped))
+ PY
+ ${DEDUPE ? `gh api --paginate repos/$OWNER_REPO/pulls/${PRNUM}/comments > /tmp/existing_comments.json; EXISTING_JSON=/tmp/existing_comments.json python3 /tmp/build_review.py` : `python3 /tmp/build_review.py`}
+
+STEP 3 — submit ONE review:
+ gh api --method POST repos/$OWNER_REPO/pulls/${PRNUM}/reviews --input /tmp/review_payload.json --jq '{id, state, html_url}'
+
+STEP 4 — if the API returns 422 mentioning a specific line/path, remove that one comment from /tmp/review_payload.json (python or jq) and resubmit so the rest still post. Repeat at most 3 times.
+
+STEP 5 — verify and report: count how many comments belong to the new review id and return a concise report: number of inline comments posted, number unmapped, and the review html_url.`,
+ { label: 'post:github-review', phase: 'Post' },
+ )
+ posted = { attempted: true, report: postResult }
+ log('Auto-posted GitHub review.')
+} else if (POST && MODE === 'pr' && !confirmedAll.length) {
+ log('No confirmed findings — nothing to post.')
+} else if (!POST && MODE === 'pr') {
+ log('post=false — skipping GitHub posting (findings returned in result).')
+}
+
+return {
+ mode: MODE,
+ base: BASE,
+ prNumber: PRNUM,
+ passes: PASSES,
+ rawFindings: rawTotal,
+ confirmedCount: confirmedAll.length,
+ counts,
+ confirmed: confirmedAll,
+ posted,
+}
diff --git a/config/openai-profiles.example.json b/config/openai-profiles.example.json
new file mode 100644
index 0000000..abbc3d2
--- /dev/null
+++ b/config/openai-profiles.example.json
@@ -0,0 +1,17 @@
+{
+ "version": 1,
+ "profiles": [
+ {
+ "name": "work-chatgpt-1",
+ "family": "work",
+ "email": "user@company.example",
+ "aliases": ["work1"]
+ },
+ {
+ "name": "personal-chatgpt-1",
+ "family": "personal",
+ "email": "user@example.com",
+ "aliases": ["personal1"]
+ }
+ ]
+}
diff --git a/config/shell/zshrc b/config/shell/zshrc
index be3e5aa..083d5ad 100755
--- a/config/shell/zshrc
+++ b/config/shell/zshrc
@@ -578,7 +578,7 @@ fi
export KUBECONFIG=/home/brett/projects/adminbench/brett-heap.kubeconfig
export PATH="/usr/bin:$PATH"
export NODE_PATH=/usr/bin/node
-export NODE_VERSION=22.20.0
+export NODE_VERSION=24.18.0
export NODEJS_HOME=/usr/bin
# Claude Code Node.js configuration
@@ -609,8 +609,69 @@ alias devbench-status='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Por
alias devbench-stop='docker stop java_bench dot_net_bench flutter_bench 2>/dev/null || true'
# End DevBench Aliases
-# Claude alias
-alias yolo="claude --dangerously-skip-permissions --teammate-mode tmux"
+# Claude helper. Runs the main Claude session inside tmux so another operator
+# can capture and drive it, while still letting Claude use tmux for teammates.
+_yolo_shell_quote() {
+ local quoted="" arg
+
+ for arg in "$@"; do
+ quoted="${quoted} $(printf '%q' "$arg")"
+ done
+
+ printf '%s\n' "${quoted# }"
+}
+
+unalias yolo 2>/dev/null || true
+yolo() {
+ local session_name command_string prompt_file
+ local -a prompt_args
+
+ if ! command -v claude >/dev/null 2>&1; then
+ echo "yolo: Claude CLI not found on PATH" >&2
+ return 1
+ fi
+
+ if ! command -v tmux >/dev/null 2>&1; then
+ echo "yolo: tmux not found on PATH" >&2
+ return 1
+ fi
+
+ prompt_file=""
+ for candidate_prompt_file in \
+ "$HOME/.claude/prompts/speckit-dashboard-full.md" \
+ "/usr/local/share/ct/claude/prompts/speckit-dashboard-full.md" \
+ "$HOME/.claude/prompts/speckit-dashboard-bootstrap.md"; do
+ if [ -r "$candidate_prompt_file" ]; then
+ prompt_file="$candidate_prompt_file"
+ break
+ fi
+ done
+ prompt_args=()
+ if [ -n "$prompt_file" ]; then
+ prompt_args=(--append-system-prompt-file "$prompt_file")
+ fi
+
+ if [ -n "${TMUX:-}" ]; then
+ claude --dangerously-skip-permissions --teammate-mode tmux "${prompt_args[@]}" "$@"
+ return $?
+ fi
+
+ session_name="yolo-$(date +%Y%m%d%H%M%S)-$$"
+ command_string=$(_yolo_shell_quote \
+ claude \
+ --dangerously-skip-permissions \
+ --teammate-mode tmux \
+ "${prompt_args[@]}" \
+ "$@") || return 1
+
+ tmux new-session -d -s "$session_name" -c "$PWD" "exec $command_string" || {
+ echo "yolo: failed to start tmux session" >&2
+ return 1
+ }
+
+ tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true
+ tmux attach-session -t "$session_name"
+}
export PATH="$HOME/.npm-global/bin:$PATH"
export PATH="/workspace/.venv/bin:$PATH"
diff --git a/devBenches/.devcontainer/devcontainer.json b/devBenches/.devcontainer/devcontainer.json
index 4bfe9d3..4581d8a 100644
--- a/devBenches/.devcontainer/devcontainer.json
+++ b/devBenches/.devcontainer/devcontainer.json
@@ -3,6 +3,7 @@
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
"service": "devbench",
"workspaceFolder": "/workspace",
+ "initializeCommand": "bash ${localWorkspaceFolder}/scripts/ensure-sonarqube-mcp.sh",
"shutdownAction": "stopCompose",
"forwardPorts": [1455],
diff --git a/devBenches/README.md b/devBenches/README.md
index 18ae108..8c8a71a 100755
--- a/devBenches/README.md
+++ b/devBenches/README.md
@@ -10,6 +10,7 @@ Each subfolder is a separate git repository containing a complete development en
- **`dotNetBench/`** - .NET development environment with DevContainer
- **`flutterBench/`** - Flutter/Dart development environment with DevContainer
- **`javaBench/`** - Java development environment with DevContainer
+- **`phpBench/`** - PHP development environment with DevContainer
- **`pyBench/`** - Python development environment with DevContainer
## Layered Containers (Current Standard)
@@ -20,6 +21,17 @@ All benches are moving to the layered image model described in `workBenches/docs
- **Layer 2**: `-bench:latest`
- **Layer 3**: `-bench:{user}` (user personalization)
+Layer 1a carries the shared developer tooling used by all devBenches, including:
+- `sonar-scanner` - SonarScanner CLI for project analysis uploads to SonarQube Server or SonarQube Cloud
+- `sonar` - SonarQube CLI for issue/project workflows, secrets scanning, and agent integrations
+- `sonar-env` - container-safe Sonar environment loader that reads `~/.config/sonarqube/sonar.env`
+- `gt` - Graphite CLI for stacked pull request workflows
+
+Devcontainers should mount `~/.config/sonarqube` read-only or mount the full host
+home directory. The shared `sonar-env` helper then loads tokens at runtime and
+sets `SONARQUBE_CLI_KEYCHAIN_FILE` to a writable file-backed keychain so `sonar`
+does not depend on a desktop keychain service inside containers.
+
## Legacy Monolithic DevContainers (Deprecated)
Some benches still include a `.devcontainer/` directory with a monolithic Dockerfile. These are **legacy** and should not be used as the source of truth. Use the layered images and bench-level build scripts instead; treat monolithic Dockerfiles as deprecated artifacts until removed.
diff --git a/devBenches/base-image/Dockerfile b/devBenches/base-image/Dockerfile
index 80b57ad..e28c4b1 100644
--- a/devBenches/base-image/Dockerfile
+++ b/devBenches/base-image/Dockerfile
@@ -1,6 +1,7 @@
+# syntax=docker/dockerfile:1.7
# Layer 1a: Developer Base Image
-# Extends Layer 0 with Python, Node.js LTS, and dev tools
-# AI CLIs are inherited from Layer 0 (workbench-base)
+# Extends Layer 0 with Python, Node.js LTS, dev tools, and spec CLIs
+# Most AI CLIs are inherited from Layer 0 (workbench-base)
# Used by ALL developer benches (Frappe, Flutter, .NET, etc.)
# USER-AGNOSTIC: No user creation — Layer 3 handles user setup
@@ -9,7 +10,7 @@ FROM workbench-base:latest
# Container version labels
LABEL layer="1"
LABEL layer.name="dev-bench-base"
-LABEL layer.version="2.3.1"
+LABEL layer.version="2.3.2"
LABEL layer.description="Developer base with Python, Node.js LTS, dev tools, Playwright browsers, generic testing tools, and the Omnigent meta-harness (user-agnostic)"
# Everything runs as root
@@ -50,11 +51,12 @@ RUN pip install --break-system-packages \
pytest \
ipython
-# Install Node.js development tools (husky, commitlint)
+# Install Node.js development tools and cross-repo workflow CLIs.
RUN npm install -g \
husky \
@commitlint/cli \
- @commitlint/config-conventional
+ @commitlint/config-conventional \
+ @withgraphite/graphite-cli@stable
# ========================================
# DEVELOPER TOOLS SETUP
@@ -63,7 +65,74 @@ RUN npm install -g \
# Verify Python and pip
RUN python3 --version && pip --version
-# uv, AI CLIs are inherited from Layer 0 (workbench-base)
+# uv and most AI CLIs are inherited from Layer 0 (workbench-base)
+
+# ========================================
+# SPEC-DRIVEN DEVELOPMENT TOOLS
+# ========================================
+
+# Remove any inherited copies so Layer 1a is the clear owner of these tools.
+RUN npm uninstall -g @fission-ai/openspec || true \
+ && rm -f /usr/bin/openspec /usr/local/bin/openspec \
+ && rm -rf /usr/lib/node_modules/@fission-ai/openspec /usr/local/lib/node_modules/@fission-ai/openspec \
+ && uv tool uninstall specify-cli || true \
+ && env UV_TOOL_BIN_DIR=/usr/local/bin UV_TOOL_DIR=/opt/uv/tools uv tool uninstall specify-cli || true \
+ && rm -f /root/.local/bin/specify /usr/local/bin/specify \
+ && rm -rf /root/.local/share/uv/tools/specify-cli /opt/uv/tools/specify-cli
+
+# Install spec-driven CLIs only in developer benches, not every bench family.
+# Clone explicitly so slow GitHub transfers are bounded and visible.
+RUN mkdir -p /opt/uv/tools /root/.local/share/uv \
+ && timeout 1800s git -c http.version=HTTP/1.1 clone --depth 1 --filter=blob:none https://github.com/github/spec-kit.git /tmp/spec-kit \
+ && UV_TOOL_BIN_DIR=/usr/local/bin UV_TOOL_DIR=/opt/uv/tools \
+ uv tool install specify-cli --from /tmp/spec-kit --python-preference system \
+ && ln -sfn /opt/uv/tools /root/.local/share/uv/tools \
+ && rm -rf /tmp/spec-kit \
+ || echo "spec-kit installation skipped (non-fatal)"
+
+RUN npm install -g @fission-ai/openspec@latest \
+ || echo "OpenSpec installation skipped (non-fatal)"
+
+# Shared OpenSpec/Speckit project bootstrapper. Keep it with the spec-driven
+# CLIs so every developer bench can initialize the same agent context files.
+COPY files/openspeckit/setup-openspeckit /usr/local/bin/setup-openspeckit
+RUN chmod 0755 /usr/local/bin/setup-openspeckit \
+ && ln -sfn setup-openspeckit /usr/local/bin/setup-openspec-speckit-project
+
+# OpenSpec Claude commands and skills live with the devBench OpenSpec CLI.
+RUN mkdir -p /etc/skel/.claude/commands/opsx \
+ && mkdir -p /etc/skel/.claude/skills/opsx-clarify \
+ && mkdir -p /etc/skel/.claude/skills/opsx-analyze
+COPY files/claude/commands/opsx/ /etc/skel/.claude/commands/opsx/
+COPY files/claude/skills/opsx-clarify/ /etc/skel/.claude/skills/opsx-clarify/
+COPY files/claude/skills/opsx-analyze/ /etc/skel/.claude/skills/opsx-analyze/
+
+# Shared, project-agnostic Speckit worktree helpers for all developer benches.
+COPY files/ct/ct-functions.zsh /usr/local/share/ct/ct-functions.zsh
+COPY files/ct/claude/ /usr/local/share/ct/claude/
+RUN chmod 0644 /usr/local/share/ct/ct-functions.zsh \
+ && chmod 0755 /usr/local/share/ct/claude/speckit-dashboard.sh \
+ /usr/local/share/ct/claude/speckit-dashboard-sync.sh \
+ /usr/local/share/ct/claude/speckit-dash-toggle.sh \
+ && chmod 0644 /usr/local/share/ct/claude/prompts/speckit-dashboard-full.md \
+ && mkdir -p /etc/skel/.claude/prompts \
+ && cp /usr/local/share/ct/claude/speckit-dashboard.sh /etc/skel/.claude/speckit-dashboard.sh \
+ && cp /usr/local/share/ct/claude/speckit-dashboard-sync.sh /etc/skel/.claude/speckit-dashboard-sync.sh \
+ && cp /usr/local/share/ct/claude/speckit-dash-toggle.sh /etc/skel/.claude/speckit-dash-toggle.sh \
+ && cp /usr/local/share/ct/claude/prompts/speckit-dashboard-full.md /etc/skel/.claude/prompts/speckit-dashboard-full.md \
+ && chmod 0755 /etc/skel/.claude/speckit-dashboard.sh \
+ /etc/skel/.claude/speckit-dashboard-sync.sh \
+ /etc/skel/.claude/speckit-dash-toggle.sh \
+ && chmod 0644 /etc/skel/.claude/prompts/speckit-dashboard-full.md
+
+# Speckit worktree bootstrap. This installs a stable command outside Speckit
+# itself so generated Speckit files can be refreshed and the worktree workflow
+# can be reapplied afterwards.
+COPY files/speckit-worktree/templates /usr/local/share/speckit-worktree/templates
+COPY files/speckit-worktree/speckit-worktree-enable /usr/local/bin/speckit-worktree-enable
+RUN chmod 0755 /usr/local/bin/speckit-worktree-enable && \
+ find /usr/local/share/speckit-worktree/templates -type f \( -name '*.sh' -o -name '*.zsh' -o -name '*.ps1' \) -exec chmod 0755 {} + && \
+ find /usr/local/share/speckit-worktree/templates -type f ! \( -name '*.sh' -o -name '*.zsh' -o -name '*.ps1' \) -exec chmod 0644 {} +
# ========================================
# AI META-HARNESS (OMNIGENT)
@@ -105,7 +174,18 @@ RUN mkdir -p /opt/corepack && \
# load testing, security scanning, accessibility, code quality, utilities)
# and preload Chromium into the shared Playwright cache for all dev benches.
COPY install-testing-tools.sh /tmp/
-RUN bash /tmp/install-testing-tools.sh && rm -f /tmp/install-testing-tools.sh
+RUN --mount=type=cache,target=/root/.cache/pip \
+ --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=cache,target=/root/.npm \
+ bash /tmp/install-testing-tools.sh && rm -f /tmp/install-testing-tools.sh
+
+# Container-safe SonarQube/SonarCloud environment. These helpers make auth
+# usable without libsecret.
+COPY files/sonarqube/sonarqube-cli-env.sh /usr/local/share/sonarqube/sonarqube-cli-env.sh
+COPY files/sonarqube/sonar-env /usr/local/bin/sonar-env
+RUN chmod 0644 /usr/local/share/sonarqube/sonarqube-cli-env.sh \
+ && chmod 0755 /usr/local/bin/sonar-env \
+ && ln -sfn /usr/local/share/sonarqube/sonarqube-cli-env.sh /etc/profile.d/sonarqube-cli.sh
# ========================================
# ZSH PLUGINS (into /etc/skel)
@@ -140,6 +220,15 @@ RUN if [ ! -f /etc/skel/.zshrc ]; then \
# Update /etc/skel/.zshrc to include plugins
RUN sed -i 's/plugins=(git)/plugins=(git zsh-autosuggestions zsh-syntax-highlighting)/' /etc/skel/.zshrc
+# Source Speckit worktree helpers from global shell startup so they remain
+# available even when benches mount a host ~/.zshrc over the generated one.
+RUN printf '\n# DevBench Speckit worktree helpers\n[[ -f /usr/local/share/ct/ct-functions.zsh ]] && source /usr/local/share/ct/ct-functions.zsh\n' >> /etc/zsh/zshrc && \
+ printf '\n# DevBench Speckit worktree helpers\n[ -f /usr/local/share/ct/ct-functions.zsh ] && . /usr/local/share/ct/ct-functions.zsh\n' >> /etc/bash.bashrc
+
+# Source SonarQube CLI env defaults from global interactive shell startup.
+RUN printf '\n# DevBench SonarQube CLI environment\n[ -f /usr/local/share/sonarqube/sonarqube-cli-env.sh ] && . /usr/local/share/sonarqube/sonarqube-cli-env.sh\n' >> /etc/zsh/zshrc && \
+ printf '\n# DevBench SonarQube CLI environment\n[ -f /usr/local/share/sonarqube/sonarqube-cli-env.sh ] && . /usr/local/share/sonarqube/sonarqube-cli-env.sh\n' >> /etc/bash.bashrc
+
# Make Grok available in zsh and bash shells created from /etc/skel.
RUN if ! grep -q '/opt/grok/bin' /etc/skel/.zshrc; then \
echo '' >> /etc/skel/.zshrc && \
diff --git a/devBenches/base-image/build.sh b/devBenches/base-image/build.sh
index 6e7b6a3..ab2e564 100755
--- a/devBenches/base-image/build.sh
+++ b/devBenches/base-image/build.sh
@@ -18,9 +18,11 @@ LEGACY_IMAGE="$(legacy_family_base_image dev)"
cd "$SCRIPT_DIR"
# Parse arguments (--user is accepted but ignored for backward compat)
+NO_CACHE="${NO_CACHE:-false}"
while [[ $# -gt 0 ]]; do
case $1 in
--user) shift 2 ;;
+ --no-cache) NO_CACHE=true; shift ;;
*) shift ;;
esac
done
@@ -28,6 +30,7 @@ done
echo "Configuration:"
echo " Tag: $CANONICAL_IMAGE (user-agnostic)"
echo " Legacy alias: $LEGACY_IMAGE"
+echo " No cache: $NO_CACHE"
echo ""
# Check if Layer 0 exists
@@ -43,6 +46,7 @@ fi
# Build the image
echo "Building $CANONICAL_IMAGE..."
docker build \
+ $([ "$NO_CACHE" = true ] && printf '%s\n' "--no-cache") \
-t "$CANONICAL_IMAGE" \
.
tag_family_base_legacy_alias dev
diff --git a/devBenches/base-image/files/claude/commands/opsx/apply.md b/devBenches/base-image/files/claude/commands/opsx/apply.md
new file mode 100644
index 0000000..3b9d1f9
--- /dev/null
+++ b/devBenches/base-image/files/claude/commands/opsx/apply.md
@@ -0,0 +1,226 @@
+---
+name: "OPSX: Apply"
+description: Implement tasks from an OpenSpec change using agent teams for parallel execution
+category: Workflow
+tags: [workflow, artifacts, experimental, teams]
+---
+
+Implement tasks from an OpenSpec change. Uses agent teams to parallelize independent tasks across non-overlapping file groups.
+
+**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+---
+
+## Phase 1: Select & Load Context
+
+1. **Select the change**
+
+ If a name is provided, use it. Otherwise:
+ - Infer from conversation context if the user mentioned a change
+ - Auto-select if only one active change exists
+ - If ambiguous, run `openspec list --json` and use **AskUserQuestion** to let the user select
+
+ Always announce: "Using change: "
+
+2. **Check status**
+ ```bash
+ openspec status --change "" --json
+ ```
+ Parse `schemaName`, artifact status, and which artifact contains tasks.
+
+3. **Get apply instructions**
+ ```bash
+ openspec instructions apply --change "" --json
+ ```
+ - If `state: "blocked"` (missing artifacts): show message, suggest `/opsx:propose`
+ - If `state: "all_done"`: congratulate, suggest `/opsx:archive`
+ - Otherwise: proceed
+
+4. **Read context files**
+
+ Read ALL files from `contextFiles` in the apply instructions output (proposal, design, specs, tasks, clarifications if present).
+
+5. **Show progress**
+ - Schema being used
+ - "N/M tasks complete"
+ - Remaining tasks overview
+
+---
+
+## Phase 2: Task Analysis & Grouping
+
+Before implementing, analyze the pending tasks to determine the execution strategy.
+
+### Step 1: Build the task dependency graph
+
+For each pending task, determine:
+- **Which files it will create or modify** (infer from the task description and design.md)
+- **Which tasks it depends on** (does it reference output from another task?)
+- **Which tasks are independent** (no shared files, no dependency)
+
+### Step 2: Choose execution strategy
+
+**Sequential (no team)** — Use when:
+- 5 or fewer pending tasks
+- Most tasks depend on each other linearly
+- Tasks touch overlapping files
+- The user asks to go one-by-one
+
+**Parallel (agent team)** — Use when:
+- 6+ pending tasks remaining
+- Tasks can be grouped into 2+ independent clusters
+- Clusters touch non-overlapping files
+
+If parallel, proceed to Phase 3. If sequential, skip to Phase 4.
+
+### Step 3: Group tasks into work packages
+
+Group independent tasks into **work packages**, where each package:
+- Contains tasks that share related files (same service, same model, same test file)
+- Has **zero file overlap** with other packages
+- Has its internal tasks ordered by dependency
+
+Example grouping:
+```
+Package A (buffer-service): Tasks 1.1, 1.2, 1.3, 2.1, 2.2 → touches buffer_service.dart, buffer_manifest.dart
+Package B (packaging-service): Tasks 6.1, 6.2, 7.1, 7.2 → touches packaging_service.dart, package_metadata.dart
+Package C (ui-widgets): Tasks 5.2, 5.3, 12.3 → touches orphan_dialog.dart, incomplete_dialog.dart
+Package D (integration-tests): Tasks 13.1, 13.2 → touches test/ files
+```
+
+**Dependencies between packages**: If Package B depends on Package A completing first, mark it. Only packages with no blockers get spawned in the first wave.
+
+---
+
+## Phase 3: Team Execution (Parallel)
+
+### Create the team
+Use **TeamCreate** to create a team (e.g., `apply-`).
+
+### Create task items
+Use **TaskCreate** for each work package. Include:
+- All tasks in the package with their descriptions
+- The files to create/modify
+- Context: which design.md sections and spec scenarios are relevant
+- Dependencies on other packages (use `addBlockedBy` if needed)
+
+### Spawn teammates
+Use the team subagent mechanism to spawn one `general-purpose` teammate per
+work package, in parallel. Each teammate must be launched into the team created
+above, for example:
+
+```text
+Task({
+ team_name: "apply-",
+ name: "",
+ subagent_type: "general-purpose",
+ run_in_background: true
+})
+```
+
+Do not use plain one-shot Agent subagents for this phase. They cannot claim
+team tasks, receive inbox messages, or participate in shutdown. Each teammate
+prompt must include:
+
+1. The team name
+2. The task ID to claim
+3. Full file paths for all context files (proposal, design, specs, clarifications)
+4. The specific tasks to implement, in order
+5. The files they own (create/modify only these)
+6. Instruction to mark tasks complete with `- [ ]` → `- [x]` in tasks.md — **but only their assigned tasks**
+7. Instruction to report back when done or blocked
+
+**CRITICAL file ownership rules:**
+- Each agent ONLY modifies files in its assigned package
+- `tasks.md` checkbox updates: each agent updates ONLY its own task checkboxes
+- If an agent discovers it needs to modify a file owned by another agent, it reports the dependency instead of making the change
+
+### Monitor & coordinate
+- Wait for agents to complete or report blockers
+- If an agent is blocked on another package, check if the blocking package is done
+- When a wave completes, check for newly-unblocked packages and spawn the next wave
+- Handle conflicts: if two agents report needing the same file, reassign one
+
+### Shutdown
+After all packages complete:
+- Send **shutdown_request** to all teammates
+- **TeamDelete** to clean up
+
+---
+
+## Phase 4: Sequential Execution (Fallback)
+
+For each pending task:
+- Show which task is being worked on
+- Make the code changes required
+- Keep changes minimal and focused
+- Mark task complete: `- [ ]` → `- [x]`
+- Continue to next task
+
+**Pause if:**
+- Task is unclear → ask for clarification
+- Implementation reveals a design issue → suggest updating artifacts
+- Error or blocker encountered → report and wait for guidance
+- User interrupts
+
+---
+
+## Phase 5: Completion
+
+### Show final status
+
+```
+## Implementation Complete
+
+**Change:**
+**Schema:**
+**Strategy:** [Sequential | Parallel — N agents, M waves]
+**Progress:** N/N tasks complete
+
+### Completed This Session
+- [x] Task 1.1 — description
+- [x] Task 1.2 — description
+...
+
+All tasks complete! Run `/opsx:archive` to archive this change.
+```
+
+### On pause (issue encountered)
+
+```
+## Implementation Paused
+
+**Change:**
+**Schema:**
+**Progress:** N/M tasks complete
+
+### Issue Encountered
+
+
+**Options:**
+1.